diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index 34f16dc8f..f40bbc724 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -188,8 +188,12 @@ export function useChannelUnreadState({ const { firstUnreadMessageId, unreadCount } = React.useMemo( () => computeChannelUnreadMarker( - timelineMessages.filter((message) => - isConversationalUnreadKind(message.kind), + timelineMessages.filter( + (message) => + isConversationalUnreadKind(message.kind) && + // Hidden islands (see buildMainTimelineEntries) can't anchor the + // divider or inflate the pill — they aren't rendered rows yet. + !message.nonContiguous, ), openFrontierSeconds, isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed, diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index bedfb156e..be0ecbfa3 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -92,6 +92,25 @@ function huddleStarted(overrides = {}) { // late edit/delete for a visible old message would silently render stale. // --------------------------------------------------------------------------- +test("formatTimelineMessages propagates the nonContiguous mark", () => { + const island = streamMessage({ nonContiguous: true }); + const contiguous = streamMessage({ id: HEX64_B, created_at: 1_700_000_001 }); + const out = formatTimelineMessages( + [island, contiguous], + null, + undefined, + null, + ); + assert.deepEqual( + out.map((m) => ({ id: m.id, nonContiguous: m.nonContiguous })), + [ + { id: HEX64_A, nonContiguous: true }, + { id: HEX64_B, nonContiguous: undefined }, + ], + "the local-only island mark must survive formatting so buildMainTimelineEntries can hide it", + ); +}); + test("a far-future edit still rewrites the body of an old message", () => { const old = streamMessage({ created_at: 1_700_000_000 }); const lateEdit = streamEdit(HEX64_A, "edited body", { @@ -428,6 +447,18 @@ function reply(id, parentId, overrides = {}) { }); } +test("countTopLevelTimelineRows excludes hidden non-contiguous islands", () => { + // Mirrors buildMainTimelineEntries: islands don't render, so they must not + // count toward the pager's row floor — otherwise a fetch pass could stop + // "satisfied" while the visible timeline gained fewer rows than the floor. + const events = [ + message(hex64("1"), { nonContiguous: true }), + message(hex64("2")), + message(hex64("3")), + ]; + assert.equal(countTopLevelTimelineRows(events), 2); +}); + test("countTopLevelTimelineRows counts top-level messages", () => { const events = [ message(hex64("1")), diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 1a2018c92..383c13579 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -101,7 +101,13 @@ export function countTopLevelTimelineRows(events: RelayEvent[]): number { let count = 0; for (const event of events) { - if (!isTimelineContentEvent(event) || deletedEventIds.has(event.id)) { + if ( + !isTimelineContentEvent(event) || + deletedEventIds.has(event.id) || + // Mirror `buildMainTimelineEntries`: out-of-band islands are hidden + // until contiguous paging heals them, so they are not visible rows. + event.nonContiguous + ) { continue; } const { parentId } = getThreadReference(event.tags); @@ -442,6 +448,7 @@ export function formatTimelineMessages( pending: event.pending, edited: edit !== undefined, kind: event.kind, + nonContiguous: event.nonContiguous, // When edited, swap the original event's imeta tags for the edit's // imeta tags. All non-imeta tags on the original are preserved. // Logic lives in `applyEditTagOverlay.mjs` so prod and tests share diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 5bf870c2b..132b6bb54 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -67,6 +67,104 @@ test("buildMainTimelineEntries includes broadcast replies", () => { ); }); +test("buildMainTimelineEntries hides non-contiguous islands until paging heals them", () => { + // An out-of-band ancestor (thread root fetched by id) lands in the cache + // days before the contiguous window. Rendering it would paint the start of + // an old day before its middle/end exist — so it stays hidden while marked. + const island = message({ + id: "island-root", + createdAt: 1, + nonContiguous: true, + }); + const contiguousRoot = message({ id: "contiguous-root", createdAt: 100 }); + // A reply that references the island still collapses into it (not the main + // list) — the island's absence must not promote or duplicate anything. + const islandReply = message({ + id: "island-reply", + createdAt: 101, + parentId: "island-root", + rootId: "island-root", + depth: 1, + tags: [["e", "island-root", "", "reply"]], + nonContiguous: true, + }); + + assert.deepEqual( + buildMainTimelineEntries([island, contiguousRoot, islandReply]).map( + (entry) => entry.message.id, + ), + ["contiguous-root"], + ); + + // Healed: contiguous paging re-fetched the events, the mark cleared, and + // the whole block appears at once, in order. + const healed = [message({ id: "island-root", createdAt: 1 }), contiguousRoot]; + assert.deepEqual( + buildMainTimelineEntries(healed).map((entry) => entry.message.id), + ["island-root", "contiguous-root"], + ); +}); + +test("buildThreadPanelData renders a marked island head and its marked replies while the main timeline hides both", () => { + // Thread-open on a head that is itself an island (spliced by + // useThreadReplies/useLoadMissingAncestors before contiguous paging reached + // it): the panel derives from the full message list and must show the head + // and its descendants, while buildMainTimelineEntries hides the duplicate + // main-timeline rows until paging heals them. + const islandHead = message({ + id: "island-head", + createdAt: 1, + nonContiguous: true, + }); + const islandReply = message({ + id: "island-reply", + createdAt: 2, + parentId: "island-head", + rootId: "island-head", + depth: 1, + tags: [["e", "island-head", "", "reply"]], + nonContiguous: true, + }); + + const panelData = buildThreadPanelData( + [islandHead, islandReply], + "island-head", + null, + new Set(), + ); + assert.equal(panelData.threadHead?.id, "island-head"); + assert.deepEqual( + panelData.visibleReplies.map((entry) => entry.message.id), + ["island-reply"], + ); + + assert.deepEqual(buildMainTimelineEntries([islandHead, islandReply]), []); +}); + +test("buildMainTimelineEntries keeps island replies in the visible parent's summary", () => { + // The reply subtree fetched on thread-open is an island in the timeline, + // but the summary on its (contiguous, visible) root must still count it. + const root = message({ id: "root", createdAt: 1 }); + const islandReply = message({ + id: "island-reply", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + tags: [["e", "root", "", "reply"]], + nonContiguous: true, + }); + + const entries = buildMainTimelineEntries([root, islandReply]); + assert.deepEqual( + entries.map((entry) => ({ + id: entry.message.id, + replyCount: entry.summary?.replyCount ?? 0, + })), + [{ id: "root", replyCount: 1 }], + ); +}); + test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => { const huddleRoot = message({ id: "huddle-root", diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 300b5213a..d909f8c1d 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -393,7 +393,13 @@ export function buildMainTimelineEntries( return messages .filter( (message) => - message.parentId == null || isBroadcastReply(message.tags ?? []), + // Out-of-band islands (thread ancestors, thread-panel subtrees) stay + // hidden until contiguous paging heals them: rendering an island + // paints the start of an old day before its middle and end exist in + // cache, so the rest "pops in" above the reader. Thread summaries + // still count island replies — the index above sees every message. + !message.nonContiguous && + (message.parentId == null || isBroadcastReply(message.tags ?? [])), ) .map((message) => { return { diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b98b1f249..9f8b5146e 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -36,4 +36,13 @@ export type TimelineMessage = { kind?: number; tags?: string[][]; reactions?: TimelineReaction[]; + /** + * Mirrors {@link RelayEvent.nonContiguous}: merged out-of-band (thread + * ancestor, thread-panel subtree), so the history around it may be unloaded. + * The main timeline hides such rows until contiguous paging heals them — + * otherwise the start of an old day pops in before its middle and end. + * Thread-panel derivations ignore the flag: a subtree fetched on thread-open + * is complete within the thread even though it is an island in the timeline. + */ + nonContiguous?: boolean; };