mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
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:
parent
eb94f8391a
commit
4f48fe7700
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user