diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index a083b9fa7e3..c87fe596cc9 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -54,6 +54,7 @@ export type InboxReply = { authorPubkey: string; avatarUrl: string | null; content: string; + createdAt: number; depth?: number; fullTimestampLabel: string; id: string; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index bcbba802408..d9c24762f3d 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -371,6 +371,7 @@ export function HomeView({ authorPubkey, avatarUrl: message.avatarUrl ?? null, content: message.body, + createdAt: message.createdAt, depth: event ? getContextMessageDepth(event, eventById) : message.depth, fullTimestampLabel: formatInboxFullTimestamp(message.createdAt), isSelected: message.id === selectedItem.id, @@ -704,6 +705,7 @@ export function HomeView({ ?.avatarUrl ?? null) : null, content, + createdAt: result.createdAt, depth: result.depth, fullTimestampLabel: formatInboxFullTimestamp( result.createdAt, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index daf0b8f502a..32611b26068 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -14,7 +14,10 @@ import { } from "@/features/home/ui/InboxMessageRow"; import type { TimelineMessage } from "@/features/messages/types"; import { formatTime } from "@/features/messages/lib/dateFormatters"; -import { hasSameMessageAuthor } from "@/features/messages/lib/messageGrouping"; +import { + hasSameMessageAuthor, + isWithinGroupingWindow, +} from "@/features/messages/lib/messageGrouping"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import type { Channel } from "@/shared/api/types"; @@ -192,6 +195,7 @@ export function InboxDetailPane({ authorPubkey: item.item.pubkey, avatarUrl: item.avatarUrl, content: item.preview, + createdAt: item.item.createdAt, depth: 0, fullTimestampLabel: item.fullTimestampLabel, id: item.id, @@ -325,11 +329,16 @@ export function InboxDetailPane({
{displayMessages.map((message, index) => { const isAfterSeparator = index === 1; + const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && hasSameMessageAuthor( - { pubkey: displayMessages[index - 1]?.authorPubkey }, + { pubkey: previousMessage?.authorPubkey }, { pubkey: message.authorPubkey }, + ) && + isWithinGroupingWindow( + previousMessage?.createdAt, + message.createdAt, ); return ( diff --git a/desktop/src/features/messages/lib/messageGrouping.test.mjs b/desktop/src/features/messages/lib/messageGrouping.test.mjs new file mode 100644 index 00000000000..2b25df5b6d8 --- /dev/null +++ b/desktop/src/features/messages/lib/messageGrouping.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MESSAGE_GROUPING_WINDOW_SECONDS, + hasSameMessageAuthor, + isWithinGroupingWindow, +} from "./messageGrouping.ts"; + +test("hasSameMessageAuthor: matches case-insensitively and trims", () => { + assert.equal( + hasSameMessageAuthor({ pubkey: " ABC " }, { pubkey: "abc" }), + true, + ); + assert.equal( + hasSameMessageAuthor({ pubkey: "abc" }, { pubkey: "def" }), + false, + ); +}); + +test("hasSameMessageAuthor: missing pubkeys never match", () => { + assert.equal(hasSameMessageAuthor(null, { pubkey: "abc" }), false); + assert.equal(hasSameMessageAuthor({ pubkey: "abc" }, undefined), false); + assert.equal(hasSameMessageAuthor({ pubkey: "" }, { pubkey: "" }), false); +}); + +test("isWithinGroupingWindow: at or under the boundary is in window", () => { + const base = 1_000_000; + assert.equal(isWithinGroupingWindow(base, base), true); + assert.equal( + isWithinGroupingWindow(base, base + MESSAGE_GROUPING_WINDOW_SECONDS), + true, + ); +}); + +test("isWithinGroupingWindow: past the boundary is out of window", () => { + const base = 1_000_000; + assert.equal( + isWithinGroupingWindow(base, base + MESSAGE_GROUPING_WINDOW_SECONDS + 1), + false, + ); +}); + +test("isWithinGroupingWindow: out-of-order (negative gap) is out of window", () => { + const base = 1_000_000; + assert.equal(isWithinGroupingWindow(base + 60, base), false); +}); + +test("isWithinGroupingWindow: missing timestamps are out of window", () => { + assert.equal(isWithinGroupingWindow(null, 1_000_000), false); + assert.equal(isWithinGroupingWindow(1_000_000, undefined), false); + assert.equal(isWithinGroupingWindow(undefined, undefined), false); +}); diff --git a/desktop/src/features/messages/lib/messageGrouping.ts b/desktop/src/features/messages/lib/messageGrouping.ts index fd47fa39978..8864f60098a 100644 --- a/desktop/src/features/messages/lib/messageGrouping.ts +++ b/desktop/src/features/messages/lib/messageGrouping.ts @@ -2,6 +2,15 @@ type MessageAuthorCandidate = { pubkey?: string | null; }; +/** + * Max gap (seconds) between two same-author messages for the later one to still + * render as a continuation (time-only, no avatar). Beyond this the message + * reads as a new thought and gets the traditional avatar + header treatment, + * even from the same author. Applied consistently across the channel timeline, + * the threaded reply panel, and the home inbox detail view. + */ +export const MESSAGE_GROUPING_WINDOW_SECONDS = 10 * 60; + export function hasSameMessageAuthor( previous: MessageAuthorCandidate | null | undefined, current: MessageAuthorCandidate | null | undefined, @@ -13,3 +22,24 @@ export function hasSameMessageAuthor( previousPubkey && currentPubkey && previousPubkey === currentPubkey, ); } + +/** + * Whether `current` falls within {@link MESSAGE_GROUPING_WINDOW_SECONDS} of + * `previous`. Both timestamps are Unix seconds. A missing previous timestamp + * (or one in the future) is treated as out of window. Callers combine this + * with {@link hasSameMessageAuthor} to decide continuation grouping. + */ +export function isWithinGroupingWindow( + previousCreatedAt: number | null | undefined, + currentCreatedAt: number | null | undefined, +) { + if ( + typeof previousCreatedAt !== "number" || + typeof currentCreatedAt !== "number" + ) { + return false; + } + + const gap = currentCreatedAt - previousCreatedAt; + return gap >= 0 && gap <= MESSAGE_GROUPING_WINDOW_SECONDS; +} diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 10143ba7a78..bd844e70325 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -8,9 +8,9 @@ import { getTimelineItemKey, } from "./timelineItems.ts"; -function dayAt(year, month, day, hour = 12) { +function dayAt(year, month, day, hour = 12, minute = 0) { return Math.floor( - new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000, + new Date(year, month - 1, day, hour, minute, 0).getTime() / 1_000, ); } @@ -90,11 +90,19 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); -test("buildTimelineItems: consecutive messages from the same author are grouped", () => { +test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => { const entries = [ entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), - entry({ id: "b", pubkey: "AUTHOR-A", createdAt: dayAt(2026, 6, 14, 13) }), - entry({ id: "c", pubkey: "author-b", createdAt: dayAt(2026, 6, 14, 14) }), + entry({ + id: "b", + pubkey: "AUTHOR-A", + createdAt: dayAt(2026, 6, 14, 12, 2), + }), + entry({ + id: "c", + pubkey: "author-b", + createdAt: dayAt(2026, 6, 14, 12, 3), + }), ]; const messageItems = buildTimelineItems(entries, null).items.filter( @@ -111,6 +119,53 @@ test("buildTimelineItems: consecutive messages from the same author are grouped" ); }); +test("buildTimelineItems: same-author messages past the window start a new group", () => { + const author = "author-a"; + const entries = [ + entry({ id: "a", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 0) }), + // 8 min later — within the 10-min window, groups as a continuation. + entry({ id: "b", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 8) }), + // 12 min after "b" — past the window, breaks into a new thought. + entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 20) }), + // 5 min after "c" — within the window again, groups onto "c". + entry({ id: "d", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 25) }), + ]; + + const messageItems = buildTimelineItems(entries, null).items.filter( + (item) => item.kind === "message", + ); + + assert.deepEqual( + messageItems.map((item) => item.isContinuation), + [false, true, false, true], + ); + assert.deepEqual( + messageItems.map((item) => item.isFollowedByContinuation), + [true, false, true, false], + ); +}); + +test("buildTimelineItems: window is measured against the previous message, not the group start", () => { + const author = "author-a"; + // Each message is 8 min after the one above it — a steady stream that never + // gaps out, so grouping continues even though the span (16 min) exceeds the + // 10-min window. + const entries = [ + entry({ id: "a", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 0) }), + entry({ id: "b", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 8) }), + entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 16) }), + ]; + + const messageItems = buildTimelineItems(entries, null).items.filter( + (item) => item.kind === "message", + ); + + assert.deepEqual( + messageItems.map((item) => item.isContinuation), + [false, true, true], + ); +}); + test("buildTimelineItems: dividers break grouping while thread summaries do not", () => { const sameAuthor = "author-a"; const entries = [ @@ -119,16 +174,20 @@ test("buildTimelineItems: dividers break grouping while thread summaries do not" ...entry({ id: "b", pubkey: sameAuthor, - createdAt: dayAt(2026, 6, 14, 13), + createdAt: dayAt(2026, 6, 14, 12, 1), }), summary: { threadHeadId: "b", replyCount: 1, - lastReplyAt: dayAt(2026, 6, 14, 13), + lastReplyAt: dayAt(2026, 6, 14, 12, 1), participants: [], }, }, - entry({ id: "c", pubkey: sameAuthor, createdAt: dayAt(2026, 6, 14, 14) }), + entry({ + id: "c", + pubkey: sameAuthor, + createdAt: dayAt(2026, 6, 14, 12, 2), + }), entry({ id: "d", pubkey: sameAuthor, createdAt: dayAt(2026, 6, 15) }), ]; diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 0f8b48c78aa..87b8a41d6b3 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -12,7 +12,10 @@ import { } from "@/features/messages/lib/timelineSnapshot"; import { shouldRenderUnreadDivider } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; -import { hasSameMessageAuthor } from "@/features/messages/lib/messageGrouping"; +import { + hasSameMessageAuthor, + isWithinGroupingWindow, +} from "@/features/messages/lib/messageGrouping"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; /** @@ -109,7 +112,11 @@ export function buildTimelineItems( const isContinuation = previousGroupEntry !== null && - hasSameMessageAuthor(previousGroupEntry.message, message); + hasSameMessageAuthor(previousGroupEntry.message, message) && + isWithinGroupingWindow( + previousGroupEntry.message.createdAt, + message.createdAt, + ); if (isContinuation && previousMessageItemIndex !== null) { const previousItem = items[previousMessageItemIndex]; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 09041e5e29f..1bd7c922d39 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -6,7 +6,10 @@ import { hasNestedThreadBranches, type MainTimelineEntry, } from "@/features/messages/lib/threadPanel"; -import { hasSameMessageAuthor } from "@/features/messages/lib/messageGrouping"; +import { + hasSameMessageAuthor, + isWithinGroupingWindow, +} from "@/features/messages/lib/messageGrouping"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { TimelineMessage } from "@/features/messages/types"; @@ -543,7 +546,11 @@ export function MessageThreadPanel({ const isContinuation = !startsUnreadSection && entry.summary === null && - hasSameMessageAuthor(previousGroupMessage, entry.message); + hasSameMessageAuthor(previousGroupMessage, entry.message) && + isWithinGroupingWindow( + previousGroupMessage?.createdAt, + entry.message.createdAt, + ); if (connectsToVisibleChild && !entry.summary) { ancestorStack.push({ index, message: entry.message });