From 4f48fe77001454b7d8eb21397ba60b8bdde1e318 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:55:38 -0400 Subject: [PATCH 1/2] fix(desktop): hide non-contiguous islands until scrollback heals them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Out-of-band merges (thread ancestors fetched by id, thread-panel reply subtrees) splice old events into the timeline cache marked nonContiguous. The pager already ignores them when anchoring its cursor, but the render path did not: an island painted the start of an old day immediately, while the middle and end of that day only arrived when contiguous paging caught up — so they visibly popped in above the reader. Hide marked events from the main timeline until paging heals them: - TimelineMessage carries the nonContiguous mark through formatTimelineMessages, - buildMainTimelineEntries skips marked entries (the single seam both ChannelPane and TimelineMessageList render through); island replies still count toward their visible parent's thread summary, - countTopLevelTimelineRows mirrors the filter so the pager's row floor counts only rows that actually render, - the channel unread marker skips hidden islands so the pill and New divider never anchor on an invisible row. Healing is unchanged: when contiguous paging reaches an island the unmarked copy wins the merge and the whole day appears in one commit, in order. Thread panels are unaffected — they derive from the full cache, not the main-timeline entries. Reported by Tyler while hammering #1483: scrolling up sometimes loaded the beginning of a day before its end, then the rest popped in. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../channels/ui/useChannelUnreadState.ts | 8 ++- .../lib/formatTimelineMessages.test.mjs | 31 ++++++++++ .../messages/lib/formatTimelineMessages.ts | 9 ++- .../messages/lib/threadPanel.test.mjs | 62 +++++++++++++++++++ .../src/features/messages/lib/threadPanel.ts | 8 ++- desktop/src/features/messages/types.ts | 9 +++ 6 files changed, 123 insertions(+), 4 deletions(-) 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..ae237c4ef 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -67,6 +67,68 @@ 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("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; }; From 1bc8e24accddfd022d65e765b0807afa9f485869 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 20:59:57 -0400 Subject: [PATCH 2/2] test(desktop): pin island thread-head visibility in the actual panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wren's #1486 review: the island-reply summary test exercised main-timeline aggregation, not buildThreadPanelData. Pin the exact panel shape — a marked island head with a marked descendant renders head + reply in the thread panel while producing zero main-timeline entries. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../messages/lib/threadPanel.test.mjs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index ae237c4ef..132b6bb54 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -105,6 +105,42 @@ test("buildMainTimelineEntries hides non-contiguous islands until paging heals t ); }); +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.