/** * Composer state: the draft, the attachment queue and the send/edit handshake. * * Drafts are server state (`PUT /chat/rooms/{id}/draft`, thread-scoped), so the * text survives a device switch — but they are saved debounced and never during * the initial load, otherwise typing one character would write a row and every * room switch would overwrite a draft the reader never touched. * * Attachments follow the backend's three-step contract (reserve → presigned PUT * → finalize) and only a `ready` id may be referenced by a message, so a half * uploaded file can never produce a broken post. Uploads are held while a send * is in flight and flushed with it, which is what makes "type, attach, send" * read as one action. */ import { type Attachment, clearDraft, createAttachment, describeFailure, finalizeAttachment, getDraft, MAX_ATTACHMENTS, MAX_MESSAGE_LEN, MAX_UPLOAD_BYTES, putUpload, readImageSize, saveDraft, textLength, type UploadItem, } from '@app/domains/chat/model' import { t } from '@app/i18n' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' const DRAFT_SAVE_DEBOUNCE_MS = 1_200 /** Rows the composer's own draft load must not clobber. */ const DRAFT_MAX_LEN = MAX_MESSAGE_LEN export interface UseComposerOptions { roomId: string /** Drafts are per thread when one is open. */ threadId?: string | null /** Freezes every request (voice channel, no access, outage gate). */ enabled: boolean /** Message being edited: the draft machinery is bypassed entirely. */ editingId?: string | null /** Seed text for the edit box. */ editingContent?: string /** Called after a successful send / edit so the caller can clear reply targets. */ onSubmitted: (content: string, attachments: Attachment[]) => Promise | boolean /** Notify the socket that typing started (throttled by the client). */ onTyping?: () => void } export interface ComposerState { text: string setText: (value: string) => void /** Attachments that finished all three upload steps and may be sent. */ readyAttachments: Attachment[] uploads: UploadItem[] addFiles: (files: File[]) => void removeUpload: (localId: string) => void retryUpload: (localId: string) => void clearUploads: () => void /** True when a file is still moving: send is deferred until it lands. */ uploading: boolean blocked: string | null overLength: boolean remaining: number canSend: boolean submit: () => Promise /** Cancel an edit: clears the box and the draft machinery. */ reset: () => void draftNotice: string | null } let uploadSeq = 0 export function useComposer({ roomId, threadId = null, enabled, editingId = null, editingContent = '', onSubmitted, onTyping, }: UseComposerOptions): ComposerState { const [text, setTextState] = useState(editingId ? editingContent.slice(0, DRAFT_MAX_LEN) : '') const [uploads, setUploads] = useState([]) const [draftNotice, setDraftNotice] = useState(null) const [loadedDraft, setLoadedDraft] = useState(false) const touched = useRef(false) const saveTimer = useRef | undefined>(undefined) // Entering or leaving an edit is a scope change too: the box is handed to the // message being corrected and comes back empty afterwards (the room's stored // draft is then re-read, so nothing typed before the detour is lost). const draftScope = `${roomId}\u0000${threadId ?? ''}` const scopeKey = `${draftScope}\u0000edit:${editingId ?? ''}` const urls = useRef>(new Set()) // Mirror of `uploads` for callbacks that must reason about the queue without // writing new state from inside a state updater. React may invoke an updater // more than once (StrictMode) and drops all but one result, so ids collected // inside it can desync from the committed state. const uploadsRef = useRef([]) /** The single writer of the queue: keeps the ref and the state in lockstep. */ const commitUploads = useCallback((update: (current: UploadItem[]) => UploadItem[]) => { const next = update(uploadsRef.current) uploadsRef.current = next setUploads(next) }, []) // A new scope starts with an empty box; the previous room's text belongs to // that room's draft, never to this one. const lastScope = useRef(scopeKey) const lastDraftScope = useRef(draftScope) useEffect(() => { if (lastScope.current === scopeKey) return const [previousRoom, previousThread] = lastDraftScope.current.split('\u0000') lastScope.current = scopeKey lastDraftScope.current = draftScope // The debounce below fires on a timer, so switching rooms inside that window // would otherwise either lose the text or write it into the *new* room's // draft: flush it to the scope it was typed in first. if (saveTimer.current) { clearTimeout(saveTimer.current) saveTimer.current = undefined } if (touched.current && text) { saveDraft(previousRoom ?? roomId, text, previousThread || undefined).catch(() => undefined) } touched.current = false setTextState('') setLoadedDraft(false) commitUploads((current) => { for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) return [] }) urls.current.clear() // `commitUploads` is a stable `useCallback`: listing it is what keeps the // queue write and this reset on the same writer. }, [draftScope, editingId, roomId, scopeKey, text, threadId, commitUploads]) /* --------------------------------------------------------------- draft */ useEffect(() => { if (!enabled || editingId || touched.current) return let active = true getDraft(roomId, threadId ?? undefined).then( (draft) => { // Never overwrite what the user already started typing while this was in // flight (a fast click into the room and straight into the box). if (active && draft?.content && !touched.current) { setTextState(draft.content.slice(0, DRAFT_MAX_LEN)) touched.current = true } if (active) setLoadedDraft(true) }, () => active && setLoadedDraft(true), ) return () => { active = false } }, [enabled, editingId, roomId, threadId]) const persistDraft = useCallback( (value: string) => { if (!enabled || editingId) return // An empty write deletes the draft server-side, which is what "the user // erased the box" means; skipping it would resurrect the text next visit. saveDraft(roomId, value, threadId ?? undefined).catch(() => { setDraftNotice(t('chat.composer.draftSaveFailed')) }) }, [enabled, editingId, roomId, threadId], ) useEffect(() => { if (!loadedDraft || editingId) return if (saveTimer.current) clearTimeout(saveTimer.current) saveTimer.current = setTimeout(() => { saveTimer.current = undefined // A scope switch flushes the pending write itself, so a timer that outlived // the room it belongs to must never write into the new one. if (lastScope.current !== scopeKey) return persistDraft(text) }, DRAFT_SAVE_DEBOUNCE_MS) return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } // The timer must not restart on every render, only on text/scope changes. // oxlint-disable-next-line react-hooks/exhaustive-deps -- 定时器只跟随 text/scope 重启;其余依赖变化会各自处理 }, [text, roomId, threadId, loadedDraft, editingId]) useEffect( () => () => { for (const url of urls.current) URL.revokeObjectURL(url) urls.current.clear() }, [], ) const setText = useCallback( (value: string) => { touched.current = true setDraftNotice(null) setTextState(value) if (value) onTyping?.() }, [onTyping], ) const reset = useCallback(() => { touched.current = false setTextState('') commitUploads((current) => { for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) return [] }) urls.current.clear() if (enabled && !editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined) }, [commitUploads, editingId, enabled, roomId, threadId]) /* ----------------------------------------------------------- attachments */ const patch = useCallback( (localId: string, changes: Partial) => { commitUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...changes } : item))) }, [commitUploads], ) const runUpload = useCallback( async (item: UploadItem) => { patch(item.localId, { state: 'checking', error: undefined, progress: 0 }) try { const intent = await createAttachment({ filename: item.file.name, content_type: item.file.type || 'application/octet-stream', size: item.file.size, }) patch(item.localId, { attachmentId: intent.id, state: 'uploading', progress: 0.25 }) await putUpload(intent.upload_url, item.file) patch(item.localId, { state: 'finalizing', progress: 0.8 }) const size = item.width ? { width: item.width, height: item.height } : await readImageSize(item.file) const meta = await finalizeAttachment(intent.id, size ?? undefined) patch(item.localId, { state: 'ready', progress: 1, attachment: meta, width: meta.width ?? undefined, height: meta.height ?? undefined, }) } catch (error) { patch(item.localId, { state: 'failed', error: describeFailure(error), progress: 0 }) } }, [patch], ) const addFiles = useCallback( (files: File[]) => { if (!enabled || files.length === 0) return // The batch is decided before state is touched: the ids handed to // `runUpload` must be exactly the ids that reach the queue, otherwise // every progress patch misses and the row spins on「上传中…」forever. const accepted: UploadItem[] = [] const queued = uploadsRef.current.length let overflow = false for (const file of files) { if (queued + accepted.length >= MAX_ATTACHMENTS) { overflow = true break } const localId = `upload:${Date.now().toString(36)}:${(uploadSeq += 1)}` if (file.size > MAX_UPLOAD_BYTES) { accepted.push({ localId, file, progress: 0, state: 'too_big', error: t('chat.composer.overLimit', { mb: Math.round(MAX_UPLOAD_BYTES / 1024 / 1024) }), }) continue } const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined if (previewUrl) urls.current.add(previewUrl) accepted.push({ localId, file, previewUrl, progress: 0.05, state: 'checking', }) } if (overflow) setDraftNotice(t('chat.composer.attachmentLimit', { max: MAX_ATTACHMENTS })) if (accepted.length === 0) return commitUploads((current) => [...current, ...accepted]) for (const item of accepted) void runUpload(item) }, [commitUploads, enabled, runUpload], ) const removeUpload = useCallback( (localId: string) => { commitUploads((current) => { const item = current.find((candidate) => candidate.localId === localId) if (item?.previewUrl) { URL.revokeObjectURL(item.previewUrl) urls.current.delete(item.previewUrl) } return current.filter((candidate) => candidate.localId !== localId) }) }, [commitUploads], ) const retryUpload = useCallback( (localId: string) => { const item = uploads.find((candidate) => candidate.localId === localId) if (item && item.state !== 'too_big') void runUpload(item) }, [runUpload, uploads], ) const clearUploads = useCallback(() => { commitUploads((current) => { for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) return [] }) }, [commitUploads]) /* ----------------------------------------------------------------- send */ const readyAttachments = useMemo( () => uploads .filter((item) => item.state === 'ready' && item.attachment) .map((item) => item.attachment as unknown as Attachment), [uploads], ) const uploading = uploads.some( (item) => item.state === 'checking' || item.state === 'uploading' || item.state === 'finalizing', ) const failedUploads = uploads.filter((item) => item.state === 'failed' || item.state === 'too_big') const blocked = uploading ? t('chat.composer.blockedUploading') : failedUploads.length > 0 ? t('chat.composer.blockedFailed') : null const length = textLength(text) const overLength = length > DRAFT_MAX_LEN const canSend = enabled && !overLength && !blocked && (Boolean(text.trim()) || readyAttachments.length > 0) const submit = useCallback(async () => { const body = text.replace(/\s+$/, '') if (!canSend) return false const attachments = readyAttachments // Clear first: the box must not keep the text if the send is slow, and the // server-side draft is dropped by the same rule the eraser uses. touched.current = false setTextState('') commitUploads((current) => { for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) return [] }) urls.current.clear() if (!editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined) const ok = await onSubmitted(body, attachments) if (!ok) { // Nothing was sent: give the text back rather than losing the message. setTextState(body) touched.current = Boolean(body) return false } return true }, [canSend, commitUploads, editingId, onSubmitted, readyAttachments, roomId, text, threadId]) return { text, setText, readyAttachments, uploads, addFiles, removeUpload, retryUpload, clearUploads, uploading, blocked, overLength, remaining: Math.max(0, DRAFT_MAX_LEN - length), canSend, submit, reset, draftNotice, } }