diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..6f32a2f775 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -18,6 +18,10 @@ import type { HomeFeedResponse, RelayEvent, } from "@/shared/api/types"; +import { + formatDayGroupLabel, + formatItemTimestamp, +} from "@/shared/lib/datetime"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = @@ -86,6 +90,7 @@ export type InboxReply = { */ signerPubkey?: string; tags?: string[][]; + /** Clock time only, for the hover gutter on continuation rows. */ timeLabel?: string; }; @@ -103,11 +108,6 @@ export type InboxGroup = { type InboxChannel = Pick; -const listTimeFormatter = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", @@ -116,31 +116,6 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const shortDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", -}); - -const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}); - -const weekdayFormatter = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -function startOfDay(value: Date) { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()); -} - -function diffInDays(from: Date, to: Date) { - return Math.round( - (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, - ); -} - function tagValue(item: FeedItem, name: string) { return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; } @@ -445,23 +420,7 @@ export function findInboxItemByEventId( } function formatInboxTimestamp(unixSeconds: number) { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - const dayDiff = diffInDays(now, date); - - if (dayDiff === 0) { - return listTimeFormatter.format(date); - } - - if (dayDiff === 1) { - return "Yesterday"; - } - - if (now.getFullYear() === date.getFullYear()) { - return shortDateFormatter.format(date); - } - - return shortDateWithYearFormatter.format(date); + return formatItemTimestamp(unixSeconds); } export function formatInboxFullTimestamp(unixSeconds: number) { @@ -480,21 +439,14 @@ export function relayEventFromFeedItem(item: FeedItem): RelayEvent { }; } -export function groupInboxItems(items: InboxItem[]): InboxGroup[] { +export function groupInboxItems( + items: InboxItem[], + nowSeconds = Date.now() / 1_000, +): InboxGroup[] { const groups = new Map(); - const now = new Date(); for (const item of items) { - const date = new Date(item.latestActivityAt * 1_000); - const dayDiff = diffInDays(now, date); - const label = - dayDiff === 0 - ? "Today" - : dayDiff === 1 - ? "Yesterday" - : dayDiff < 7 - ? weekdayFormatter.format(date) - : shortDateWithYearFormatter.format(date); + const label = formatDayGroupLabel(item.latestActivityAt, nowSeconds); const current = groups.get(label) ?? []; current.push(item); diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..308640c93d 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -4,10 +4,12 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers"; import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import type { TimelineMessage } from "@/features/messages/types"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; +import { MessageMetaSeparator } from "@/features/messages/ui/MessageHeader"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; @@ -89,6 +91,21 @@ export function InboxMessageRow({ const hoverTimestampLabel = formatTimeWithoutDayPeriod( message.timeLabel ?? message.fullTimestampLabel, ); + // Derived here rather than plumbed in with the message: the thread pane has no + // day divider to supply the date, and deriving on render means a row does not + // keep saying "Today" after midnight. `fullTimestampLabel` stays the absolute + // value behind the hover title. + const timestampLabel = formatItemTimestamp(message.createdAt, { + withTime: true, + }); + const timestampNode = ( +

+ {timestampLabel} +

+ ); return (
@@ -185,14 +202,24 @@ export function InboxMessageRow({ {message.isAgent ? ( - - ) : null} -

- {message.fullTimestampLabel} -

+ <> + + {/* + Grouped with the timestamp so the divider never wraps to the + start of a line on its own. Gap matches the container's, so + spacing reads the same either side of the divider. + */} + + + {timestampNode} + + + ) : ( + timestampNode + )}
)} diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf6..138851b163 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -2,8 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - formatDayHeading, - formatShortMonthDayOrdinal, + formatShortMonthDay, formatThreadSummaryLastReplyTime, formatTimeWithoutDayPeriod, startOfLocalDaySeconds, @@ -13,66 +12,16 @@ function localUnixSeconds(year, monthIndex, day) { return new Date(year, monthIndex, day, 12).getTime() / 1_000; } -function weekday(date) { - return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); -} - -function month(date) { - return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date); -} - -test("formatShortMonthDayOrdinal formats month before ordinal day", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)), - "May 19th", - ); +test("formatShortMonthDay abbreviates the month and omits the ordinal", () => { + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 19)), "May 19"); + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 1)), "May 1"); }); -test("formatShortMonthDayOrdinal handles ordinal suffixes", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)), - "May 1st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)), - "May 2nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)), - "May 3rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)), - "May 4th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)), - "May 11th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)), - "May 12th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)), - "May 13th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)), - "May 21st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)), - "May 22nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)), - "May 23rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)), - "May 31st", - ); +test("no day carries an ordinal suffix", () => { + for (const day of [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatShortMonthDay(localUnixSeconds(2026, 4, day)); + assert.doesNotMatch(label, /\d(?:st|nd|rd|th)\b/, `ordinal in "${label}"`); + } }); test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => { @@ -108,31 +57,11 @@ test("formatThreadSummaryLastReplyTime expands relative units", () => { ); }); -test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => { +test("formatThreadSummaryLastReplyTime dates older replies without an ordinal", () => { const now = localUnixSeconds(2026, 5, 15); const replyAt = localUnixSeconds(2026, 4, 19); - assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th"); -}); - -test("formatDayHeading omits the year for current-year dates", () => { - const now = new Date(); - const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, ${month(date)} 19th`, - ); -}); - -test("formatDayHeading includes the year for other years", () => { - const year = new Date().getFullYear() - 1; - const date = new Date(year, 4, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, May 19th, ${year}`, - ); + assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19"); }); test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => { diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d8150..f752bdfd20 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -4,9 +4,17 @@ * - `formatTime` — short clock time ("2:34 PM"), used in message rows. * - `formatFullDateTime` — verbose string for tooltips * ("Wednesday, April 2, 2026 at 2:34 PM"). - * - `formatDayHeading` — label for day dividers / sticky headers. - * Returns "Today", "Yesterday", or a date like "Monday, March 31st". * - `isSameDay` — compare two unix-second timestamps. + * + * Relative labels ("Today", "Yesterday", "June 20", "Yesterday at 9:05 AM") are + * not here: chat and the Inbox share them from `shared/lib/datetime.ts`. What + * stays in this file is the absolute end of the range — a bare clock time, the + * verbose tooltip string, and same-day comparison. + * + * `formatTime` is for places with only enough room for a clock: the hover gutter + * that replaces the avatar on continuation rows. A message header uses the + * relative ladder instead, because the day divider that supplies its date + * scrolls away while the messages under it stay on screen. */ const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -25,16 +33,9 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "long", -}); - -const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { +const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", + day: "numeric", }); /** Short clock time, e.g. "2:34 PM". */ @@ -52,34 +53,6 @@ export function formatFullDateTime(unixSeconds: number): string { return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); } -/** - * Human-friendly day label for dividers and sticky headers. - * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st", - * or a prior-year date like "Monday, March 31st, 2025". - */ -export function formatDayHeading(unixSeconds: number): string { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - - if (isSameDayDate(date, now)) { - return "Today"; - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (isSameDayDate(date, yesterday)) { - return "Yesterday"; - } - - const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal( - date, - LONG_MONTH_FORMATTER, - )}`; - return date.getFullYear() === now.getFullYear() - ? dateLabel - : `${dateLabel}, ${date.getFullYear()}`; -} - /** True when two unix-second timestamps fall on the same calendar day (local time). */ export function isSameDay(a: number, b: number): boolean { return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); @@ -97,17 +70,14 @@ export function startOfLocalDaySeconds(unixSeconds: number): number { return Math.floor(date.getTime() / 1_000); } -/** Short month + ordinal day, e.g. "May 19th". */ -export function formatShortMonthDayOrdinal(unixSeconds: number): string { - return formatMonthDayOrdinal( - new Date(unixSeconds * 1_000), - SHORT_MONTH_FORMATTER, - ); +/** Short month + day, e.g. "May 19". No ordinal suffix, per the writing standard. */ +export function formatShortMonthDay(unixSeconds: number): string { + return SHORT_MONTH_DAY_FORMATTER.format(new Date(unixSeconds * 1_000)); } /** * Relative thread-summary timestamp with expanded units, e.g. "3 hours ago", - * falling back to "on May 19th" for older replies. + * falling back to "on May 19" for older replies. */ export function formatThreadSummaryLastReplyTime( unixSeconds: number, @@ -120,7 +90,7 @@ export function formatThreadSummaryLastReplyTime( if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour"); if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day"); - return `on ${formatShortMonthDayOrdinal(unixSeconds)}`; + return `on ${formatShortMonthDay(unixSeconds)}`; } function isSameDayDate(a: Date, b: Date): boolean { @@ -131,33 +101,6 @@ function isSameDayDate(a: Date, b: Date): boolean { ); } -function formatMonthDayOrdinal( - date: Date, - monthFormatter: Intl.DateTimeFormat, -): string { - return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix( - date.getDate(), - )}`; -} - function formatAgo(value: number, unit: string): string { return `${value} ${unit}${value === 1 ? "" : "s"} ago`; } - -function ordinalSuffix(day: number): string { - const lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { - return "th"; - } - - switch (day % 10) { - case 1: - return "st"; - case 2: - return "nd"; - case 3: - return "rd"; - default: - return "th"; - } -} diff --git a/desktop/src/features/messages/ui/MessageAgentOwner.tsx b/desktop/src/features/messages/ui/MessageAgentOwner.tsx index e5b92cd734..e394d567b4 100644 --- a/desktop/src/features/messages/ui/MessageAgentOwner.tsx +++ b/desktop/src/features/messages/ui/MessageAgentOwner.tsx @@ -17,14 +17,28 @@ export function MessageAgentOwner({ {ownerLabel ? "Agent managed by" : "Agent; owner unavailable"} + {/* + * Icon and label sit directly in this baseline row rather than in a nested + * flex wrapper, so the label's own baseline is what aligns with the author + * name beside it. Both branches share the icon for the same reason: two + * wrappers meant two alignment rules and the "owner unavailable" variant + * had drifted a pixel off the other one. + * + * `self-center` keeps the icon out of baseline alignment, so the label — + * not the icon's box — sets this chip's baseline. Centred on the line box + * the glyph's ink still rides ~1.6px above the text's cap band, reading as + * a couple of pixels too high; 0.125em drops its optical centre onto that + * band. In em so it holds under Cmd +/- zoom, and as a transform so it + * shifts nothing else in the row. + */} +