feat(desktop): add 10-minute message grouping window (#1578)

Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-07-07 07:42:51 -07:00
committed by GitHub
co-authored by npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
parent d94561795a
commit 564ead3856
8 changed files with 182 additions and 14 deletions
+1
View File
@@ -54,6 +54,7 @@ export type InboxReply = {
authorPubkey: string;
avatarUrl: string | null;
content: string;
createdAt: number;
depth?: number;
fullTimestampLabel: string;
id: string;
@@ -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,
@@ -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({
<div>
{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 (
@@ -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);
});
@@ -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;
}
@@ -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) }),
];
@@ -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];
@@ -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 });