diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index fa72326146..6526d19f8e 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { + countTopLevelTimelineRows, + formatTimelineMessages, +} from "./formatTimelineMessages.ts"; const HEX64_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -100,3 +103,94 @@ test("deletion target with non-hex `e` tag value is ignored", () => { "malformed deletion tag should not match anything", ); }); + +// --------------------------------------------------------------------------- +// countTopLevelTimelineRows — the unit fetch-older pages by. Must match the +// rows `buildMainTimelineEntries` would actually render: top-level content +// events, minus deletions, with thread replies collapsed into their parent. +// --------------------------------------------------------------------------- + +function hex64(char) { + return char.repeat(64); +} + +function message(id, overrides = {}) { + return { + id, + pubkey: PUBKEY_A, + kind: 9, + created_at: 1_700_000_000, + content: "hi", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function reply(id, parentId, overrides = {}) { + return message(id, { + tags: [ + ["h", CHANNEL_ID], + ["e", parentId, "", "reply"], + ], + ...overrides, + }); +} + +test("countTopLevelTimelineRows counts top-level messages", () => { + const events = [ + message(hex64("1")), + message(hex64("2")), + message(hex64("3")), + ]; + assert.equal(countTopLevelTimelineRows(events), 3); +}); + +test("countTopLevelTimelineRows ignores collapsed thread replies", () => { + const root = hex64("1"); + const events = [ + message(root), + reply(hex64("2"), root), + reply(hex64("3"), root), + ]; + // Two replies collapse into the root's summary → one visible row. + assert.equal(countTopLevelTimelineRows(events), 1); +}); + +test("countTopLevelTimelineRows counts broadcast replies as top-level", () => { + const root = hex64("1"); + const broadcast = reply(hex64("2"), root, { + tags: [ + ["h", CHANNEL_ID], + ["e", root, "", "reply"], + ["broadcast", "1"], + ], + }); + assert.equal(countTopLevelTimelineRows([message(root), broadcast]), 2); +}); + +test("countTopLevelTimelineRows excludes deleted messages", () => { + const target = hex64("1"); + const events = [ + message(target), + message(hex64("2")), + deletionEvent(9005, target, { id: hex64("9") }), + ]; + assert.equal(countTopLevelTimelineRows(events), 1); +}); + +test("countTopLevelTimelineRows ignores non-content kinds (reactions)", () => { + const reaction = { + id: hex64("9"), + pubkey: PUBKEY_B, + kind: 7, + created_at: 1_700_000_001, + content: "+", + tags: [ + ["h", CHANNEL_ID], + ["e", hex64("1")], + ], + sig: "sig", + }; + assert.equal(countTopLevelTimelineRows([message(hex64("1")), reaction]), 1); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 6b18a90be7..5cb474c0d9 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -9,7 +9,10 @@ import type { TimelineMessage, TimelineReaction, } from "@/features/messages/types"; -import { getThreadReference } from "@/features/messages/lib/threading"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; import { resolveUserLabel, type UserProfileLookup, @@ -66,6 +69,47 @@ function getDeletionTargets(tags: string[][]) { .map((tag) => tag[1]); } +/** + * Count the *visible top-level rows* a raw event window would render in the + * main channel timeline — the same unit `buildMainTimelineEntries` produces. + * + * This is deliberately NOT `events.length`: thread replies collapse into their + * parent's summary row, deleted events disappear, and non-content kinds + * (reactions, edits, deletions) never render as their own row. A history batch + * heavy with replies can add 100 events but only a handful of rows, which is + * why fetch-older counts rows here, not messages, when deciding how far to page. + * + * Mirrors the two filters that bound the rendered list: + * 1. `formatTimelineMessages` keeps content kinds that aren't deletion targets. + * 2. `buildMainTimelineEntries` keeps entries that are top-level + * (`parentId == null`) or broadcast replies. + */ +export function countTopLevelTimelineRows(events: RelayEvent[]): number { + const deletedEventIds = new Set(); + for (const event of events) { + if ( + event.kind === KIND_DELETION || + event.kind === KIND_NIP29_DELETE_EVENT + ) { + for (const targetId of getDeletionTargets(event.tags)) { + deletedEventIds.add(targetId); + } + } + } + + let count = 0; + for (const event of events) { + if (!isTimelineContentEvent(event) || deletedEventIds.has(event.id)) { + continue; + } + const { parentId } = getThreadReference(event.tags); + if (parentId == null || isBroadcastReply(event.tags)) { + count += 1; + } + } + return count; +} + function getReactionTargetId(tags: string[][]) { for (let index = tags.length - 1; index >= 0; index -= 1) { const tag = tags[index]; diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 0203199a4a..ad4d39d9f9 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; import { channelMessagesKey, mergeTimelineHistoryMessages, @@ -10,6 +11,14 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; const OLDER_MESSAGES_BATCH_SIZE = 100; +// One scroll-up should advance the timeline by a predictable, *visible* amount. +// Because thread replies collapse into their parent and non-content events +// never render, a single 100-message batch can add far fewer rows than that — +// so we page in additional batches until at least this many top-level rows have +// been added (or history runs out). Counting rows, not messages, keeps a +// reply-heavy window from feeling like the fetch did nothing. +const MIN_TOP_LEVEL_ROWS_PER_FETCH = 10; + export function useFetchOlderMessages(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; @@ -43,39 +52,62 @@ export function useFetchOlderMessages(channel: Channel | null) { return; } - // Use the oldest timestamp directly — `until` is inclusive so the relay will - // return the boundary message again, but `sortMessages` deduplicates by id. - // Subtracting 1 risks skipping messages that share the same second. - const oldestTimestamp = currentMessages[0].created_at; isFetchingOlderRef.current = true; setIsFetchingOlder(true); + // Page in batches until the timeline has gained at least + // MIN_TOP_LEVEL_ROWS_PER_FETCH *visible* rows or history is exhausted. + // A single batch is fetched first; only reply-heavy windows that fall short + // of the floor loop again. Each batch re-reads the oldest timestamp from the + // cache so successive `until` values keep walking backward without gaps. + const baselineRowCount = countTopLevelTimelineRows(currentMessages); try { - const olderMessages = await relayClient.fetchChannelHistoryBefore( - channelId, - oldestTimestamp, - OLDER_MESSAGES_BATCH_SIZE, - ); + while (hasOlderMessagesRef.current) { + const messagesBeforeFetch = + queryClient.getQueryData(queryKey) ?? []; + if (messagesBeforeFetch.length === 0) { + break; + } - if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) { - hasOlderMessagesRef.current = false; - setHasOlderMessages(false); - } - - if (olderMessages.length > 0) { - queryClient.setQueryData(queryKey, (current = []) => - mergeTimelineHistoryMessages(current, olderMessages), + // Use the oldest timestamp directly — `until` is inclusive so the relay + // will return the boundary message again, but `sortMessages` + // deduplicates by id. Subtracting 1 risks skipping messages that share + // the same second. + const oldestTimestamp = messagesBeforeFetch[0].created_at; + const olderMessages = await relayClient.fetchChannelHistoryBefore( + channelId, + oldestTimestamp, + OLDER_MESSAGES_BATCH_SIZE, ); - const updatedMessages = - queryClient.getQueryData(queryKey) ?? []; - if ( - updatedMessages.length > 0 && - updatedMessages[0].created_at === oldestTimestamp - ) { + if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) { hasOlderMessagesRef.current = false; setHasOlderMessages(false); } + + if (olderMessages.length > 0) { + queryClient.setQueryData(queryKey, (current = []) => + mergeTimelineHistoryMessages(current, olderMessages), + ); + + const updatedMessages = + queryClient.getQueryData(queryKey) ?? []; + if ( + updatedMessages.length > 0 && + updatedMessages[0].created_at === oldestTimestamp + ) { + hasOlderMessagesRef.current = false; + setHasOlderMessages(false); + } + } + + const rowsGained = + countTopLevelTimelineRows( + queryClient.getQueryData(queryKey) ?? [], + ) - baselineRowCount; + if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) { + break; + } } } catch (error) { console.error("Failed to fetch older messages", channelId, error);