Skip to content
Clone
main
397 lines 14.1 KB
/**
* 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> | 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<boolean>
/** 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<UploadItem[]>([])
const [draftNotice, setDraftNotice] = useState<string | null>(null)
const [loadedDraft, setLoadedDraft] = useState(false)
const touched = useRef(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<Set<string>>(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<UploadItem[]>([])
/** 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<UploadItem>) => {
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,
}
}