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 {