diff --git a/mobile/index.html b/mobile/index.html index 90d7572..8a97b47 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -552,13 +552,15 @@ async function pickHost() { } // ---------- live state ---------- +// The person's time zone, for a brain on a server that only knows UTC. +const TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (_) { return ""; } })(); async function start() { if (S.open) openChat(S.open); else show("list"); if (S.polling) return; S.polling = true; while (S.token) { try { - const v = await api(base() + "api/state?since=" + S.version); + const v = await api(base() + "api/state?since=" + S.version + "&tz=" + encodeURIComponent(TZ)); if (v.version !== S.version) { S.version = v.version; apply(v); } } catch (e) { if (!S.token) break; diff --git a/src/app.rs b/src/app.rs index a3e7e6c..89515e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -219,6 +219,7 @@ impl App { // ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo. let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err(); let state = if demo { State::demo() } else { State::load() }; + crate::routines::set_person_timezone(&state.timezone); let (tx, rx) = channel(); let mut app = App { state, diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index b70a1b2..ddb6b03 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -207,6 +207,7 @@ impl App { fn remote_signature(&self) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); self.state.remote.host_name.hash(&mut h); + self.state.timezone.hash(&mut h); self.scr.cfg_hash.hash(&mut h); for b in &self.state.bots { (b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h); @@ -243,6 +244,7 @@ impl App { } self.rm.sig = sig; self.rm.last_publish = Some(Instant::now()); + crate::routines::set_person_timezone(&self.state.timezone); let names: HashMap = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect(); let mut bots: Vec<&Bot> = self.state.bots.iter().collect(); // Pinned first, then the most recently active. @@ -349,6 +351,14 @@ impl App { self.start_reply(ctx, bot); } Cmd::Stop { bot } => self.stop(bot), + // The server's own zone (UTC) was never the person's choice: take theirs. A zone the + // person set on purpose stays. + Cmd::PersonTimezone(tz) => { + if crate::routines::is_server_default_zone(&self.state.timezone) && tz != self.state.timezone { + self.state.timezone = tz; + self.mark(); + } + } Cmd::SetBot { bot, provider, model, computer } => { let kind = serde_json::from_value::(json!(provider)).ok(); if let (Some(k), false) = (kind, model.is_empty()) { diff --git a/src/pages.rs b/src/pages.rs index c8e52df..26cf739 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -177,7 +177,7 @@ pub fn list(pages: &[Page], bot: u64) -> String { /// "11/5 09:33" in the local time zone. pub fn when(ms: i64) -> String { use chrono::TimeZone; - chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default() + crate::routines::person_tz().timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default() } /// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」. @@ -190,7 +190,8 @@ pub fn ago(ms: i64, now: i64) -> String { if d < 3600 { return format!("{} 分鐘前", d / 60); } - let (Some(t), Some(n)) = (chrono::Local.timestamp_millis_opt(ms).single(), chrono::Local.timestamp_millis_opt(now).single()) else { return String::new() }; + let tz = crate::routines::person_tz(); + let (Some(t), Some(n)) = (tz.timestamp_millis_opt(ms).single(), tz.timestamp_millis_opt(now).single()) else { return String::new() }; let days = (n.date_naive() - t.date_naive()).num_days(); match days { 0 => format!("今天 {}", t.format("%H:%M")), diff --git a/src/remote.rs b/src/remote.rs index 803d5a3..ac66b90 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -121,6 +121,8 @@ pub enum Cmd { Answer { bot: u64, answer: String }, Read { bot: u64 }, NewThread { bot: u64 }, + /// The time zone of the phone the person uses (a brain on a server only knows UTC). + PersonTimezone(String), /// The phone changes a bot's model or its computer (empty = unchanged). SetBot { bot: u64, provider: String, model: String, computer: String }, /// Answer the last message again (重新產生) / reply to what is there (重新傳送). @@ -354,6 +356,15 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { match (req.method.as_str(), path.as_slice()) { ("GET", ["api", "state"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); + // The phone says where the person is (once per change, not every poll). + if let Some(tz) = req.query.get("tz").filter(|t| t.parse::().is_ok()) { + static TOLD: Mutex = Mutex::new(String::new()); + let mut told = TOLD.lock().unwrap_or_else(|e| e.into_inner()); + if *told != *tz { + *told = tz.clone(); + hub().push(Cmd::PersonTimezone(tz.clone())); + } + } let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); // 頁面 for the phone's home: the newest first, each with its bot. let now = crate::model::now_ms(); diff --git a/src/routines.rs b/src/routines.rs index 3321cb6..209381e 100644 --- a/src/routines.rs +++ b/src/routines.rs @@ -97,6 +97,23 @@ pub fn local_timezone() -> String { iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into()) } +/// The person's time zone (their setting), for times shown to them by an AnyBot whose own clock +/// may be elsewhere: a brain on a server runs in UTC, and a page 「更新於 今天 01:03」 meant 09:03. +static PERSON_TZ: std::sync::Mutex = std::sync::Mutex::new(String::new()); + +/// A zone that only says where the computer is, not where the person is (a server's UTC). +pub fn is_server_default_zone(tz: &str) -> bool { + matches!(tz.trim(), "" | "UTC" | "Etc/UTC" | "Etc/Universal" | "Universal" | "Zulu" | "GMT" | "Etc/GMT") +} + +pub fn set_person_timezone(tz: &str) { + *PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner()) = tz.to_string(); +} + +pub fn person_tz() -> Tz { + tz_of(&PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner())) +} + fn tz_of(name: &str) -> Tz { name.parse::().or_else(|_| local_timezone().parse::()).unwrap_or(chrono_tz::Asia::Taipei) } @@ -369,6 +386,12 @@ pub fn now_in(timezone: &str) -> DateTime { #[cfg(test)] mod tests { + #[test] + fn a_servers_utc_is_not_the_persons_zone() { + assert!(super::is_server_default_zone("Etc/UTC") && super::is_server_default_zone("") && super::is_server_default_zone("UTC")); + assert!(!super::is_server_default_zone("Asia/Taipei") && !super::is_server_default_zone("Europe/London")); + } + use super::*; fn ts(y: i32, m: u32, d: u32, h: u32, mi: u32) -> i64 {