mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
parent
53c580871e
commit
feb9ac4243
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user