Files
buzz/desktop/src/features/messages/lib/messageQueryKeys.ts
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d 022edf3fc4 fix(desktop): split channel history fetch by kind so limit buys visible depth
The GUI history filter requested the broad CHANNEL_EVENT_KINDS set (reactions,
edits, deletions alongside messages), so a 200-event "limit" budget bought only
visible-message depth diluted by aux events — measured 136 messages / 200 events
on a reaction-heavy channel, deterministic across runs. Scrollback inherited the
same dilution, and same-second events could shuffle position when merged from
two REQs (history vs live-sub).

Fix, per the invariant converged on in #buzz-bugs:
1. Base history filter = visible content kinds only (CHANNEL_TIMELINE_CONTENT_KINDS,
   mirroring isTimelineContentEvent), so the limit budget maps to visible rows.
2. Backfill reactions/edits/deletions by `#e` over the loaded message ids — by
   reference, not by time window, so a late edit/delete for a visible old message
   still applies (avoids the silent-stale-state trap).
3. Keep the broad-kind live subscription for future aux events; cold/scrollback
   correctness comes from the #e backfill, not the tail.
4. Sort GUI merges by (created_at, id) so same-second merges are deterministic —
   both the cache sort (sortMessages) and the history-resolve sort (sortEvents)
   carry the id tiebreaker, keeping the two on one invariant.

Extracted the channel filter-builders into relayChannelFilters.ts to keep
relayClientSession.ts under its size cap. Aux backfill lives in its own module
(auxBackfill.ts) to avoid coupling useFetchOlderMessages to hooks.ts.

Regression tests prove the reducer applies aux by `#e` reference regardless of
timestamp (far-future edit rewrites an old body; far-future deletion hides an
old message), plus sort order-independence for both sort functions.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-19 19:57:31 -04:00

100 lines
3.0 KiB
TypeScript

import type { RelayEvent } from "@/shared/api/types";
import {
KIND_JOB_ACCEPTED,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_JOB_PROGRESS,
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_V2,
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";
const MAX_TIMELINE_MESSAGES = 2_000;
export function channelMessagesKey(channelId: string) {
return ["channel-messages", channelId] as const;
}
export function dedupeMessagesById(messages: RelayEvent[]) {
const seenIds = new Set<string>();
const deduped: RelayEvent[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (seenIds.has(message.id)) {
continue;
}
seenIds.add(message.id);
deduped.push(message);
}
return deduped.reverse();
}
export function sortMessages(messages: RelayEvent[]) {
return dedupeMessagesById(messages).sort((left, right) => {
if (left.created_at !== right.created_at) {
return left.created_at - right.created_at;
}
// Tiebreak same-second events on id so the merge order is deterministic.
// Without this, two events sharing a created_at can land in a different
// position depending on which REQ (history vs live-sub) delivered them
// first — reading as a "missing"/shuffled message at a fixed scroll offset.
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
});
}
function isTimelineWindowContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||
event.kind === KIND_STREAM_MESSAGE_DIFF ||
event.kind === KIND_SYSTEM_MESSAGE ||
event.kind === KIND_JOB_REQUEST ||
event.kind === KIND_JOB_ACCEPTED ||
event.kind === KIND_JOB_PROGRESS ||
event.kind === KIND_JOB_RESULT ||
event.kind === KIND_JOB_CANCEL ||
event.kind === KIND_JOB_ERROR
);
}
/**
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} visible
* content events so de-virtualized rendering does not grow into an unbounded
* DOM during long-lived channel sessions.
*
* Auxiliary events (reactions, edits, tombstones) are kept in cache so they can
* still apply to retained or later-loaded content, but they must not consume the
* visible message window and evict older loaded roots.
*/
export function normalizeTimelineMessages(messages: RelayEvent[]) {
const normalized = sortMessages(messages);
const contentEvents = normalized.filter(isTimelineWindowContentEvent);
if (contentEvents.length <= MAX_TIMELINE_MESSAGES) {
return normalized;
}
const retainedContentIds = new Set(
contentEvents.slice(-MAX_TIMELINE_MESSAGES).map((event) => event.id),
);
return normalized.filter(
(event) =>
!isTimelineWindowContentEvent(event) || retainedContentIds.has(event.id),
);
}
export function mergeTimelineHistoryMessages(
current: RelayEvent[],
history: RelayEvent[],
) {
return normalizeTimelineMessages([...current, ...history]);
}