diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d6fdc9e955cde..b7e899cdd0bba 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,16 +294,23 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", -}); +// A formatter pins the default zone when built, and Hermes keeps Date's local offset until a cold +// start (react/react-native#59131), so rebuild when Intl reports a different zone. +let messageTimeFormatter: Intl.DateTimeFormat | null = null; function formatMessageTime(input: string): string { const timestamp = Date.parse(input); 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..5f6513ab69623 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; +import { + formatChatTimestampTooltip, + formatDayAwareTimestamp, + formatUpcomingTimestamp, +} from "./timestampFormat"; + +// Node re-resolves the default zone when TZ is assigned, like a renderer after the OS zone changes. +function useTimeZone(timeZone: string): Disposable { + const previous = process.env.TZ; + process.env.TZ = timeZone; + return { + [Symbol.dispose]: () => { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + }, + }; +} 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 +35,54 @@ describe("timestamps in an explicit time zone", () => { ); }); }); + +describe("timestamps after the system time zone changes", () => { + // Built fresh with an explicit zone, so expectations follow the host locale but not a stale zone. + const timeIn = (iso: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone, + }).format(new Date(iso)); + const dayIn = (iso: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric", timeZone }).format( + new Date(iso), + ); + + it("renders the gutter label in the new zone", () => { + const sameDay = "2026-11-07T04:45:43.877Z"; + const earlierInWeek = "2026-11-04T20:00:00.000Z"; + const now = Date.parse(sameDay); + using _zone = useTimeZone("America/New_York"); + expect([ + formatDayAwareTimestamp(sameDay, "12-hour", now), + formatDayAwareTimestamp(earlierInWeek, "12-hour", now), + ]).toEqual([ + timeIn(sameDay, "America/New_York"), + `${dayIn(earlierInWeek, "America/New_York")} ${timeIn(earlierInWeek, "America/New_York")}`, + ]); + + process.env.TZ = "Asia/Bangkok"; + expect([ + formatDayAwareTimestamp(sameDay, "12-hour", now), + formatDayAwareTimestamp(earlierInWeek, "12-hour", now), + ]).toEqual([ + timeIn(sameDay, "Asia/Bangkok"), + `${dayIn(earlierInWeek, "Asia/Bangkok")} ${timeIn(earlierInWeek, "Asia/Bangkok")}`, + ]); + }); + + it("renders the tooltip in the new zone", () => { + const monthEnd = "2026-10-31T20:00:00.000Z"; + using _zone = useTimeZone("America/New_York"); + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe( + `${timeIn(monthEnd, "America/New_York")}, 31st October 2026`, + ); + + process.env.TZ = "Asia/Bangkok"; + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe( + `${timeIn(monthEnd, "Asia/Bangkok")}, 1st November 2026`, + ); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index abad91401ccbe..5cec06e81741a 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -68,14 +68,20 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin export const weekStartsOn = resolveWeekStartsOn(timestampLocale); +// A formatter keeps the default zone it was built in, so default-zone formatters run in UTC +// on the wall-clock time, which Date reads from the current zone. +function wallClockAsUtc(date: Date): Date { + return new Date(date.getTime() - date.getTimezoneOffset() * 60_000); +} + const timestampFormatterCache = new Map(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, - timeZone?: string, + timeZone = "UTC", ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; + const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; @@ -83,12 +89,18 @@ function getTimestampFormatter( const formatter = new Intl.DateTimeFormat(timestampLocale, { ...getTimestampFormatOptions(timestampFormat, includeSeconds), - ...(timeZone ? { timeZone } : {}), + timeZone, }); timestampFormatterCache.set(cacheKey, formatter); return formatter; } +function formatTime(date: Date, timestampFormat: TimestampFormat, timeZone?: string): string { + return timeZone + ? getTimestampFormatter(timestampFormat, false, timeZone).format(date) + : getTimestampFormatter(timestampFormat, false).format(wallClockAsUtc(date)); +} + export function resolveHour12(timestampFormat: TimestampFormat): boolean { return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false; } @@ -101,7 +113,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 monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; @@ -124,9 +136,9 @@ export function formatChatTimestampTooltip( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = formatShortTimestamp(isoDate, timestampFormat); + const time = formatTime(date, timestampFormat); const day = date.getDate(); - const month = monthNameFormatter.format(date); + const month = monthNameFormatter.format(wallClockAsUtc(date)); const year = date.getFullYear(); return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`; } @@ -134,7 +146,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 formatTime(date, timestampFormat); } const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; @@ -142,11 +154,14 @@ const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { ...numericDateOptions, year: "numeric", }; -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); -const numericDateWithYearFormatter = new Intl.DateTimeFormat( - timestampLocale, - numericDateWithYearOptions, -); +const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { + ...numericDateOptions, + timeZone: "UTC", +}); +const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { + ...numericDateWithYearOptions, + timeZone: "UTC", +}); interface CalendarDay { year: number; @@ -173,7 +188,9 @@ 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 (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format( + wallClockAsUtc(date), + ); } return new Intl.DateTimeFormat(timestampLocale, { ...(sameYear ? numericDateOptions : numericDateWithYearOptions), @@ -189,7 +206,7 @@ export function formatDayAwareTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = formatTime(date, timestampFormat, timeZone); const today = calendarDay(new Date(nowMs), timeZone); const messageDay = calendarDay(date, timeZone); @@ -200,7 +217,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's current zone. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, @@ -209,7 +226,7 @@ export function formatUpcomingTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = formatTime(date, timestampFormat, timeZone); const today = calendarDay(new Date(nowMs), timeZone); const targetDay = calendarDay(date, timeZone);