'use client'; import { isSupportAttachmentAllowed, isSupportAttachmentImage, SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE, } from '@edr/types'; import { useCallback, useEffect, useRef, useState } from 'react'; /** A file staged in the composer, not yet sent. */ export interface PendingAttachment { /** Local-only id; the server id doesn't exist until the message is sent. */ id: string; file: File; /** Object URL, images only. Revoked when the entry goes away. */ previewUrl?: string; } let nextId = 0; /** * Staging area for files being attached to a message. * * Files are held client-side until send, then posted alongside the text in one * multipart request — there's no upload-then-reference step, so nothing to * garbage-collect if the agent changes their mind. * * Object URLs for image previews are revoked on removal and unmount; without * that, pasting screenshots into a long-lived chat page leaks the full bytes of * every image for the life of the tab. */ export function useAttachmentDraft(onReject?: (reason: string) => void) { const [attachments, setAttachments] = useState([]); const rejectRef = useRef(onReject); rejectRef.current = onReject; // Read from a ref in the unmount cleanup so it doesn't re-run (and revoke // still-live URLs) on every change to the list. const attachmentsRef = useRef(attachments); attachmentsRef.current = attachments; useEffect( () => () => { for (const a of attachmentsRef.current) { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); } }, [], ); const add = useCallback((files: File[]) => { if (files.length === 0) return; setAttachments((current) => { const accepted: PendingAttachment[] = []; for (const file of files) { if (current.length + accepted.length >= SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) { rejectRef.current?.(`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`); break; } if (!isSupportAttachmentAllowed(file.type)) { rejectRef.current?.(`${file.name}: that file type isn't supported.`); continue; } if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) { rejectRef.current?.( `${file.name} is over the ${SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)}MB limit.`, ); continue; } accepted.push({ id: `pending-${nextId++}`, file, previewUrl: isSupportAttachmentImage(file.type) ? URL.createObjectURL(file) : undefined, }); } return accepted.length ? [...current, ...accepted] : current; }); }, []); const remove = useCallback((id: string) => { setAttachments((current) => { const target = current.find((a) => a.id === id); if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); return current.filter((a) => a.id !== id); }); }, []); const clear = useCallback(() => { setAttachments((current) => { for (const a of current) { if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); } return []; }); }, []); /** * Pull files off a paste. Returns true if anything was taken, so the caller * can suppress the default paste — otherwise pasting a screenshot also drops * its filename (or nothing) into the textarea. * * Copying an image in most apps puts BOTH the bitmap and some text/html on * the clipboard, so check for files first and only then let the text through. */ const addFromPaste = useCallback( (clipboard: DataTransfer | null): boolean => { const files = Array.from(clipboard?.files ?? []); if (files.length === 0) return false; add(files); return true; }, [add], ); return { attachments, files: attachments.map((a) => a.file), add, addFromPaste, remove, clear, }; }