mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
parent
d94561795a
commit
564ead3856
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user