From 130f27f8f3dc89f79031c3cba2886471890f5b63 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Sat, 7 Nov 2026 02:01:28 -0500 Subject: [PATCH 1/4] fix(web): timestamps follow the system time zone without a reload Intl formatters resolve the default time zone when they are built, so the formatters cached at module load kept the zone the app started in. After the OS zone changed, every timestamp rendered in the old zone until a window reload. The web cache and both mobile formatters now key on the current zone and rebuild when it changes. Fixes #32355 Signed-off-by: Tiger Kaovilai --- .../features/cloud/T3ConnectProfilePage.tsx | 4 +- .../src/features/threads/ThreadFeed.tsx | 13 ++++- apps/web/src/timestampFormat.test.ts | 26 ++++++++- apps/web/src/timestampFormat.ts | 53 ++++++++++--------- 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 03b6daaa4e715..6a283f4ca230d 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -21,13 +21,11 @@ import { useManagedRelayEnvironments, } from "./managedRelayState"; -const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); - function linkedAtLabel(value: string): string { const linkedAt = new Date(value); return Number.isNaN(linkedAt.getTime()) ? "Link date unavailable" - : `Linked ${linkedAtFormatter.format(linkedAt)}`; + : `Linked ${new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(linkedAt)}`; } function endpointLabel(environment: RelayClientEnvironmentRecord): string { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d6fdc9e955cde..eb34c3e06fc3b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,7 +294,8 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { +// A formatter pins the default zone when built; rebuild when the device zone changes. +let messageTimeFormatter = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); @@ -303,7 +304,15 @@ function formatMessageTime(input: string): string { if (Number.isNaN(timestamp)) { return ""; } - return MESSAGE_TIME_FORMATTER.format(timestamp); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (messageTimeFormatter.resolvedOptions().timeZone !== timeZone) { + messageTimeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone, + }); + } + return messageTimeFormatter.format(timestamp); } const TURN_FOLD_HEIGHT = 42; diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5b5e11d812027..c90d52fe42608 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; +import { + formatDayAwareTimestamp, + formatShortTimestamp, + formatUpcomingTimestamp, +} from "./timestampFormat"; describe("timestamps in an explicit time zone", () => { // 23:00 UTC on July 1 is 7 PM on July 1 in New York, whatever the host zone. @@ -19,3 +23,23 @@ describe("timestamps in an explicit time zone", () => { ); }); }); + +describe("timestamps in the system time zone", () => { + it("follows a system time zone change without a reload", () => { + const instant = "2026-11-07T04:45:43.877Z"; + const now = Date.parse("2026-11-20T12:00:00.000Z"); + const originalTimeZone = process.env.TZ; + try { + process.env.TZ = "America/New_York"; + expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 PM"); + expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/6 11:45 PM"); + + process.env.TZ = "Asia/Bangkok"; + expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 AM"); + expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/7 11:45 AM"); + } finally { + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; + } + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index abad91401ccbe..215015b02e6c8 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -68,27 +68,37 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin export const weekStartsOn = resolveWeekStartsOn(timestampLocale); -const timestampFormatterCache = new Map(); +const formatterCache = new Map(); -function getTimestampFormatter( - timestampFormat: TimestampFormat, - includeSeconds: boolean, - timeZone?: string, +// A formatter pins the default zone when built, so key on the current one to follow OS zone changes. +function getCachedFormatter( + name: string, + options: Intl.DateTimeFormatOptions, + timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; - const cachedFormatter = timestampFormatterCache.get(cacheKey); + const cacheKey = `${name}:${timeZone}`; + const cachedFormatter = formatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } - const formatter = new Intl.DateTimeFormat(timestampLocale, { - ...getTimestampFormatOptions(timestampFormat, includeSeconds), - ...(timeZone ? { timeZone } : {}), - }); - timestampFormatterCache.set(cacheKey, formatter); + const formatter = new Intl.DateTimeFormat(timestampLocale, { ...options, timeZone }); + formatterCache.set(cacheKey, formatter); return formatter; } +function getTimestampFormatter( + timestampFormat: TimestampFormat, + includeSeconds: boolean, + timeZone?: string, +): Intl.DateTimeFormat { + return getCachedFormatter( + `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`, + getTimestampFormatOptions(timestampFormat, includeSeconds), + timeZone, + ); +} + export function resolveHour12(timestampFormat: TimestampFormat): boolean { return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false; } @@ -142,11 +152,6 @@ const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { ...numericDateOptions, year: "numeric", }; -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); -const numericDateWithYearFormatter = new Intl.DateTimeFormat( - timestampLocale, - numericDateWithYearOptions, -); interface CalendarDay { year: number; @@ -172,13 +177,11 @@ function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { } function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { - if (!timeZone) { - return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date); - } - return new Intl.DateTimeFormat(timestampLocale, { - ...(sameYear ? numericDateOptions : numericDateWithYearOptions), - timeZone, - }).format(date); + return ( + sameYear + ? getCachedFormatter("numericDate", numericDateOptions, timeZone) + : getCachedFormatter("numericDateWithYear", numericDateWithYearOptions, timeZone) + ).format(date); } export function formatDayAwareTimestamp( @@ -200,7 +203,7 @@ export function formatDayAwareTimestamp( return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; } -/** `timeZone` overrides the zone the formatters cached when they were built. */ +/** `timeZone` overrides the system time zone. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, From b364cd37d4de90a82ec933366df6c20bfce5fb79 Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Sat, 7 Nov 2026 02:14:39 -0500 Subject: [PATCH 2/4] fix(web): month names and probes follow the live time zone The tooltip's month name still pinned its zone at module load, so a zone change across a month boundary printed the wrong month. It now uses the same zone-aware cache as the rest of the timestamps. Public formatting entry points probe the system zone once and pass it down instead of rebuilding a probe formatter on every lookup, and mobile re-probes only when the UTC offset moves. Signed-off-by: Tiger Kaovilai --- .../src/features/threads/ThreadFeed.tsx | 28 +++++---- apps/web/src/timestampFormat.test.ts | 17 +++++ apps/web/src/timestampFormat.ts | 63 +++++++++++++------ 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index eb34c3e06fc3b..bfb986ee74922 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,23 +294,27 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -// A formatter pins the default zone when built; rebuild when the device zone changes. -let messageTimeFormatter = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", -}); +// A formatter pins the default zone when built. Probing the zone builds a formatter too, so +// re-probe only when the UTC offset moves: that catches DST and almost every zone change. +// Missed: a switch between zones with the same current offset; it heals at the next offset change. +const MESSAGE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" }; +let messageTimeFormatter = new Intl.DateTimeFormat(undefined, MESSAGE_TIME_OPTIONS); +let messageTimeOffset = new Date().getTimezoneOffset(); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (messageTimeFormatter.resolvedOptions().timeZone !== timeZone) { - messageTimeFormatter = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - timeZone, - }); + const offset = new Date().getTimezoneOffset(); + if (offset !== messageTimeOffset) { + messageTimeOffset = offset; + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (messageTimeFormatter.resolvedOptions().timeZone !== timeZone) { + messageTimeFormatter = new Intl.DateTimeFormat(undefined, { + ...MESSAGE_TIME_OPTIONS, + timeZone, + }); + } } return messageTimeFormatter.format(timestamp); } diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c90d52fe42608..245affb999ba3 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + formatChatTimestampTooltip, formatDayAwareTimestamp, formatShortTimestamp, formatUpcomingTimestamp, @@ -42,4 +43,20 @@ describe("timestamps in the system time zone", () => { else process.env.TZ = originalTimeZone; } }); + + it("names the month in the current system time zone", () => { + // Oct 31 in New York, Nov 1 in Bangkok. + const instant = "2026-10-31T23:45:00.000Z"; + const originalTimeZone = process.env.TZ; + try { + process.env.TZ = "America/New_York"; + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe("7:45 PM, 31st October 2026"); + + process.env.TZ = "Asia/Bangkok"; + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe("6:45 AM, 1st November 2026"); + } finally { + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; + } + }); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 215015b02e6c8..8ffd732457775 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -71,10 +71,17 @@ export const weekStartsOn = resolveWeekStartsOn(timestampLocale); const formatterCache = new Map(); // A formatter pins the default zone when built, so key on the current one to follow OS zone changes. +// The probe builds a formatter, so public entry points take it once and pass it down. Reusing it +// while getTimezoneOffset() is unchanged would miss switches between same-offset zones. +function currentTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + function getCachedFormatter( name: string, options: Intl.DateTimeFormatOptions, - timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone, + timeZone: string, + locale: string | undefined = timestampLocale, ): Intl.DateTimeFormat { const cacheKey = `${name}:${timeZone}`; const cachedFormatter = formatterCache.get(cacheKey); @@ -82,7 +89,7 @@ function getCachedFormatter( return cachedFormatter; } - const formatter = new Intl.DateTimeFormat(timestampLocale, { ...options, timeZone }); + const formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone }); formatterCache.set(cacheKey, formatter); return formatter; } @@ -90,7 +97,7 @@ function getCachedFormatter( function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, - timeZone?: string, + timeZone: string, ): Intl.DateTimeFormat { return getCachedFormatter( `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`, @@ -100,7 +107,10 @@ function getTimestampFormatter( } export function resolveHour12(timestampFormat: TimestampFormat): boolean { - return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false; + return ( + getTimestampFormatter(timestampFormat, false, currentTimeZone()).resolvedOptions().hour12 ?? + false + ); } export function parseTimestampDate(isoDate: string): Date | null { @@ -111,7 +121,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". -const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); +const monthNameOptions: Intl.DateTimeFormatOptions = { month: "long" }; function ordinalSuffix(day: number): string { const lastTwo = day % 100; @@ -134,9 +144,10 @@ export function formatChatTimestampTooltip( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = formatShortTimestamp(isoDate, timestampFormat); + const timeZone = currentTimeZone(); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); const day = date.getDate(); - const month = monthNameFormatter.format(date); + const month = getCachedFormatter("monthName", monthNameOptions, timeZone, "en-US").format(date); const year = date.getFullYear(); return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`; } @@ -144,7 +155,7 @@ export function formatChatTimestampTooltip( export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - return getTimestampFormatter(timestampFormat, false).format(date); + return getTimestampFormatter(timestampFormat, false, currentTimeZone()).format(date); } const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; @@ -176,7 +187,7 @@ function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 }; } -function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { +function formatNumericDate(date: Date, sameYear: boolean, timeZone: string): string { return ( sameYear ? getCachedFormatter("numericDate", numericDateOptions, timeZone) @@ -184,15 +195,15 @@ function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | und ).format(date); } -export function formatDayAwareTimestamp( - isoDate: string, +/** `timeZone` set: calendar days come from that zone; unset: from the system zone `formatZone` names. */ +function formatPastTimestamp( + date: Date, timestampFormat: TimestampFormat, - nowMs: number = Date.now(), - timeZone?: string, + nowMs: number, + timeZone: string | undefined, + formatZone: string, ): string { - const date = parseTimestampDate(isoDate); - if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = getTimestampFormatter(timestampFormat, false, formatZone).format(date); const today = calendarDay(new Date(nowMs), timeZone); const messageDay = calendarDay(date, timeZone); @@ -200,7 +211,18 @@ export function formatDayAwareTimestamp( if (dayDiff <= 0) return time; if (dayDiff === 1) return `yesterday at ${time}`; - return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; + return `${formatNumericDate(date, messageDay.year === today.year, formatZone)} ${time}`; +} + +export function formatDayAwareTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + nowMs: number = Date.now(), + timeZone?: string, +): string { + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return formatPastTimestamp(date, timestampFormat, nowMs, timeZone, timeZone ?? currentTimeZone()); } /** `timeZone` overrides the system time zone. */ @@ -212,16 +234,17 @@ export function formatUpcomingTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const formatZone = timeZone ?? currentTimeZone(); const today = calendarDay(new Date(nowMs), timeZone); const targetDay = calendarDay(date, timeZone); const dayDiff = targetDay.index - today.index; - if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone); + if (dayDiff < 0) return formatPastTimestamp(date, timestampFormat, nowMs, timeZone, formatZone); + const time = getTimestampFormatter(timestampFormat, false, formatZone).format(date); if (dayDiff === 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; - return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`; + return `${formatNumericDate(date, targetDay.year === today.year, formatZone)} ${time}`; } type RelativeTimeParts = { value: string; suffix: string | null }; From 1083c0de7c8f017efdbe3058acf9af0b9b8c0cce Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Sat, 7 Nov 2026 02:21:46 -0500 Subject: [PATCH 3/4] test(web): timestamp assertions derive from a fresh formatter Hardcoded en-US-shaped expectations failed on hosts whose default locale differs (en-GB renders 11:45 pm). Derive the time and numeric-date parts from a freshly built formatter for each zone, which is the correct oracle: the bug being guarded is the cached formatter ignoring the zone change. Signed-off-by: Tiger Kaovilai --- apps/web/src/timestampFormat.test.ts | 45 ++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 245affb999ba3..ab20f88710285 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -26,18 +26,41 @@ describe("timestamps in an explicit time zone", () => { }); describe("timestamps in the system time zone", () => { + // Expectations come from a fresh formatter pinned to each zone, so the tests pass under any host + // locale ("11:45 PM" vs "11:45 pm", "11/6" vs "06/11") while still proving the cached formatter + // tracks the live zone. + const expectedTime = (instant: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone, + }).format(new Date(instant)); + const expectedNumericDate = (instant: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric", timeZone }).format( + new Date(instant), + ); + it("follows a system time zone change without a reload", () => { const instant = "2026-11-07T04:45:43.877Z"; const now = Date.parse("2026-11-20T12:00:00.000Z"); const originalTimeZone = process.env.TZ; try { - process.env.TZ = "America/New_York"; - expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 PM"); - expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/6 11:45 PM"); - - process.env.TZ = "Asia/Bangkok"; - expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 AM"); - expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/7 11:45 AM"); + for (const zone of ["America/New_York", "Asia/Bangkok"]) { + process.env.TZ = zone; + const time = expectedTime(instant, zone); + expect(formatShortTimestamp(instant, "12-hour")).toBe(time); + expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe( + `${expectedNumericDate(instant, zone)} ${time}`, + ); + } + // Guard the oracle: the zones must disagree on both the time and the date. + expect(expectedTime(instant, "America/New_York")).not.toBe( + expectedTime(instant, "Asia/Bangkok"), + ); + expect(expectedNumericDate(instant, "America/New_York")).not.toBe( + expectedNumericDate(instant, "Asia/Bangkok"), + ); } finally { if (originalTimeZone === undefined) delete process.env.TZ; else process.env.TZ = originalTimeZone; @@ -50,10 +73,14 @@ describe("timestamps in the system time zone", () => { const originalTimeZone = process.env.TZ; try { process.env.TZ = "America/New_York"; - expect(formatChatTimestampTooltip(instant, "12-hour")).toBe("7:45 PM, 31st October 2026"); + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe( + `${expectedTime(instant, "America/New_York")}, 31st October 2026`, + ); process.env.TZ = "Asia/Bangkok"; - expect(formatChatTimestampTooltip(instant, "12-hour")).toBe("6:45 AM, 1st November 2026"); + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe( + `${expectedTime(instant, "Asia/Bangkok")}, 1st November 2026`, + ); } finally { if (originalTimeZone === undefined) delete process.env.TZ; else process.env.TZ = originalTimeZone; From feccddeb109d11bfabd88924e52586a638c404fa Mon Sep 17 00:00:00 2001 From: Tiger Kaovilai Date: Sat, 7 Nov 2026 02:25:25 -0500 Subject: [PATCH 4/4] fix(mobile): message times key the formatter on the live zone The UTC-offset guard missed a switch between two zones that share the current offset, which then formatted summer timestamps an hour off. Key the mobile formatter on the resolved zone like web does; one resolvedOptions() call per rendered message is the accepted cost. Signed-off-by: Tiger Kaovilai --- .../src/features/threads/ThreadFeed.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index bfb986ee74922..c68e75b65e633 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,29 +294,22 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -// A formatter pins the default zone when built. Probing the zone builds a formatter too, so -// re-probe only when the UTC offset moves: that catches DST and almost every zone change. -// Missed: a switch between zones with the same current offset; it heals at the next offset change. +// A formatter pins the zone when built, so key it on the live zone. Probing costs one +// resolvedOptions() per rendered message; accepted for correctness across zone switches. const MESSAGE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" }; -let messageTimeFormatter = new Intl.DateTimeFormat(undefined, MESSAGE_TIME_OPTIONS); -let messageTimeOffset = new Date().getTimezoneOffset(); +const messageTimeFormatters = new Map(); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } - const offset = new Date().getTimezoneOffset(); - if (offset !== messageTimeOffset) { - messageTimeOffset = offset; - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (messageTimeFormatter.resolvedOptions().timeZone !== timeZone) { - messageTimeFormatter = new Intl.DateTimeFormat(undefined, { - ...MESSAGE_TIME_OPTIONS, - timeZone, - }); - } + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + let formatter = messageTimeFormatters.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat(undefined, { ...MESSAGE_TIME_OPTIONS, timeZone }); + messageTimeFormatters.set(timeZone, formatter); } - return messageTimeFormatter.format(timestamp); + return formatter.format(timestamp); } const TURN_FOLD_HEIGHT = 42;