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