Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 11 additions & 59 deletions desktop/src/features/home/lib/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -86,6 +90,7 @@ export type InboxReply = {
*/
signerPubkey?: string;
tags?: string[][];
/** Clock time only, for the hover gutter on continuation rows. */
timeLabel?: string;
};

Expand All @@ -103,11 +108,6 @@ export type InboxGroup = {

type InboxChannel = Pick<Channel, "channelType" | "id" | "name">;

const listTimeFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
});

const fullTimeFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, InboxItem[]>();
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);
Expand Down
43 changes: 35 additions & 8 deletions desktop/src/features/home/ui/InboxMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = (
<p
className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55"
title={message.fullTimestampLabel}
>
{timestampLabel}
</p>
);

return (
<div className="relative px-2">
Expand Down Expand Up @@ -185,14 +202,24 @@ export function InboxMessageRow({
</span>
</UserProfilePopover>
{message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
) : null}
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
<>
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
{/*
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.
*/}
<span className="inline-flex min-w-0 items-center gap-x-2">
<MessageMetaSeparator />
{timestampNode}
</span>
</>
) : (
timestampNode
)}
</div>
)}

Expand Down
93 changes: 11 additions & 82 deletions desktop/src/features/messages/lib/dateFormatters.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
formatDayHeading,
formatShortMonthDayOrdinal,
formatShortMonthDay,
formatThreadSummaryLastReplyTime,
formatTimeWithoutDayPeriod,
startOfLocalDaySeconds,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading