Skip to content
Clone
main
380 lines 12.9 KB
/**
* 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 type { ChatMessage, PendingMessage } from '@app/domains/chat/model'
import { mergeMessages, mergeReactions, tipSeq } from '@app/domains/chat/model'
/** 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<string, ResolvedRef>
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<ChatMessage, 'thread_id' | 'thread_broadcast'>,
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
}