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.
823 lines
29 KiB
TypeScript
823 lines
29 KiB
TypeScript
// 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, 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"
|
|
import {
|
|
ImageIcon,
|
|
Loader2Icon,
|
|
MessageSquareTextIcon,
|
|
PaperclipIcon,
|
|
SendHorizonalIcon,
|
|
SendIcon,
|
|
SparklesIcon,
|
|
XIcon,
|
|
} from "lucide-react"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import {
|
|
buildSendableEditorHTML,
|
|
hasUploadingEditorImages,
|
|
markEditorImageUploadedByTitle,
|
|
MessageImageExtension,
|
|
removeEditorImageByTitle,
|
|
revokeEditorObjectUrl,
|
|
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"
|
|
|
|
export type UploadedMessageEditorImage = UploadedEditorImage & {
|
|
url: string
|
|
}
|
|
|
|
export type MessageEditorQuickReply = {
|
|
id: number
|
|
groupName?: string
|
|
title: string
|
|
content: string
|
|
}
|
|
|
|
type SharedMessageEditorVariant = "customer" | "agent"
|
|
|
|
export type SharedMessageEditorAICopilot = {
|
|
onSuggest: () => Promise<string | null>
|
|
loading?: boolean
|
|
}
|
|
|
|
export type SharedMessageEditorProps = {
|
|
variant: SharedMessageEditorVariant
|
|
conversationId?: number | null
|
|
draftScope?: string
|
|
draftStore?: MessageEditorDraftStore
|
|
sending?: boolean
|
|
disabled?: boolean
|
|
uploadingAsset?: boolean
|
|
manageLocalUploading?: boolean
|
|
quickReplies?: {
|
|
open: boolean
|
|
loading: boolean
|
|
items: MessageEditorQuickReply[]
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
aiCopilot?: SharedMessageEditorAICopilot
|
|
onSend: (html: string) => Promise<void>
|
|
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
|
onSendAttachment: (file: File) => Promise<void>
|
|
onTyping?: () => void
|
|
}
|
|
|
|
export function SharedMessageEditor({
|
|
variant,
|
|
conversationId,
|
|
draftScope = "default",
|
|
draftStore,
|
|
sending = false,
|
|
disabled = false,
|
|
uploadingAsset = false,
|
|
manageLocalUploading = false,
|
|
quickReplies,
|
|
aiCopilot,
|
|
onSend,
|
|
onUploadImage,
|
|
onSendAttachment,
|
|
onTyping,
|
|
}: 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)
|
|
const quickReplySearchRef = useRef(quickReplySearch)
|
|
quickReplySearchRef.current = quickReplySearch
|
|
const isSlashTriggeredRef = useRef(false)
|
|
const quickRepliesRef = useRef(quickReplies)
|
|
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
|
|
const onSendRef = useRef(onSend)
|
|
const onUploadImageRef = useRef(onUploadImage)
|
|
const onSendAttachmentRef = useRef(onSendAttachment)
|
|
const onTypingRef = useRef(onTyping)
|
|
const shouldRestoreFocusRef = useRef(false)
|
|
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
|
|
const isCustomer = variant === "customer"
|
|
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
|
const isSending = sending || draftSending
|
|
|
|
placeholderRef.current = t("conversation.editorPlaceholder")
|
|
|
|
useEffect(() => {
|
|
quickRepliesRef.current = quickReplies
|
|
}, [quickReplies])
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true
|
|
return () => {
|
|
mountedRef.current = false
|
|
suggestionRequestRef.current += 1
|
|
// A supplied store belongs to the parent and survives editor unmounts.
|
|
if (!draftStore) localDraftStore.clear()
|
|
}
|
|
}, [draftStore, localDraftStore])
|
|
|
|
useLayoutEffect(() => {
|
|
onSendRef.current = onSend
|
|
onUploadImageRef.current = onUploadImage
|
|
onSendAttachmentRef.current = onSendAttachment
|
|
onTypingRef.current = onTyping
|
|
}, [onSend, onUploadImage, onSendAttachment, onTyping])
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: false,
|
|
blockquote: false,
|
|
codeBlock: false,
|
|
bulletList: false,
|
|
orderedList: false,
|
|
horizontalRule: false,
|
|
}),
|
|
MessageImageExtension,
|
|
Placeholder.configure({
|
|
placeholder: () => placeholderRef.current,
|
|
}),
|
|
],
|
|
content: "",
|
|
onUpdate: ({ editor }) => {
|
|
const displayed = displayedDraftRef.current
|
|
if (displayed) drafts.update(displayed.draft, editor.getHTML())
|
|
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
|
|
onTypingRef.current?.()
|
|
}
|
|
if (!quickRepliesRef.current || quickRepliesRef.current.loading) {
|
|
return
|
|
}
|
|
const { $from } = editor.state.selection
|
|
const blockText = $from.parent.textContent
|
|
const trimmed = blockText.trimStart()
|
|
const match = trimmed.match(/^\/([^\s]*)$/)
|
|
if (match) {
|
|
isSlashTriggeredRef.current = true
|
|
const query = match[1]
|
|
setQuickReplySearch(query)
|
|
if (!quickRepliesRef.current.open) {
|
|
quickRepliesRef.current.onOpenChange(true)
|
|
}
|
|
} else if (isSlashTriggeredRef.current && quickRepliesRef.current.open) {
|
|
isSlashTriggeredRef.current = false
|
|
setQuickReplySearch("")
|
|
quickRepliesRef.current.onOpenChange(false)
|
|
}
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: getEditorClassName(variant),
|
|
},
|
|
handleKeyDown: (_view, event) => {
|
|
if (event.key === "Escape" && quickRepliesRef.current?.open) {
|
|
event.preventDefault()
|
|
quickRepliesRef.current.onOpenChange(false)
|
|
setQuickReplySearch("")
|
|
isSlashTriggeredRef.current = false
|
|
return true
|
|
}
|
|
if (
|
|
(event.key === "Enter" || event.key === "Tab") &&
|
|
!event.shiftKey &&
|
|
quickRepliesRef.current?.open
|
|
) {
|
|
const query = quickReplySearchRef.current.toLowerCase().trim()
|
|
const items = quickRepliesRef.current.items
|
|
const matching = items.filter((item) => {
|
|
if (!query) return true
|
|
const target = `${item.groupName ?? ""} ${item.title} ${item.content}`.toLowerCase()
|
|
return target.includes(query)
|
|
})
|
|
if (matching.length > 0) {
|
|
event.preventDefault()
|
|
handleInsertQuickReply(matching[0])
|
|
return true
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
return true
|
|
}
|
|
}
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault()
|
|
void handleSend()
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
handlePaste: (_view, event) => {
|
|
if (disabled || isUploading || isSending) {
|
|
return false
|
|
}
|
|
const imageFile = getClipboardImageFile(event.clipboardData)
|
|
if (!imageFile) {
|
|
return false
|
|
}
|
|
event.preventDefault()
|
|
void insertUploadedImage(imageFile)
|
|
return true
|
|
},
|
|
},
|
|
})
|
|
|
|
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
|
|
}
|
|
editor.setEditable(!disabled && !isUploading)
|
|
}, [disabled, editor, isUploading])
|
|
|
|
useEffect(() => {
|
|
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
|
return
|
|
}
|
|
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() {
|
|
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, 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 || isSending) {
|
|
restoreFocusIfNeeded()
|
|
return
|
|
}
|
|
await insertUploadedImage(file)
|
|
}
|
|
|
|
async function insertUploadedImage(file: File) {
|
|
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)
|
|
draft.objectUrls.add(objectUrl)
|
|
const placeholderId = `uploading-${generateUUID()}`
|
|
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 upload(file)
|
|
if (!drafts.contains(key, draft)) return
|
|
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
|
removePlaceholder()
|
|
return
|
|
}
|
|
draft.uploadedImages.set(placeholderId, uploaded)
|
|
drafts.notify(draft)
|
|
} catch {
|
|
removePlaceholder()
|
|
} finally {
|
|
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 || 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 sendAttachment(file)
|
|
} catch {
|
|
// The caller reports failed attachment sends; no text draft is discarded.
|
|
} finally {
|
|
if (generation === mutationGenerationRef.current) {
|
|
uploadingRef.current = false
|
|
if (mountedRef.current) setLocalUploading(false)
|
|
}
|
|
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
|
|
}
|
|
}
|
|
|
|
function handleInsertQuickReply(item: MessageEditorQuickReply) {
|
|
if (!editor || disabled || isUploading) {
|
|
return
|
|
}
|
|
if (!item.content.trim()) {
|
|
return
|
|
}
|
|
|
|
const { $from } = editor.state.selection
|
|
const blockText = $from.parent.textContent
|
|
const trimmed = blockText.trimStart()
|
|
const match = trimmed.match(/^\/([^\s]*)$/)
|
|
|
|
if (match) {
|
|
const slashIndex = blockText.indexOf("/")
|
|
const blockStart = $from.start() + slashIndex
|
|
const matchLen = match[0].length
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: blockStart, to: blockStart + matchLen })
|
|
.insertContent(item.content)
|
|
.run()
|
|
} else {
|
|
const fullText = editor.getText().trim()
|
|
const fullMatch = fullText.match(/^\/([^\s]*)$/)
|
|
if (fullMatch) {
|
|
editor.chain().focus().setContent(item.content).run()
|
|
} else {
|
|
editor.chain().focus().insertContent(item.content).run()
|
|
}
|
|
}
|
|
|
|
quickRepliesRef.current?.onOpenChange(false)
|
|
setQuickReplySearch("")
|
|
isSlashTriggeredRef.current = false
|
|
focusEditorEnd()
|
|
}
|
|
|
|
function focusEditorEnd() {
|
|
const draft = displayedDraftRef.current?.draft
|
|
requestAnimationFrame(() => {
|
|
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(() => {
|
|
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
|
})
|
|
}
|
|
}
|
|
|
|
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
|
|
|
|
async function handleTriggerSuggest() {
|
|
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 (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 {
|
|
if (mountedRef.current && requestId === suggestionRequestRef.current) {
|
|
suggestionPendingRef.current = false
|
|
setIsGeneratingSuggestion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleInsertSuggestedReply() {
|
|
if (!editor || !suggestedReply) {
|
|
return
|
|
}
|
|
editor.chain().focus().insertContent(suggestedReply).run()
|
|
setSuggestedReply(null)
|
|
focusEditorEnd()
|
|
}
|
|
|
|
function handleDismissSuggestedReply() {
|
|
setSuggestedReply(null)
|
|
}
|
|
|
|
const editorContent = (
|
|
<>
|
|
<input
|
|
ref={imageInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleSelectImage}
|
|
/>
|
|
<input
|
|
ref={attachmentInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
onChange={handleSelectAttachment}
|
|
/>
|
|
{!isCustomer && suggestedReply ? (
|
|
<div className="m-2 rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
|
<div className="flex items-center justify-between gap-2 pb-1.5">
|
|
<div className="flex items-center gap-1.5 text-xs font-medium text-primary">
|
|
<SparklesIcon className="size-3.5" />
|
|
<span>{t("aiCopilot.suggestReply")}</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-5 text-muted-foreground hover:text-foreground"
|
|
onClick={handleDismissSuggestedReply}
|
|
aria-label={t("aiCopilot.discardReply")}
|
|
title={t("aiCopilot.discardReply")}
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="line-clamp-3 select-text text-xs leading-5 text-foreground/90 whitespace-pre-wrap">
|
|
{suggestedReply}
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={handleDismissSuggestedReply}
|
|
>
|
|
{t("aiCopilot.discardReply")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-7 px-2.5 text-xs"
|
|
onClick={handleInsertSuggestedReply}
|
|
>
|
|
{t("aiCopilot.insertReply")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{isCustomer ? (
|
|
<div className="min-h-10">
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-hidden px-2 py-1">
|
|
<EditorContent editor={editor} className="h-full" />
|
|
</div>
|
|
)}
|
|
<div className={getToolbarClassName(variant)}>
|
|
<div className={isCustomer ? "flex items-center gap-1.5" : "flex items-center gap-1"}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={getIconButtonClassName(variant)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => {
|
|
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
imageInputRef.current?.click()
|
|
}}
|
|
disabled={disabled || isUploading || isSending}
|
|
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
|
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
|
>
|
|
<ImageIcon className={isCustomer ? undefined : "size-4"} />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={getIconButtonClassName(variant)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => {
|
|
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
attachmentInputRef.current?.click()
|
|
}}
|
|
disabled={disabled || isUploading || isSending}
|
|
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
|
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
|
>
|
|
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
|
|
</Button>
|
|
{quickReplies ? (
|
|
<Popover
|
|
open={quickReplies.open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) {
|
|
setQuickReplySearch("")
|
|
isSlashTriggeredRef.current = false
|
|
}
|
|
quickReplies.onOpenChange(nextOpen)
|
|
}}
|
|
>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8"
|
|
disabled={disabled || isUploading || quickReplies.loading}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
title={t("conversation.quickReplySlashHint")}
|
|
aria-label={t("conversation.quickReplySlashHint")}
|
|
/>
|
|
}
|
|
>
|
|
<MessageSquareTextIcon className="size-4" />
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-[30rem] p-0"
|
|
align="start"
|
|
initialFocus={!isSlashTriggeredRef.current}
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
value={quickReplySearch}
|
|
onValueChange={setQuickReplySearch}
|
|
placeholder={t("conversation.searchQuickReplies")}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>{t("conversation.emptyQuickReplies")}</CommandEmpty>
|
|
<CommandGroup>
|
|
{quickReplies.items.map((item) => (
|
|
<CommandItem
|
|
key={item.id}
|
|
value={`${item.groupName ?? ""} ${item.title} ${item.content}`}
|
|
onSelect={() => handleInsertQuickReply(item)}
|
|
>
|
|
<div className="flex min-w-0 flex-col gap-0.5 py-0.5">
|
|
<span className="line-clamp-1 text-sm">
|
|
{item.groupName
|
|
? `${item.groupName} / ${item.title}`
|
|
: item.title}
|
|
</span>
|
|
<span className="line-clamp-2 text-xs text-muted-foreground">
|
|
{item.content}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : null}
|
|
{!isCustomer && aiCopilot ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 text-primary hover:bg-primary/10"
|
|
disabled={disabled || isUploading || isGenerating || aiCopilot.loading}
|
|
onClick={handleTriggerSuggest}
|
|
title={isGenerating ? t("aiCopilot.generating") : t("aiCopilot.suggestReply")}
|
|
aria-label={t("aiCopilot.suggestReply")}
|
|
>
|
|
{isGenerating ? (
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
) : (
|
|
<SparklesIcon className="size-4" />
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
{!isCustomer && quickReplies ? (
|
|
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline-block pl-1">
|
|
{t("conversation.quickReplySlashHint")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<p className={isCustomer ? "hidden text-[10px] text-muted-foreground sm:block" : "text-xs text-muted-foreground"}>
|
|
{t("conversation.enterToSend")}
|
|
</p>
|
|
{isCustomer ? (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
onClick={() => void handleSend()}
|
|
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"
|
|
>
|
|
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => void handleSend()}
|
|
aria-busy={isSending}
|
|
disabled={disabled || isUploading || isSending}
|
|
>
|
|
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
|
|
{isUploading ? t("conversation.uploading") : t("conversation.send")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
|
|
if (isCustomer) {
|
|
return (
|
|
<div className="px-3 pt-2 pb-3">
|
|
<div className="rounded-xl border border-border bg-background p-2 shadow-[0_8px_24px_rgba(15,23,42,0.05)] dark:shadow-none">
|
|
{editorContent}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col p-2">
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border bg-card">
|
|
{editorContent}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function getEditorClassName(variant: SharedMessageEditorVariant) {
|
|
if (variant === "customer") {
|
|
return "agent-desk-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-lg [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55"
|
|
}
|
|
return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.agent-desk-editor-image-wrap]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-md [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
|
|
}
|
|
|
|
function getToolbarClassName(variant: SharedMessageEditorVariant) {
|
|
if (variant === "customer") {
|
|
return "mt-2 flex items-center justify-between"
|
|
}
|
|
return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2"
|
|
}
|
|
|
|
function getIconButtonClassName(variant: SharedMessageEditorVariant) {
|
|
if (variant === "customer") {
|
|
return "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
}
|
|
return "size-8"
|
|
}
|
|
|
|
function isMeaningfulHTML(html: string) {
|
|
const normalized = html
|
|
.replace(/<p><\/p>/g, "")
|
|
.replace(/<p><br><\/p>/g, "")
|
|
.replace(/\s+/g, "")
|
|
if (/<img[\s\S]*?>/i.test(normalized)) {
|
|
return true
|
|
}
|
|
const plainText = normalized.replace(/<[^>]+>/g, "").trim()
|
|
return plainText !== ""
|
|
}
|
|
|
|
function getClipboardImageFile(data: DataTransfer | null) {
|
|
if (!data) {
|
|
return null
|
|
}
|
|
|
|
for (const item of Array.from(data.items)) {
|
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
return item.getAsFile()
|
|
}
|
|
}
|
|
return null
|
|
}
|