mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Two CI failures on main: 1. File-size guard: lib.rs grew by one line in PR #1246 (show_native_notification added to Tauri command list). Bump the approved override from 1034 to 1035 and update the comment. 2. Scroll-history regression (scroll-history.spec.ts:1363): when the user scrolls back past MAX_TIMELINE_MESSAGES (2000) events, a live subscription event arriving via mergeTimelineCacheMessages triggered normalizeTimelineMessages, which caps to the newest 2000. This evicted the backward-paged roots, reset oldestAfterMerge, and stalled pagination — deepest stalled at ~471 instead of reaching < 50. Fix: mergeTimelineCacheMessages now checks the content-event count on the ORIGINAL current array (before dedup+incoming). If the cache is already in backward-paged state (> MAX_TIMELINE_MESSAGES content events), it skips the cap and returns sort+dedupe only. The cap still applies during normal live sessions (cache at or under the limit). Also exports isTimelineWindowContentEvent and MAX_TIMELINE_MESSAGES from messageQueryKeys.ts so hooks.ts can reference them directly. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
131 lines
3.8 KiB
TypeScript
131 lines
3.8 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";
|
|
|
|
export 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;
|
|
});
|
|
}
|
|
|
|
export 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
|
|
);
|
|
}
|
|
|
|
function capNewestTimelineMessages(normalized: RelayEvent[]) {
|
|
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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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[]) {
|
|
return capNewestTimelineMessages(sortMessages(messages));
|
|
}
|
|
|
|
function isOlderHistoryPage(current: RelayEvent[], history: RelayEvent[]) {
|
|
if (current.length === 0 || history.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const sortedCurrent = sortMessages(current);
|
|
const sortedHistory = sortMessages(history);
|
|
const newestHistory = sortedHistory[sortedHistory.length - 1]?.created_at;
|
|
const oldestCurrent = sortedCurrent[0]?.created_at;
|
|
|
|
if (newestHistory === undefined || oldestCurrent === undefined) {
|
|
return false;
|
|
}
|
|
|
|
return newestHistory <= oldestCurrent;
|
|
}
|
|
|
|
function normalizeTimelineHistoryMessages(
|
|
current: RelayEvent[],
|
|
history: RelayEvent[],
|
|
) {
|
|
return sortMessages([...current, ...history]);
|
|
}
|
|
|
|
export function mergeTimelineHistoryMessages(
|
|
current: RelayEvent[],
|
|
history: RelayEvent[],
|
|
) {
|
|
if (isOlderHistoryPage(current, history)) {
|
|
return normalizeTimelineHistoryMessages(current, history);
|
|
}
|
|
|
|
return normalizeTimelineMessages([...current, ...history]);
|
|
}
|