//! Page: appearance (`/settings/appearance`). The colour theme and the language, both stored per //! user. Anonymous visitors set the theme from the footer instead (see `pages/theme_cookie.rs`). //! Every POST checks the CSRF token first. use forge_core::AuditAction; use forge_core::store::ui_prefs::THEMES; use forge_core::{Error as CoreError, Session, Store, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::{page, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::misc::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::state::AppState; use crate::views::shell; /// Language codes offered on the page. `auto` follows the browser. Other codes must be /// translated in `i18n/mod.rs` before they are added here. pub const LANGUAGE_CODES: &[&str] = &["auto", "en", "tr"]; /// Theme codes, in display order. const THEME_CODES: &[&str] = &["auto", "light", "dark"]; #[query_params(error = bad_request)] struct AppearanceQuery { saved: Option, } #[derive(Deserialize)] struct AppearanceForm { #[serde(default)] csrf: String, #[serde(default)] theme: String, #[serde(default)] language: String, } struct Choice { value: &'static str, label: &'static str, selected: bool, } struct AppearancePage { lang: Lang, csrf: String, theme: Vec, language: Vec, notice: Option<&'static str>, } /// The label of a language code. The two native names stay as they are in every language. fn language_label(lang: Lang, code: &str) -> &'static str { match code { "auto" => g::t(lang, K::MatchBrowser), "en" => "English", "tr" => "T\u{fc}rk\u{e7}e", _ => "", } } /// The label of a theme code. Light is the default for everyone who has not chosen, so it is /// marked as the default. fn theme_label(lang: Lang, code: &str) -> &'static str { match code { "light" => match lang { Lang::En => "Light (default)", Lang::Tr => "A\u{e7}\u{131}k (varsay\u{131}lan)", }, "dark" => g::t(lang, K::Dark), _ => g::t(lang, K::MatchSystem), } } fn choices( codes: &[&'static str], current: &str, label: impl Fn(&str) -> &'static str, ) -> Vec { codes .iter() .map(|&value| Choice { value, label: label(value), selected: value == current, }) .collect() } #[component] async fn appearance_body(page: AppearancePage) -> Result { let AppearancePage { lang, csrf, theme, language, notice, } = page; Ok(view! {

(g::t(lang, K::Appearance))

if let Some(message) = notice {

(message)

}

(g::t(lang, K::ThemeAndLanguage))

}) } /// Rejects a theme or language that is not offered. Nothing is saved for either. fn check_choice(lang: Lang, theme: &str, language: &str) -> forge_core::Result<()> { if !THEMES.contains(&theme) { return Err(CoreError::Invalid(g::t(lang, K::ChooseTheme).into())); } if !LANGUAGE_CODES.contains(&language) { return Err(CoreError::Invalid(g::t(lang, K::ChooseLanguage).into())); } Ok(()) } fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> { match saved { Some("1") => Some(g::t(lang, K::AppearanceSaved)), _ => None, } } /// Builds the page for a viewer. `theme` and `language` are the stored values. fn page_for(viewer: &(User, Session), lang: Lang, notice: Option<&'static str>) -> AppearancePage { AppearancePage { lang, csrf: viewer.1.csrf_token.clone(), theme: choices(THEME_CODES, &viewer.0.theme, |code| theme_label(lang, code)), language: choices(LANGUAGE_CODES, &viewer.0.language, |code| { language_label(lang, code) }), notice, } } #[page("/settings/appearance")] async fn appearance_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let saved = query_params::(cx)?.saved.clone(); let page = page_for(&viewer, lang, notice_for(lang, saved.as_deref())); Ok(view! { shell(title: g::t(lang, K::Appearance), viewer: Some(&viewer), appearance_body(page: page)) }) } #[route(POST "/settings/appearance")] async fn appearance_save(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let store: &Store = &app_context::(cx).store; check_choice(lang, &form.theme, &form.language)?; store .set_ui_prefs(&viewer.0.id, &form.theme, &form.language) .await?; audit::record( store, Some(cx), Actor::User(&viewer.0), AuditAction::UiPrefsChanged, Target { kind: "user", id: &viewer.0.id, name: &viewer.0.username, }, serde_json::json!({ "theme": form.theme, "language": form.language }), ) .await; Ok(see_other("/settings/appearance?saved=1")) } #[cfg(test)] mod tests { use super::*; #[test] fn only_offered_choices_pass() { assert!(check_choice(Lang::En, "dark", "tr").is_ok()); assert!(check_choice(Lang::Tr, "auto", "auto").is_ok()); assert!(check_choice(Lang::En, "neon", "en").is_err()); assert!(check_choice(Lang::En, "light", "de").is_err()); assert!(check_choice(Lang::Tr, "light", "").is_err()); } #[test] fn the_stored_value_is_selected() { let options = choices(THEME_CODES, "dark", |code| theme_label(Lang::En, code)); let selected: Vec<&str> = options .iter() .filter(|c| c.selected) .map(|c| c.value) .collect(); assert_eq!(selected, ["dark"]); } #[test] fn labels_follow_the_page_language() { assert_eq!(theme_label(Lang::En, "dark"), "Dark"); assert_eq!(theme_label(Lang::Tr, "dark"), "Koyu"); } #[test] fn light_is_marked_as_the_default() { assert_eq!(theme_label(Lang::En, "light"), "Light (default)"); assert_eq!( theme_label(Lang::Tr, "light"), "A\u{e7}\u{131}k (varsay\u{131}lan)" ); assert_eq!(language_label(Lang::Tr, "tr"), "T\u{fc}rk\u{e7}e"); } }