Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions desktop/src/features/channels/ui/useChannelUnreadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/features/messages/lib/formatTimelineMessages.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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")),
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions desktop/src/features/messages/lib/threadPanel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/messages/lib/threadPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/messages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading