diff --git a/apps/mobile/src/components/RelativeTime.tsx b/apps/mobile/src/components/RelativeTime.tsx new file mode 100644 index 000000000000..8d2067a4907f --- /dev/null +++ b/apps/mobile/src/components/RelativeTime.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; +import { msUntilRelativeTimeChange, relativeTime } from "../lib/time"; + +// The React Compiler caches inline relativeTime calls on the timestamp alone. +export function RelativeTime(props: { timestamp: string }) { + const subscribe = (onChange: () => void) => { + let timer: ReturnType | undefined; + const schedule = () => { + const delayMs = msUntilRelativeTimeChange(props.timestamp); + if (delayMs === null) return; + timer = setTimeout(() => { + onChange(); + schedule(); + }, delayMs); + }; + schedule(); + return () => clearTimeout(timer); + }; + const getSnapshot = () => relativeTime(props.timestamp); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c3e29ec39afe..5ad372403474 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -23,7 +23,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; import { useEnvironmentScope } from "../../state/session"; @@ -192,7 +192,6 @@ function ArchivedThreadRow(props: { props.thread.environmentId, AuthOrchestrationOperateScope, ); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); @@ -218,7 +217,7 @@ function ArchivedThreadRow(props: { {props.thread.title} - {timestamp} + {subtitle.length > 0 ? ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx index cd461028e270..b42a796938e1 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx @@ -5,7 +5,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { Menu } from "../../components/ui/Menu"; import type { MenuProps } from "../../components/ui/Menu.types"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { selectedThreadRowColors, THREAD_LIST_V2_SIDEBAR_ROW_STYLE, @@ -128,7 +128,7 @@ export const PullRequestListRow = memo( ) : null} - {relativeTime(item.updatedAt)} + - Updated {relativeTime(pr.updatedAt)} ago + Updated ago diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index 903e7fd574bb..2e15b3a0ac62 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -13,7 +13,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import type { EnvironmentRuntimeState } from "../../state/remote-runtime-types"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -143,7 +143,7 @@ export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: { ) : null} {snapshot?.updatedAt ? ( - {relativeTime(snapshot.updatedAt)} + ) : null} diff --git a/apps/mobile/src/lib/time.test.ts b/apps/mobile/src/lib/time.test.ts new file mode 100644 index 000000000000..8881b6bbab8b --- /dev/null +++ b/apps/mobile/src/lib/time.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { msUntilRelativeTimeChange, relativeTime } from "./time"; + +const at = "2026-10-19T12:00:00.000Z"; +const timestamp = Date.parse(at); + +describe("relative time", () => { + it.each([ + [59_999, "<1m", "1m"], + [119_999, "1m", "2m"], + [3_599_999, "59m", "1h"], + [7_199_999, "1h", "2h"], + [86_399_999, "23h", "1d"], + [172_799_999, "1d", "2d"], + ] as const)("updates at the next boundary after %i ms", (elapsed, before, after) => { + const nowMs = timestamp + elapsed; + expect(relativeTime(at, nowMs)).toBe(before); + expect(msUntilRelativeTimeChange(at, nowMs)).toBe(1); + expect(relativeTime(at, nowMs + 1)).toBe(after); + }); + + it.each([ + [0, 60_000], + [60_000, 60_000], + [3_600_000, 3_600_000], + [86_400_000, 86_400_000], + ])("waits a full label interval at %i ms", (elapsed, delay) => { + expect(msUntilRelativeTimeChange(at, timestamp + elapsed)).toBe(delay); + }); + + it("keeps invalid timestamps static", () => { + expect(relativeTime("invalid", timestamp)).toBe("<1m"); + expect(msUntilRelativeTimeChange("invalid", timestamp)).toBeNull(); + }); + + it("waits until a future timestamp is a minute old, with bounded timers", () => { + expect(relativeTime(at, timestamp - 30_000)).toBe("<1m"); + expect(msUntilRelativeTimeChange(at, timestamp - 30_000)).toBe(90_000); + expect(msUntilRelativeTimeChange(at, timestamp - 30 * 86_400_000)).toBe(86_400_000); + }); +}); diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts index 6cdd56ae6dcf..8635ccb37138 100644 --- a/apps/mobile/src/lib/time.ts +++ b/apps/mobile/src/lib/time.ts @@ -1,10 +1,10 @@ -export function relativeTime(input: string): string { +export function relativeTime(input: string, nowMs: number = Date.now()): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return "<1m"; } - const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + const deltaSeconds = Math.max(0, Math.floor((nowMs - timestamp) / 1000)); if (deltaSeconds < 60) return "<1m"; const deltaMinutes = Math.floor(deltaSeconds / 60); @@ -16,3 +16,17 @@ export function relativeTime(input: string): string { const deltaDays = Math.floor(deltaHours / 24); return `${deltaDays}d`; } + +/** Milliseconds until the relative-time label next changes. */ +export function msUntilRelativeTimeChange( + input: string, + nowMs: number = Date.now(), +): number | null { + const timestamp = Date.parse(input); + if (Number.isNaN(timestamp)) return null; + const elapsedMs = Math.max(0, nowMs - timestamp); + const unitMs = elapsedMs < 3_600_000 ? 60_000 : elapsedMs < 86_400_000 ? 3_600_000 : 86_400_000; + const nextChangeMs = timestamp + (Math.floor(elapsedMs / unitMs) + 1) * unitMs; + // Far-future timestamps must not overflow setTimeout's signed 32-bit delay. + return Math.min(nextChangeMs - nowMs, 86_400_000); +}