import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { ArrowUp, ImageIcon, Loader2, Paperclip, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useAttachedImages, type AttachedImage, type AttachmentError, MAX_IMAGES_PER_MESSAGE, } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage } from "@/hooks/useNanobotStream"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is * deliberately excluded to avoid an embedded-script XSS surface. */ const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } interface ThreadComposerProps { onSend: (content: string, images?: SendImage[]) => void; disabled?: boolean; placeholder?: string; modelLabel?: string | null; variant?: "thread" | "hero"; } export function ThreadComposer({ onSend, disabled, placeholder, modelLabel = null, variant = "thread", }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); const isHero = variant === "hero"; const resolvedPlaceholder = placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, encoding, full } = useAttachedImages(); const formatRejection = useCallback( (reason: AttachmentError): string => { const key = `thread.composer.imageRejected.${reason}`; return t(key, { max: MAX_IMAGES_PER_MESSAGE }); }, [t], ); const addFiles = useCallback( (files: File[]) => { if (files.length === 0) return; const { rejected } = enqueue(files); if (rejected.length > 0) { setInlineError(formatRejection(rejected[0].reason)); } else { setInlineError(null); } }, [enqueue, formatRejection], ); const { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop, } = useClipboardAndDrop(addFiles); useEffect(() => { if (disabled) return; const el = textareaRef.current; if (!el) return; const id = requestAnimationFrame(() => el.focus()); return () => cancelAnimationFrame(id); }, [disabled]); const readyImages = useMemo( () => images.filter((img): img is AttachedImage & { dataUrl: string } => img.status === "ready" && typeof img.dataUrl === "string", ), [images], ); const hasErrors = images.some((img) => img.status === "error"); const canSend = !disabled && !encoding && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); // Share the same normalized ``data:`` URL with both the wire payload and // the optimistic bubble preview: data URLs are self-contained (no blob // lifetime, safe under React StrictMode double-mount) and keep the // bubble in sync with whatever the backend actually sees. const payload: SendImage[] | undefined = readyImages.length > 0 ? readyImages.map((img) => ({ media: { data_url: img.dataUrl, name: img.file.name, }, preview: { url: img.dataUrl, name: img.file.name }, })) : undefined; onSend(trimmed, payload); setValue(""); setInlineError(null); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); requestAnimationFrame(() => { const el = textareaRef.current; if (el) { el.style.height = "auto"; el.focus(); } }); }, [canSend, clear, onSend, readyImages, value]); const onKeyDown = (e: ReactKeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } }; const onInput: React.FormEventHandler = (e) => { const el = e.currentTarget; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; }; const onFilePick: React.ChangeEventHandler = (e) => { const files = Array.from(e.target.files ?? []); e.target.value = ""; addFiles(files); }; const removeChip = useCallback( (id: string) => { const { nextFocusId } = remove(id); setInlineError(null); requestAnimationFrame(() => { const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null; if (el) { el.focus(); } else { textareaRef.current?.focus(); } }); }, [remove], ); const onChipKey = useCallback( (id: string) => (e: ReactKeyboardEvent) => { if ( e.key === "Delete" || e.key === "Backspace" || e.key === "Enter" || e.key === " " ) { e.preventDefault(); removeChip(id); } }, [removeChip], ); const attachButtonDisabled = disabled || full; return ( { e.preventDefault(); submit(); }} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} > {images.length > 0 ? ( {images.map((img) => ( t("thread.composer.normalizedSizeHint", { orig: formatBytes(orig), current: formatBytes(current), }) } formatError={formatRejection} onRemove={() => removeChip(img.id)} onKeyDown={onChipKey(img.id)} registerRef={(el) => { if (el) chipRefs.current.set(img.id, el); else chipRefs.current.delete(img.id); }} /> ))} ) : null} setValue(e.target.value)} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} rows={1} placeholder={resolvedPlaceholder} disabled={disabled} aria-label={t("thread.composer.inputAria")} className={cn( "w-full resize-none bg-transparent", isHero ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", "placeholder:text-muted-foreground", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", )} /> {inlineError ? ( {inlineError} ) : null} fileInputRef.current?.click()} className={cn( "rounded-full text-muted-foreground hover:text-foreground", isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", )} > {modelLabel ? ( {modelLabel} ) : null} {t("thread.composer.sendHint")} ); } interface AttachmentChipProps { image: AttachedImage; labelRemove: string; labelEncoding: string; normalizedHint: (origBytes: number, currentBytes: number) => string; formatError: (reason: AttachmentError) => string; onRemove: () => void; onKeyDown: (e: ReactKeyboardEvent) => void; registerRef: (el: HTMLButtonElement | null) => void; } function AttachmentChip({ image, labelRemove, labelEncoding, normalizedHint, formatError, onRemove, onKeyDown, registerRef, }: AttachmentChipProps) { const sizeLabel = image.status === "ready" && image.normalized && image.encodedBytes ? normalizedHint(image.file.size, image.encodedBytes) : formatBytes(image.file.size); const tone = image.status === "error" ? "border-destructive/40 bg-destructive/5 text-destructive" : "border-border/70 bg-muted/60"; return ( {image.previewUrl ? ( ) : ( )} {image.status === "encoding" ? ( ) : null} {image.file.name} {image.status === "error" && image.error ? formatError(image.error) : sizeLabel} ); }