/** * The transcript store: one reducer that owns every message-list mutation, so * the HTTP seed, the WebSocket live stream and the user's own optimistic posts * all funnel through the same ordering/dedupe rules. * * Invariants the reducer guarantees: * - `rows` stays ascending by `seq` and free of duplicate ids (a `history` * snapshot repeats rows the room channel already delivered); * - `pending` holds only local rows, in send order, rendered after `rows`; * - an edit or a reaction aggregate arriving over the socket never loses the * local user's own `me` flag, because that payload is built for the *actor*; * - trimming to {@link TRANSCRIPT_CAP} is counted in `olderTrimmed`, so the * "load older" affordance keeps working instead of looking exhausted. */ import { mergeMessages, mergeReactions, tipSeq } from '@/entities/chat' import type { ChatMessage, PendingMessage } from '@/entities/chat' /** A reply-to target that is not part of the loaded window. */ export type ResolvedRef = | { state: 'loading' } | { state: 'ready'; message: ChatMessage } | { state: 'gone' } export interface TranscriptState { roomId: string /** Scope of the list: null is the room's main stream. */ threadId: string | null rows: ChatMessage[] pending: PendingMessage[] refs: Record oldestSeq: number | null newestSeq: number | null hasOlder: boolean loadingSeed: boolean loadingOlder: boolean loadingNewer: boolean seeded: boolean /** Fatal for this room: the transcript cannot be shown at all. */ error: string | null /** The socket reported `resync_required{access revoked}` for this room. */ accessRevoked: boolean /** Rows dropped from memory by the cap; older pages stay fetchable. */ olderTrimmed: number highlightId: string | null /** Bumped by live traffic so panels can refetch pins/members/threads. */ liveVersion: number /** Bumped by live *message* traffic (read-state and scroll logic watch it). */ messageVersion: number } export type TranscriptAction = /** New scope: drop everything, including the previous room's cursor. */ | { type: 'reset'; roomId: string; threadId: string | null } | { type: 'seedStart' } /** * Prefill from the local-first store while the network seed is in flight. * Same merge invariants as `seedDone` (dedupe by id, ascending by seq), but * `loadingSeed` stays true — the fresh tail still replaces these rows. */ | { type: 'seedCached' items: ChatMessage[] hasMore: boolean oldestSeq: number | null newestSeq: number | null } | { type: 'seedDone' items: ChatMessage[] hasMore: boolean oldestSeq: number | null newestSeq: number | null } | { type: 'seedError'; message: string } | { type: 'olderStart' } | { type: 'olderDone'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null } | { type: 'newerStart' } /** Back to the tail: the window is replaced, never merged (a jumped-back reader * has an intentional hole between what they read and what is new). */ | { type: 'tailReplaced'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null; newestSeq: number | null } | { type: 'liveHistory'; items: ChatMessage[] } | { type: 'published'; message: ChatMessage } | { type: 'edited'; message: ChatMessage } | { type: 'deleted'; messageIds: string[] } | { type: 'pinned'; messageId: string; pinned: boolean } | { type: 'reactions'; messageId: string; reactions: readonly { emoji: string; count: number }[] } | { type: 'pendingAdd'; message: PendingMessage } | { type: 'pendingSent'; localId: string; message: ChatMessage } | { type: 'pendingFail'; localId: string; error: string } | { type: 'pendingRetry'; localId: string } | { type: 'pendingDrop'; localId: string } | { type: 'refStart'; messageId: string } | { type: 'refDone'; messageId: string; message: ChatMessage | null } | { type: 'highlight'; messageId: string | null } | { type: 'accessRevoked' } | { type: 'liveTick'; message?: boolean } export function createTranscriptState(roomId: string, threadId: string | null): TranscriptState { return { roomId, threadId, rows: [], pending: [], refs: {}, oldestSeq: null, newestSeq: null, hasOlder: false, loadingSeed: true, loadingOlder: false, loadingNewer: false, seeded: false, error: null, accessRevoked: false, olderTrimmed: 0, highlightId: null, liveVersion: 0, messageVersion: 0, } } /** * Does a live row belong to this list? The main stream and a thread panel never * mix, mirroring the backend filter `thread_id IS NULL OR thread_broadcast`. */ export function belongsToScope( message: Pick, threadId: string | null | undefined, ): boolean { if (threadId) return message.thread_id === threadId return !message.thread_id || message.thread_broadcast === true } function merged(state: TranscriptState, incoming: ChatMessage[]) { const { items, dropped } = mergeMessages(state.rows, incoming) return { rows: items, newestSeq: Math.max(state.newestSeq ?? 0, tipSeq(items)), olderTrimmed: state.olderTrimmed + dropped, } } export function transcriptReducer( state: TranscriptState, action: TranscriptAction, ): TranscriptState { switch (action.type) { case 'reset': if (state.roomId === action.roomId && state.threadId === action.threadId) return state return createTranscriptState(action.roomId, action.threadId) case 'seedStart': return { ...state, loadingSeed: true, error: null } case 'seedCached': { // The stored page renders while the live seed is still in flight; the // reducer's merge keeps dedupe/ordering, and `seedDone` later replaces // any row the server has since edited (incoming wins per id). const next = merged(state, action.items) return { ...state, ...next, oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq), newestSeq: action.newestSeq ?? next.newestSeq, hasOlder: action.hasMore, seeded: true, } } case 'seedDone': { const next = merged(state, action.items) return { ...state, ...next, oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq), newestSeq: action.newestSeq ?? next.newestSeq, hasOlder: action.hasMore, loadingSeed: false, seeded: true, error: null, } } case 'seedError': // Keep whatever is on screen: a failed refresh must not blank a room that // already rendered, but the caller still needs the reason. return { ...state, loadingSeed: false, error: action.message } case 'olderStart': return { ...state, loadingOlder: true } case 'olderDone': { const next = merged(state, action.items) return { ...state, ...next, hasOlder: action.hasMore, oldestSeq: action.oldestSeq ?? next.rows[0]?.seq ?? state.oldestSeq, loadingOlder: false, } } case 'newerStart': return { ...state, loadingNewer: true } case 'tailReplaced': { const rows = [...action.items].sort((a, b) => a.seq - b.seq) return { ...state, rows, oldestSeq: action.oldestSeq ?? (rows[0]?.seq ?? null), newestSeq: action.newestSeq ?? (rows[rows.length - 1]?.seq ?? state.newestSeq), hasOlder: action.hasMore, loadingSeed: false, loadingNewer: false, seeded: true, highlightId: null, olderTrimmed: 0, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1, } } case 'liveHistory': { if (action.items.length === 0) return state return { ...state, ...merged(state, action.items), loadingSeed: false, seeded: true, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1, } } case 'published': { if (state.rows.some(row => row.id === action.message.id)) return state return { ...state, ...merged(state, [action.message]), loadingSeed: false, seeded: true, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1, } } case 'edited': { const index = state.rows.findIndex(row => row.id === action.message.id) if (index < 0) return state const rows = [...state.rows] const previous = rows[index] rows[index] = { ...action.message, reactions: mergeReactions(previous.reactions, action.message.reactions ?? []), } return { ...state, rows, liveVersion: state.liveVersion + 1 } } case 'deleted': { const gone = new Set(action.messageIds) const rows = state.rows.filter(row => !gone.has(row.id)) if (rows.length === state.rows.length) return state return { ...state, rows, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1, } } case 'pinned': { const index = state.rows.findIndex(row => row.id === action.messageId) if (index < 0) return state const rows = [...state.rows] rows[index] = { ...rows[index], pinned: action.pinned } return { ...state, rows, liveVersion: state.liveVersion + 1 } } case 'reactions': { const index = state.rows.findIndex(row => row.id === action.messageId) if (index < 0) return state const rows = [...state.rows] rows[index] = { ...rows[index], reactions: mergeReactions(rows[index].reactions, action.reactions) } return { ...state, rows } } case 'pendingAdd': return { ...state, pending: [...state.pending, action.message] } case 'pendingSent': { const pending = state.pending.filter(item => item.localId !== action.localId) if (state.rows.some(row => row.id === action.message.id)) return { ...state, pending } return { ...state, ...merged(state, [action.message]), pending, seeded: true, loadingSeed: false, } } case 'pendingFail': return { ...state, pending: state.pending.map(item => item.localId === action.localId ? { ...item, state: 'failed', error: action.error, sending: false } : item, ), } case 'pendingRetry': return { ...state, pending: state.pending.map(item => item.localId === action.localId ? { ...item, state: 'sending', error: undefined, sending: true } : item, ), } case 'pendingDrop': return { ...state, pending: state.pending.filter(item => item.localId !== action.localId) } case 'refStart': if (state.refs[action.messageId]) return state return { ...state, refs: { ...state.refs, [action.messageId]: { state: 'loading' } } } case 'refDone': return { ...state, refs: { ...state.refs, [action.messageId]: action.message ? { state: 'ready', message: action.message } : { state: 'gone' }, }, } case 'highlight': return { ...state, highlightId: action.messageId } case 'accessRevoked': return { ...state, accessRevoked: true, rows: [], pending: [], hasOlder: false } case 'liveTick': return action.message ? { ...state, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1 } : { ...state, liveVersion: state.liveVersion + 1 } default: return state } } /** * The POST response and the socket echo of your own message race each other. The * id match in `pendingSent` covers the normal path; when the response was lost * (timeout) the echo is matched by identical content, attachments, reply and * thread so a stuck「发送中」row cannot outlive the message it stands for. */ /** * Only a *failed* row is eligible: its request already reported an error, so the * only way the message exists on the server is that the response was lost. A * pending row that is merely still in flight is matched by id in `pendingSent`. */ export function findEchoedPending( pending: readonly PendingMessage[], message: ChatMessage, viewerId?: number, ): PendingMessage | undefined { if (pending.length === 0 || message.author_type !== 'User') return undefined if (viewerId && message.author_id !== viewerId) return undefined const key = (attachments: readonly { id: string }[]) => attachments.map(a => a.id).sort().join(',') return pending.find( item => item.state === 'failed' && item.content === message.content && key(item.attachments) === key(message.attachments) && (item.reply_to_id ?? null) === (message.reply_to_id ?? null) && (item.thread_id ?? null) === (message.thread_id ?? null), ) } export function isEmptyTranscript(state: TranscriptState): boolean { return state.rows.length === 0 && state.pending.length === 0 }