Each browser keeps its own theme, in a cookie (#69)
The theme was kept on the account, so every browser signed in as the same person got the same one: no Glass on the phone with Dracula on the desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by theme.ts, and read by the server to draw the page in it from the first frame as before. /api/me no longer reports or takes a theme, and set_theme is gone. A browser with no cookie yet is sent the theme the account kept, and takes it as its cookie on that first load, so nobody loses their choice in the move. users.theme and theme_mode are only read now, for that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
18
src/db.rs
18
src/db.rs
@@ -1517,7 +1517,11 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The theme this person chose, and light, dark or auto; None for either until they choose.
|
||||
/// The theme this person chose when it was kept on the account, and light, dark or auto; None
|
||||
/// for either if they never did. Only read now, to give a browser with no theme cookie its
|
||||
/// first one (issue #69).
|
||||
// ponytail: users.theme and theme_mode are never written any more; drop them once every
|
||||
// browser in use has its own cookie.
|
||||
pub async fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
|
||||
Ok(users::Entity::find_by_id(user_id)
|
||||
.one(&self.orm)
|
||||
@@ -1526,18 +1530,6 @@ impl Db {
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
|
||||
self.update_user(
|
||||
user_id,
|
||||
users::ActiveModel {
|
||||
theme: Set(Some(theme.to_owned())),
|
||||
theme_mode: Set(Some(mode.to_owned())),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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 async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
|
||||
|
||||
47
src/web.rs
47
src/web.rs
@@ -1,6 +1,7 @@
|
||||
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
@@ -252,7 +253,11 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
||||
}
|
||||
|
||||
fn cookie(req: &Request, name: &str) -> Option<String> {
|
||||
req.headers()
|
||||
headers_cookie(req.headers(), name)
|
||||
}
|
||||
|
||||
fn headers_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|c| {
|
||||
@@ -335,38 +340,25 @@ async fn me(
|
||||
) -> Json<serde_json::Value> {
|
||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
||||
Json(serde_json::json!({
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||
"blocked": blocked,
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MePatch {
|
||||
theme: Option<String>,
|
||||
mode: Option<String>,
|
||||
/// Words that hide an item in every feed you read.
|
||||
blocked: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Saves the theme to the account, so it follows the person rather than the browser, and the
|
||||
/// block list for every feed.
|
||||
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
|
||||
/// own, in a cookie (issue #69).
|
||||
async fn patch_me(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<MePatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if body.theme.is_some() || body.mode.is_some() {
|
||||
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
|
||||
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
||||
// into the page's <html> tag, which is where index() puts it.
|
||||
if !theme_ok(&theme, &mode) {
|
||||
return Err(ApiError::bad_request("not a theme"));
|
||||
}
|
||||
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
|
||||
}
|
||||
if let Some(words) = body.blocked {
|
||||
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||
}
|
||||
@@ -530,13 +522,26 @@ async fn app_css() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// The cookie the page keeps its theme in, `<theme>.<mode>`, written by theme.ts.
|
||||
const THEME_COOKIE: &str = "ipx_theme";
|
||||
|
||||
/// The theme this browser chose, from its cookie: per device, so a phone and a desktop signed in
|
||||
/// as the same person can each have their own (issue #69). A browser without one yet gets the
|
||||
/// theme the account kept from before, which theme.ts then writes into the cookie.
|
||||
async fn page_theme(state: &WebState, user: &crate::db::User, headers: &HeaderMap) -> (Option<String>, Option<String>) {
|
||||
if let Some((t, m)) = headers_cookie(headers, THEME_COOKIE).as_deref().and_then(|v| v.split_once('.')) {
|
||||
return (Some(t.to_owned()), Some(m.to_owned()));
|
||||
}
|
||||
state.ctx.db.theme(user.id).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
||||
/// sent. Anyone else asking for the page is sent back to the app.
|
||||
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
|
||||
async fn admin_page(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> Response {
|
||||
if !user.is_admin {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
let theme = page_theme(&state, &user, &headers).await;
|
||||
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||
}
|
||||
@@ -636,8 +641,8 @@ const VERSION_SLOT: &str = "iPX {version}";
|
||||
|
||||
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
async fn index(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> impl IntoResponse {
|
||||
let theme = page_theme(&state, &user, &headers).await;
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user