//! The colour theme for a request. A signed-in user's stored preference wins. Anonymous //! visitors use the `forge_theme` cookie, which the footer form sets. The default is light: a //! visitor with no explicit choice gets light whatever their operating system uses. Only an //! explicit `auto` renders no `data-theme`, so the stylesheet follows the operating system. use std::time::Duration; use forge_core::User; use topcoat::context::Cx; use topcoat::cookie::{Cookie, Cookies, SameSite, cookies, time}; /// Name of the anonymous theme cookie. pub const THEME_COOKIE: &str = "forge_theme"; /// Lifetime of the anonymous theme cookie. pub const THEME_COOKIE_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60); /// The stored colour preference for this request: `"auto"`, `"light"` or `"dark"`. A signed-in /// user's stored preference wins. Anonymous visitors use the cookie. A missing, empty or /// unknown value reads as `"light"`, the default. Only an explicit `"auto"` follows the system. pub fn preference(cx: &Cx, viewer: Option<&User>) -> &'static str { let stored = match viewer { Some(user) => user.theme.clone(), None => cookies(cx) .get(THEME_COOKIE) .map(|cookie| cookie.value().to_owned()) .unwrap_or_default(), }; resolve_preference(&stored) } /// Maps a stored value to a preference. Only `"auto"` and `"dark"` are kept as they are. Every /// other value, including the empty string, means the default, `"light"`. pub fn resolve_preference(stored: &str) -> &'static str { match stored { "auto" => "auto", "dark" => "dark", _ => "light", } } /// The `data-theme` value for this request: `Some("light")` or `Some("dark")`, or `None` when the /// page should follow the operating system. pub fn data_theme(cx: &Cx, viewer: Option<&User>) -> Option<&'static str> { match preference(cx, viewer) { "auto" => None, theme => Some(theme), } } /// The preference the top-bar button switches to from `current`: light, then dark, then auto, /// then back to light. Anything that is not `"light"` or `"dark"` (including `"auto"`) moves on /// to light, the first step of the cycle. pub fn next_preference(current: &str) -> &'static str { match current { "light" => "dark", "dark" => "auto", _ => "light", } } /// Sets the anonymous theme cookie. The caller has already checked that `theme` is valid. pub fn set_theme_cookie(cx: &Cx, theme: &str) { let max_age = time::Duration::seconds(i64::try_from(THEME_COOKIE_TTL.as_secs()).unwrap_or(i64::MAX)); cookies(cx).add( Cookie::build((THEME_COOKIE, theme.to_owned())) .path("/") .secure(true) .http_only(true) .same_site(SameSite::Lax) .max_age(max_age) .build(), ); } #[cfg(test)] mod tests { use super::{next_preference, resolve_preference}; #[test] fn the_toggle_cycles_light_dark_auto_light() { assert_eq!(next_preference("light"), "dark"); assert_eq!(next_preference("dark"), "auto"); assert_eq!(next_preference("auto"), "light"); } #[test] fn unknown_preferences_move_on_to_light() { assert_eq!(next_preference(""), "light"); assert_eq!(next_preference("neon"), "light"); } #[test] fn no_choice_means_light() { assert_eq!(resolve_preference(""), "light"); assert_eq!(resolve_preference("neon"), "light"); assert_eq!(resolve_preference("light"), "light"); } #[test] fn explicit_choices_are_kept() { assert_eq!(resolve_preference("dark"), "dark"); assert_eq!(resolve_preference("auto"), "auto"); } #[test] fn the_default_cycle_starts_on_light_and_reaches_every_choice() { let start = resolve_preference(""); assert_eq!(start, "light"); assert_eq!(next_preference(start), "dark"); assert_eq!(next_preference(next_preference(start)), "auto"); assert_eq!( next_preference(next_preference(next_preference(start))), "light" ); } }