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 });