'use client'; import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'; import { buildAcceptString, useClipboardPaste } from '../../forms/Uploader'; import type { UseChatComposerReturn } from '../hooks/useChatComposer'; import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment'; import { DEFAULT_PASTE_TEXT_THRESHOLD, textToAttachment, } from './textToAttachment'; import type { ComposerAcceptType, ComposerAttachConfig, ComposerAttachHandle, } from './types'; const ALL_ACCEPT: ComposerAcceptType[] = ['image', 'audio', 'video', 'document']; /** True when a file's MIME is allowed by the accepted asset categories. */ function isAcceptedType(file: File, accept: ComposerAcceptType[]): boolean { const mime = file.type; if (mime.startsWith('image/')) return accept.includes('image'); if (mime.startsWith('audio/')) return accept.includes('audio'); if (mime.startsWith('video/')) return accept.includes('video'); // No / other MIME — treat as a document. return accept.includes('document'); } export interface UseComposerAttachParams { composer: UseChatComposerReturn; config: ComposerAttachConfig; disabled?: boolean; /** Paste listener scope. Defaults to `document` when omitted. */ pasteScopeRef?: RefObject; } /** * The unified attach pipeline. One validated path that the paperclip * button, the `+` menu, drag-drop and Ctrl+V paste all funnel into: * * openPicker() / attachFiles(File[]) * → validate (size / type / count) * → fileToAttachment() * → composer.addAttachment() * * Phase 2 stops at `status:'ready'` (object-URL). The `uploadFn` * lifecycle lands in phase 5 — `config.uploadFn` is accepted here but * not yet consumed. * * Reuses `useClipboardPaste` + `buildAcceptString` from the Uploader * tool rather than re-implementing clipboard / mime handling. */ export function useComposerAttach({ composer, config, disabled = false, pasteScopeRef, }: UseComposerAttachParams): ComposerAttachHandle { const { accept = ALL_ACCEPT, maxFiles, maxSizeBytes, multiple = true, pasteEnabled = true, pasteTextAsChunk = true, pasteTextThreshold = DEFAULT_PASTE_TEXT_THRESHOLD, uploadFn, onReject, } = config; const inputRef = useRef(null); // Stable refs so the paste listener / handlers never go stale without // forcing the consumer to memoize anything. const composerRef = useRef(composer); composerRef.current = composer; const onRejectRef = useRef(onReject); onRejectRef.current = onReject; const uploadFnRef = useRef(uploadFn); uploadFnRef.current = uploadFn; const acceptString = useMemo(() => { // buildAcceptString expects the Uploader's AssetType — same string // union as ComposerAcceptType, so the cast is safe. return buildAcceptString(accept as Parameters[0]); }, [accept]); // Drive one file through the host `uploadFn`: flip the attachment to // `uploading`, stream progress, then settle on `ready` (url rewritten // to the remote location) or `error`. Fire-and-forget — failures are // surfaced on the attachment, never thrown. const runUpload = useCallback( (file: File, attachmentId: string, localUrl: string) => { const fn = uploadFnRef.current; if (!fn) return; const c = composerRef.current; c.updateAttachment(attachmentId, { status: 'uploading', progress: 0 }); fn(file, (fraction) => { composerRef.current.updateAttachment(attachmentId, { progress: Math.max(0, Math.min(1, fraction)), }); }) .then((result) => { composerRef.current.updateAttachment(attachmentId, { status: 'ready', progress: 1, url: result.url, thumbnailUrl: result.thumbnailUrl, }); // The remote URL replaced the object-URL — free it. revokeAttachmentUrl({ url: localUrl }); }) .catch(() => { composerRef.current.updateAttachment(attachmentId, { status: 'error' }); }); }, [], ); // The single validated entry point. Drop / paste / picker all land here. const attachFiles = useCallback( (files: File[]) => { if (disabled || files.length === 0) return; const c = composerRef.current; const cap = maxFiles ?? Number.POSITIVE_INFINITY; let slots = cap - c.attachments.length; for (const file of files) { if (slots <= 0) { onRejectRef.current?.(file, 'count'); continue; } if (maxSizeBytes != null && file.size > maxSizeBytes) { onRejectRef.current?.(file, 'size'); continue; } if (!isAcceptedType(file, accept)) { onRejectRef.current?.(file, 'type'); continue; } const attachment = fileToAttachment(file); c.addAttachment(attachment); // With an uploadFn the attachment starts local then uploads in // the background; without one it stays `ready` immediately. if (uploadFnRef.current) { runUpload(file, attachment.id, attachment.url); } slots -= 1; } }, [disabled, maxFiles, maxSizeBytes, accept, runUpload], ); const openPicker = useCallback(() => { if (disabled) return; inputRef.current?.click(); }, [disabled]); // Remove a staged attachment, freeing its object-URL first so a // discarded blob does not leak. const removeAttachment = useCallback((id: string) => { const c = composerRef.current; const found = c.attachments.find((a) => a.id === id); if (found) revokeAttachmentUrl(found); c.removeAttachment(id); }, []); const onInputChange = useCallback( (e: React.ChangeEvent) => { const picked = e.target.files; if (picked && picked.length > 0) attachFiles(Array.from(picked)); // Reset so picking the same file twice still fires `change`. e.target.value = ''; }, [attachFiles], ); // Ctrl+V / Cmd+V — reuse the Uploader's clipboard resolver. It already // skips text fields when the clipboard carries text, so pasting an // image into the composer textarea attaches without hijacking typing. const pasteAccept = useMemo( () => accept.filter((a) => a !== 'document'), [accept], ); useClipboardPaste( { enabled: pasteEnabled && !disabled, acceptTypes: pasteAccept.length > 0 ? pasteAccept : undefined, maxBytes: maxSizeBytes, onFiles: attachFiles, }, pasteScopeRef, ); // Long-text paste → "Pasted text" chunk. A separate listener from the // file path above: `useClipboardPaste` deliberately ignores pastes // that carry text into a text field (so it doesn't hijack typing), so // it never sees this case. We intercept here, before the browser // inserts the text into the textarea, and mint a `type:'text'` // attachment instead — the ChatGPT/Claude behaviour. const pasteTextEnabled = pasteEnabled && pasteTextAsChunk && !disabled; useEffect(() => { if (!pasteTextEnabled) return undefined; const target: HTMLElement | Document = pasteScopeRef?.current ?? document; const onPaste = (e: Event) => { const ev = e as ClipboardEvent; const cd = ev.clipboardData; if (!cd) return; // Only chunk pastes aimed at the composer's editable surface — // a paste into some unrelated input inside the scope is left alone. const node = ev.target; const el = node instanceof HTMLElement ? node : null; const isEditable = el != null && (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement || el.isContentEditable); if (!isEditable) return; // A file/blob paste is the image path's job — don't steal it. if (Array.from(cd.items).some((i) => i.kind === 'file')) return; const text = cd.getData('text/plain'); if (text.length < pasteTextThreshold) return; // Respect the file-count cap (text chunks share the attachment list). const c = composerRef.current; if (maxFiles != null && c.attachments.length >= maxFiles) return; e.preventDefault(); c.addAttachment(textToAttachment(text)); }; target.addEventListener('paste', onPaste); return () => target.removeEventListener('paste', onPaste); }, [pasteTextEnabled, pasteTextThreshold, maxFiles, pasteScopeRef]); // On unmount, free any object-URLs still held by staged attachments // (drafts abandoned without sending). `composerRef` gives the latest // list without re-running the effect on every attach. useEffect(() => { return () => { for (const a of composerRef.current.attachments) { revokeAttachmentUrl(a); } }; }, []); const atCap = maxFiles != null && composer.attachments.length >= maxFiles; return { openPicker, attachFiles, removeAttachment, disabled: disabled || atCap, inputProps: { ref: inputRef, type: 'file', accept: acceptString, multiple, hidden: true, onChange: onInputChange, }, }; }