fix(desktop): page older history until N visible rows, not a fixed message count

Scrolling up fetched a fixed 100-message batch and stopped. Because thread
replies collapse into their parent's summary and non-content events never
render, a reply-heavy window could fetch 100 events but grow the timeline by
only a handful of rows — making one wheel-up feel like it did nothing.

fetchOlder now pages in batches until at least MIN_TOP_LEVEL_ROWS_PER_FETCH
(10) *visible* top-level rows have been added, or history is exhausted. A new
pure helper, countTopLevelTimelineRows, counts rows the same way
buildMainTimelineEntries renders them: content kinds, minus deletions, top-level
or broadcast replies only. The dedup/exhaustion guards already in place
terminate the loop when the window stops advancing.

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-18 16:10:46 -04:00
parent 53c580871e
commit feb9ac4243
3 changed files with 195 additions and 25 deletions

View File

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

View File

@@ -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<string>();
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];

View File

@@ -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<RelayEvent[]>(queryKey) ?? [];
if (messagesBeforeFetch.length === 0) {
break;
}
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(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<RelayEvent[]>(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<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
);
const updatedMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (
updatedMessages.length > 0 &&
updatedMessages[0].created_at === oldestTimestamp
) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
}
const rowsGained =
countTopLevelTimelineRows(
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [],
) - baselineRowCount;
if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) {
break;
}
}
} catch (error) {
console.error("Failed to fetch older messages", channelId, error);