diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 6419c7dc7778..ba0445c6a4b2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -288,7 +288,7 @@ import { import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; -import { formatMessageTimestamp } from "./messageTimestamp"; +import { useMessageTimeFormatter } from "./useMessageTimeFormatter"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // iOS blockquotes and selectable text measure separately; definite bubble width matches UIKit's drawing width. @@ -1667,6 +1667,7 @@ function renderFeedEntry( | "threadId" | "workspaceRoot" > & { + readonly formatMessageTime: (input: string) => string; readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly workRowSizing: ReturnType; @@ -1912,7 +1913,7 @@ function renderFeedEntry( const presentation = resolveUserMessagePresentation(message); const renderedText = renderAssistantCitationsAsText(presentation.text); const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = formatMessageTimestamp(isUser ? message.createdAt : message.updatedAt); + const timestampLabel = props.formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = presentation.text.includes(" { const now = new Date(2026, 10, 8, 20, 0).getTime(); @@ -20,3 +20,15 @@ describe("formatMessageTimestamp", () => { ); }); }); + +describe("createMessageTimeFormatter", () => { + const now = new Date(2026, 0, 1, 20, 0).getTime(); + + it("follows the system clock preference over the locale's hour cycle", () => { + const format24 = createMessageTimeFormatter(true, "en-US"); + expect(format24(new Date(2026, 0, 1, 15, 7).toISOString(), now)).toBe("15:07"); + expect(format24(new Date(2026, 0, 1, 0, 7).toISOString(), now)).toBe("00:07"); + const format12 = createMessageTimeFormatter(false, "en-GB"); + expect(format12(new Date(2026, 0, 1, 15, 7).toISOString(), now)).toMatch(/^3:07\spm$/i); + }); +}); diff --git a/apps/mobile/src/features/threads/messageTimestamp.ts b/apps/mobile/src/features/threads/messageTimestamp.ts index 5624b71c0c91..cfae85e09266 100644 --- a/apps/mobile/src/features/threads/messageTimestamp.ts +++ b/apps/mobile/src/features/threads/messageTimestamp.ts @@ -7,13 +7,28 @@ const DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat(undefined, { }); /** Time only for today's messages; older ones lead with the day and month. */ -export function formatMessageTimestamp(input: string, nowMs: number = Date.now()): string { +export function formatMessageTimestamp( + input: string, + nowMs: number = Date.now(), + timeFormatter: Intl.DateTimeFormat = TIME_FORMATTER, +): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) return ""; const date = new Date(timestamp); const now = new Date(nowMs); - const time = TIME_FORMATTER.format(date); + const time = timeFormatter.format(date); if (date.toDateString() === now.toDateString()) return time; const sameYear = date.getFullYear() === now.getFullYear(); return `${(sameYear ? DATE_FORMATTER : DATE_WITH_YEAR_FORMATTER).format(date)} ${time}`; } + +/** `is24Hour` overrides the locale's hour cycle with the system clock preference when known. */ +export function createMessageTimeFormatter(is24Hour: boolean | undefined, locale?: string) { + const timeFormatter = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + ...(is24Hour === undefined ? {} : { hourCycle: is24Hour ? "h23" : "h12" }), + }); + return (input: string, nowMs?: number): string => + formatMessageTimestamp(input, nowMs, timeFormatter); +} diff --git a/apps/mobile/src/features/threads/useMessageTimeFormatter.ts b/apps/mobile/src/features/threads/useMessageTimeFormatter.ts new file mode 100644 index 000000000000..2320d369762c --- /dev/null +++ b/apps/mobile/src/features/threads/useMessageTimeFormatter.ts @@ -0,0 +1,22 @@ +import { requireNativeModule } from "expo"; +import { useEffect, useMemo, useState } from "react"; +import { AppState, Platform } from "react-native"; +import { createMessageTimeFormatter } from "./messageTimestamp"; + +function systemUses24HourClock() { + if (Platform.OS !== "android") return undefined; + return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat(); +} + +export function useMessageTimeFormatter() { + const [is24Hour, setIs24Hour] = useState(systemUses24HourClock); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Refresh the native clock preference when Android returns to the foreground. + useEffect(() => { + if (Platform.OS !== "android") return; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") setIs24Hour(systemUses24HourClock()); + }); + return () => subscription.remove(); + }, []); + return useMemo(() => createMessageTimeFormatter(is24Hour), [is24Hour]); +} diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 8363b0ba5c95..2c7a7313e47e 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -113,6 +113,7 @@ vi.mock("../components/ui/Button", () => ({ createElement("button", { onClick: props.onPress, disabled: props.disabled }, props.label), })); vi.mock("expo", () => ({ + requireNativeModule: () => ({ is24HourFormat: () => true }), useEvent: (_player: unknown, _event: unknown, initial: unknown) => initial, useReleasingSharedObjectWithLifecycle: ( lifecycle: {