fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
@@ -32,10 +34,14 @@ import {
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
setEditorImageUploadingByTitle,
|
||||
type UploadedEditorImage,
|
||||
} from "@/lib/im-editor-image"
|
||||
import {
|
||||
createMessageEditorDraftStore,
|
||||
type MessageEditorDraft,
|
||||
type MessageEditorDraftStore,
|
||||
} from "@/lib/message-editor-drafts"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
|
||||
export type SharedMessageEditorProps = {
|
||||
variant: SharedMessageEditorVariant
|
||||
conversationId?: number | null
|
||||
draftScope?: string
|
||||
draftStore?: MessageEditorDraftStore
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
manageLocalUploading?: boolean
|
||||
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
|
||||
export function SharedMessageEditor({
|
||||
variant,
|
||||
conversationId,
|
||||
draftScope = "default",
|
||||
draftStore,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
manageLocalUploading = false,
|
||||
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
|
||||
}: SharedMessageEditorProps) {
|
||||
const t = useI18n()
|
||||
const [localUploading, setLocalUploading] = useState(false)
|
||||
const [localDraftStore] = useState(createMessageEditorDraftStore)
|
||||
const drafts = draftStore ?? localDraftStore
|
||||
const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false)
|
||||
const draftKey = String(conversationId ?? 0)
|
||||
const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null)
|
||||
const contextRef = useRef({ draftKey, draftScope })
|
||||
contextRef.current = { draftKey, draftScope }
|
||||
const mountedRef = useRef(false)
|
||||
const suggestionRequestRef = useRef(0)
|
||||
const suggestionPendingRef = useRef(false)
|
||||
const uploadingRef = useRef(false)
|
||||
const mutationGenerationRef = useRef(0)
|
||||
const [quickReplySearch, setQuickReplySearch] = useState("")
|
||||
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
|
||||
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
|
||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||
const onTypingRef = useRef(onTyping)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
|
||||
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
|
||||
const isCustomer = variant === "customer"
|
||||
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
||||
const isSending = sending || draftSending
|
||||
|
||||
placeholderRef.current = t("conversation.editorPlaceholder")
|
||||
|
||||
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
|
||||
}, [quickReplies])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
revokeEditorObjectUrls(objectUrls)
|
||||
mountedRef.current = false
|
||||
suggestionRequestRef.current += 1
|
||||
// A supplied store belongs to the parent and survives editor unmounts.
|
||||
if (!draftStore) localDraftStore.clear()
|
||||
}
|
||||
}, [])
|
||||
}, [draftStore, localDraftStore])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
onSendRef.current = onSend
|
||||
}, [onSend])
|
||||
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage
|
||||
}, [onUploadImage])
|
||||
|
||||
useEffect(() => {
|
||||
onSendAttachmentRef.current = onSendAttachment
|
||||
}, [onSendAttachment])
|
||||
|
||||
useEffect(() => {
|
||||
onTypingRef.current = onTyping
|
||||
}, [onTyping])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestedReply(null)
|
||||
}, [conversationId])
|
||||
}, [onSend, onUploadImage, onSendAttachment, onTyping])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
const displayed = displayedDraftRef.current
|
||||
if (displayed) drafts.update(displayed.draft, editor.getHTML())
|
||||
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
|
||||
onTypingRef.current?.()
|
||||
}
|
||||
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
|
||||
return false
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (disabled || isUploading) {
|
||||
if (disabled || isUploading || isSending) {
|
||||
return false
|
||||
}
|
||||
const imageFile = getClipboardImageFile(event.clipboardData)
|
||||
@@ -241,6 +256,49 @@ export function SharedMessageEditor({
|
||||
},
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
mutationGenerationRef.current += 1
|
||||
uploadingRef.current = false
|
||||
setLocalUploading(false)
|
||||
drafts.setScope(draftScope)
|
||||
}, [drafts, draftScope])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
const draft = drafts.get(draftKey)
|
||||
displayedDraftRef.current = { key: draftKey, draft }
|
||||
editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
// Image metadata is retained separately while a placeholder upload settles.
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
suggestionRequestRef.current += 1
|
||||
suggestionPendingRef.current = false
|
||||
setSuggestedReply(null)
|
||||
setIsGeneratingSuggestion(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}, [draftKey, draftScope, drafts, editor])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
// The parent-owned draft may finish sending after this editor was remounted.
|
||||
return drafts.subscribe((draft) => {
|
||||
if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return
|
||||
if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
if (!draft.html) {
|
||||
setSuggestedReply(null)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}
|
||||
})
|
||||
}, [drafts, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
|
||||
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [disabled, editor, isUploading])
|
||||
|
||||
async function handleSend() {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
if (displayed.key !== contextRef.current.draftKey) return
|
||||
const rawHTML = editor.getHTML()
|
||||
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||
return
|
||||
}
|
||||
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||
if (!isMeaningfulHTML(html)) {
|
||||
return
|
||||
}
|
||||
await onSendRef.current(html)
|
||||
editor.commands.clearContent(true)
|
||||
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||
uploadedImagesRef.current.clear()
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return
|
||||
const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages)
|
||||
if (!isMeaningfulHTML(html)) return
|
||||
|
||||
const sent = drafts.beginSend(displayed.key)
|
||||
if (!sent) return
|
||||
const send = onSendRef.current
|
||||
try {
|
||||
await send(html)
|
||||
const cleared = drafts.finishSend(sent, true)
|
||||
if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return
|
||||
editor.commands.setContent("", { emitUpdate: false })
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) focusEditorEnd()
|
||||
} catch {
|
||||
// The caller displays the error. Keep the entire draft, including images, for retry.
|
||||
drafts.finishSend(sent, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || !editor || disabled || isUploading) {
|
||||
if (!file || !editor || disabled || isUploading || isSending) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
|
||||
}
|
||||
|
||||
async function insertUploadedImage(file: File) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
const { key, draft } = displayed
|
||||
const upload = onUploadImageRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft
|
||||
|
||||
shouldRestoreFocusRef.current = true
|
||||
uploadingRef.current = true
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrlsRef.current.add(objectUrl)
|
||||
draft.objectUrls.add(objectUrl)
|
||||
const placeholderId = `uploading-${generateUUID()}`
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
})
|
||||
.run()
|
||||
editor.chain().focus().setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
}).run()
|
||||
setEditorImageUploadingByTitle(editor, placeholderId)
|
||||
|
||||
const removePlaceholder = () => {
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (isVisible()) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
} else {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = draft.html
|
||||
for (const image of template.content.querySelectorAll("img")) {
|
||||
if (image.getAttribute("title") === placeholderId) image.remove()
|
||||
}
|
||||
drafts.update(draft, template.innerHTML)
|
||||
drafts.notify(draft)
|
||||
}
|
||||
revokeEditorObjectUrl(draft.objectUrls, objectUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
const uploaded = await onUploadImageRef.current(file)
|
||||
const uploaded = await upload(file)
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||
removePlaceholder()
|
||||
return
|
||||
}
|
||||
markEditorImageUploadedByTitle(
|
||||
editor,
|
||||
placeholderId,
|
||||
uploaded,
|
||||
uploadedImagesRef.current
|
||||
)
|
||||
draft.uploadedImages.set(placeholderId, uploaded)
|
||||
drafts.notify(draft)
|
||||
} catch {
|
||||
removePlaceholder()
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (!disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (isVisible()) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || disabled || isUploading) {
|
||||
if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const sendAttachment = onSendAttachmentRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
uploadingRef.current = true
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
await onSendAttachmentRef.current(file)
|
||||
await sendAttachment(file)
|
||||
} catch {
|
||||
// The caller reports failed attachment sends; no text draft is discarded.
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function focusEditorEnd() {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) {
|
||||
editor.commands.focus("end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function restoreFocusIfNeeded() {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -412,17 +499,23 @@ export function SharedMessageEditor({
|
||||
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
|
||||
|
||||
async function handleTriggerSuggest() {
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return
|
||||
const requestId = ++suggestionRequestRef.current
|
||||
suggestionPendingRef.current = true
|
||||
const context = contextRef.current
|
||||
setIsGeneratingSuggestion(true)
|
||||
try {
|
||||
const text = await aiCopilot.onSuggest()
|
||||
if (text && text.trim()) {
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) {
|
||||
setSuggestedReply(text.trim())
|
||||
}
|
||||
} catch {
|
||||
// The caller displays the suggestion error. Ignore any late failure here.
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false)
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current) {
|
||||
suggestionPendingRef.current = false
|
||||
setIsGeneratingSuggestion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,9 +525,7 @@ export function SharedMessageEditor({
|
||||
}
|
||||
editor.chain().focus().insertContent(suggestedReply).run()
|
||||
setSuggestedReply(null)
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function handleDismissSuggestedReply() {
|
||||
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
imageInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
>
|
||||
@@ -536,7 +627,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
attachmentInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
>
|
||||
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={t("conversation.send")}
|
||||
title={t("conversation.send")}
|
||||
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
|
||||
>
|
||||
<SendHorizonalIcon />
|
||||
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
>
|
||||
<SendIcon className="mr-1 size-4" />
|
||||
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
|
||||
{isUploading ? t("conversation.uploading") : t("conversation.send")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user