From d660719962b9cdb08e14cd7e6bed9238a53ad065 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 12:55:21 +0100 Subject: [PATCH 1/2] feat(desktop): break message grouping after a 5-minute gap Consecutive same-author messages currently collapse into the time-only (avatarless) continuation style regardless of how far apart they are. Add a 5-minute rolling window: a message only groups onto the one above it when it is from the same author AND within 5 minutes of it. Past that gap the message reads as a new thought and gets the traditional avatar + header treatment. The window is measured against the immediately preceding message, so a steady stream keeps grouping while a lull breaks it. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- .../messages/lib/timelineItems.test.mjs | 74 +++++++++++++++++-- .../features/messages/lib/timelineItems.ts | 12 ++- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 10143ba7a78..938a23c4313 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,52 @@ 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) }), + // 4 min later — within the 5-min window, groups as a continuation. + entry({ id: "b", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 4) }), + // 6 min after "b" — past the window, breaks into a new thought. + entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 10) }), + // 3 min after "c" — within the window again, groups onto "c". + entry({ id: "d", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 13) }), + ]; + + 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 4 min after the one above it — a steady stream that never + // gaps out, so grouping continues even though the span exceeds 5 min total. + 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, 4) }), + entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 8) }), + ]; + + 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 +173,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..ed74f91c5e8 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -15,6 +15,14 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { hasSameMessageAuthor } from "@/features/messages/lib/messageGrouping"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +/** + * 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. + */ +export const MESSAGE_GROUPING_WINDOW_SECONDS = 5 * 60; + /** * One renderable row in the flattened timeline. Dividers carry no message and * never appear in the index map; the three message-bearing kinds do. @@ -109,7 +117,9 @@ export function buildTimelineItems( const isContinuation = previousGroupEntry !== null && - hasSameMessageAuthor(previousGroupEntry.message, message); + hasSameMessageAuthor(previousGroupEntry.message, message) && + message.createdAt - previousGroupEntry.message.createdAt <= + MESSAGE_GROUPING_WINDOW_SECONDS; if (isContinuation && previousMessageItemIndex !== null) { const previousItem = items[previousMessageItemIndex]; From 724c1969a2f73a197d7008ba0d36197fa3e3f82c Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 13:03:30 +0100 Subject: [PATCH 2/2] feat(desktop): extend 10-min grouping window to threads + inbox Promote the grouping-window logic into shared helpers in messageGrouping.ts (MESSAGE_GROUPING_WINDOW_SECONDS = 10 min, isWithinGroupingWindow) and apply it consistently across the three continuation surfaces: - channel timeline (buildTimelineItems) - threaded reply panel (MessageThreadPanel) - home inbox detail (InboxDetailPane) Same-author messages now only collapse into the avatarless continuation style when they land within 10 minutes of the previous one; past that gap they read as a new thought with the traditional avatar + header. Threads/inbox previously grouped on author alone with no time check. Threads carry createdAt on TimelineMessage already; the inbox reply model gained a createdAt field, populated in all three InboxReply builders. Bumps the window from 5 to 10 minutes per product decision. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/src/features/home/lib/inbox.ts | 1 + desktop/src/features/home/ui/HomeView.tsx | 2 + .../src/features/home/ui/InboxDetailPane.tsx | 13 ++++- .../messages/lib/messageGrouping.test.mjs | 53 +++++++++++++++++++ .../features/messages/lib/messageGrouping.ts | 30 +++++++++++ .../messages/lib/timelineItems.test.mjs | 21 ++++---- .../features/messages/lib/timelineItems.ts | 19 +++---- .../messages/ui/MessageThreadPanel.tsx | 11 +++- 8 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 desktop/src/features/messages/lib/messageGrouping.test.mjs 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 938a23c4313..bd844e70325 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -123,12 +123,12 @@ 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) }), - // 4 min later — within the 5-min window, groups as a continuation. - entry({ id: "b", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 4) }), - // 6 min after "b" — past the window, breaks into a new thought. - entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 10) }), - // 3 min after "c" — within the window again, groups onto "c". - entry({ id: "d", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 13) }), + // 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( @@ -147,12 +147,13 @@ test("buildTimelineItems: same-author messages past the window start a new group test("buildTimelineItems: window is measured against the previous message, not the group start", () => { const author = "author-a"; - // Each message is 4 min after the one above it — a steady stream that never - // gaps out, so grouping continues even though the span exceeds 5 min total. + // 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, 4) }), - entry({ id: "c", pubkey: author, createdAt: dayAt(2026, 6, 14, 12, 8) }), + 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( diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index ed74f91c5e8..87b8a41d6b3 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -12,17 +12,12 @@ 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"; -/** - * 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. - */ -export const MESSAGE_GROUPING_WINDOW_SECONDS = 5 * 60; - /** * One renderable row in the flattened timeline. Dividers carry no message and * never appear in the index map; the three message-bearing kinds do. @@ -118,8 +113,10 @@ export function buildTimelineItems( const isContinuation = previousGroupEntry !== null && hasSameMessageAuthor(previousGroupEntry.message, message) && - message.createdAt - previousGroupEntry.message.createdAt <= - MESSAGE_GROUPING_WINDOW_SECONDS; + 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 });