fix(desktop): hide non-contiguous islands until scrollback heals them

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-02 20:55:38 -04:00
parent eb94f8391a
commit 4f48fe7700
6 changed files with 123 additions and 4 deletions

View File

@@ -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,

View File

@@ -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")),

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {

View File

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