diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c0b3a..1f814d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The long form, with what was wrong before and how it was found, is in Classic and Modern (the existing dark and light). Each that comes both ways has its own Light, Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them. A theme chosen before this carries over. +- Your theme is kept on your account rather than in the browser, so it follows you to another + browser or computer, and the page arrives in it with no flash of the default. The theme a + browser already had is saved to your account the first time you load the page. - Touch gestures: pull the item list down from its top to check the feed for new items, and swipe the item you are reading left for the next one and right for the one before, or back to the list from the first. diff --git a/src/db.rs b/src/db.rs index f9731cf..74365d6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -81,7 +81,10 @@ CREATE TABLE IF NOT EXISTS users ( is_admin INTEGER NOT NULL DEFAULT 0, -- For whoever maintains the server. NULL where it is not known. created INTEGER, - last_login INTEGER + last_login INTEGER, + -- The theme chosen in Settings, and light, dark or auto. NULL until one is chosen. + theme TEXT, + theme_mode TEXT ); -- What one person wants from a feed. The feed, its items and its files are shared; this @@ -176,6 +179,9 @@ fn migrate(conn: &Connection) -> Result<()> { ("feeds", "error_since", "INTEGER"), ("feeds", "category", "TEXT"), ("entry_state", "duration", "INTEGER"), + // Kept per account so a theme follows you to another browser; it was in localStorage. + ("users", "theme", "TEXT"), + ("users", "theme_mode", "TEXT"), ]; let retired: &[(&str, &str)] = &[ // Read state from before accounts, long since moved to entry_state. Two bugs came from @@ -1180,6 +1186,23 @@ impl Db { Ok(()) } + /// The theme this person chose, and light, dark or auto; None for either until they choose. + pub fn theme(&self, user_id: i64) -> Result<(Option, Option)> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| { + Ok((r.get(0)?, r.get(1)?)) + })?) + } + + pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1", + params![user_id, theme, mode], + )?; + Ok(()) + } + /// The user behind a session cookie, if it is still live. Idle sessions expire after /// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive. pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result> { diff --git a/src/web.rs b/src/web.rs index 4402e78..f4e6f58 100644 --- a/src/web.rs +++ b/src/web.rs @@ -36,7 +36,7 @@ pub struct WebState { pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) - .route("/api/me", get(me)) + .route("/api/me", get(me).patch(patch_me)) .route("/api/logout", post(logout)) .route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) @@ -317,7 +317,37 @@ async fn me( ) -> Json { let url = state.ctx.cfg().web.sign_out_url.clone(); let sign_out = (by_proxy && !url.is_empty()).then_some(url); - Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out })) + let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default(); + Json(serde_json::json!({ + "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, + })) +} + +#[derive(Deserialize)] +struct MePatch { + theme: String, + mode: String, +} + +/// Saves the theme to the account, so it follows the person rather than the browser. +async fn patch_me( + State(state): State, + user: crate::db::User, + Json(body): Json, +) -> Result { + // The page's script knows the themes; this only makes sure what is kept is safe to write + // into the page's tag, which is where index() puts it. + if !theme_ok(&body.theme, &body.mode) { + return Err(ApiError::bad_request("not a theme")); + } + state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?; + Ok(StatusCode::NO_CONTENT) +} + +fn theme_ok(theme: &str, mode: &str) -> bool { + (1..=20).contains(&theme.len()) + && theme.bytes().all(|b| b.is_ascii_lowercase()) + && ["light", "dark", "auto"].contains(&mode) } // ---- accounts: admin only ---- @@ -524,16 +554,29 @@ const LOG_BUTTON: &str = " ${admin?``:''}`); - $('#stheme').onchange=e=>setTheme(e.target.value); - $('#smode').onchange=e=>setTheme(undefined,e.target.value); + $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); + $('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#gopml').onclick=opmlModal; if(!admin) return; $('#gusers').onclick=usersModal; diff --git a/web/src/theme.ts b/web/src/theme.ts index 1e61691..d193713 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,7 +1,9 @@ /* ---------------- theme ---------------- */ -// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in -// ipx.theme and ipx.mode. The page gets data-theme and data-mode, light or dark, which is all -// the CSS reads: Auto is worked out here, from the system, so no palette is written twice. +// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on +// the account, so it follows you to another browser or computer. The server writes it onto the +// page's tag (data-theme, data-choice) so the page is drawn in it from the start. The +// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from +// the system, so no palette is written twice. const THEMES: Record = { modern: {name: 'Modern', modes: true}, classic: {name: 'Classic, the 2004 Mac app', modes: false}, @@ -13,12 +15,13 @@ const THEMES: Record = { nordic: {name: 'Nordic', modes: true}, }; const MODES: Record = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'}; -// Before themes came in light and dark, ipx.theme held one of these. +// Before themes came in light and dark, ipx.theme in localStorage held one of these. const OLD_THEMES: Record = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']}; const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)'); const theme = {name: 'modern', mode: 'dark'}; -function setTheme(name = theme.name, mode = theme.mode){ +/// `save` for a choice made in Settings, which goes to the account; not for applying one. +function setTheme(name = theme.name, mode = theme.mode, save = false){ theme.name = THEMES[name] ? name : 'modern'; theme.mode = MODES[mode] ? mode : 'dark'; const both = THEMES[theme.name].modes; @@ -30,11 +33,26 @@ function setTheme(name = theme.name, mode = theme.mode){ const sel = $('#stheme'); if(sel) sel.value = theme.name; const ms = $('#smode'); if(ms) ms.value = theme.mode; const mf = $('#smodefield'); if(mf) mf.hidden = !both; - try{ localStorage.setItem('ipx.theme', theme.name); localStorage.setItem('ipx.mode', theme.mode); }catch{} + if(save) saveTheme(); +} + +/// One save at a time, each sending the choice as it stands when it goes. Sent as they came, +/// several at once, a quick run through the list could reach the server out of order and +/// leave the account on a theme passed on the way. +let themeSaving = Promise.resolve(); +function saveTheme(){ + themeSaving = themeSaving + .then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})})) + .catch(e => toast(`Your theme was not saved: ${e.message}`, true)); } systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); }); -try{ - let name = localStorage.getItem('ipx.theme'), mode = localStorage.getItem('ipx.mode'); +(() => { + const root = document.documentElement; + if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice); + // Nothing on the account yet. A theme this browser kept, from before themes were kept on the + // account, goes up to it once, so nobody has to choose again. + let name: string | null = null, mode: string | null = null; + try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{} if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name]; - setTheme(name ?? undefined, mode ?? undefined); -}catch{ setTheme(); } + setTheme(name ?? undefined, mode ?? undefined, !!name); +})();