Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/src/features/home/lib/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type InboxReply = {
authorPubkey: string;
avatarUrl: string | null;
content: string;
createdAt: number;
depth?: number;
fullTimestampLabel: string;
id: string;
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -704,6 +705,7 @@ export function HomeView({
?.avatarUrl ?? null)
: null,
content,
createdAt: result.createdAt,
depth: result.depth,
fullTimestampLabel: formatInboxFullTimestamp(
result.createdAt,
Expand Down
13 changes: 11 additions & 2 deletions desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
53 changes: 53 additions & 0 deletions desktop/src/features/messages/lib/messageGrouping.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
30 changes: 30 additions & 0 deletions desktop/src/features/messages/lib/messageGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
75 changes: 67 additions & 8 deletions desktop/src/features/messages/lib/timelineItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -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(
Expand All @@ -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 = [
Expand All @@ -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) }),
];

Expand Down
11 changes: 9 additions & 2 deletions desktop/src/features/messages/lib/timelineItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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];
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
Loading