import * as React from 'react' import type { PendingAttachment } from './types' import { downscaleImage } from './image-downscale' /** * Structural interface for the upload backend the composer drives. The app's * `@startsimpli/api` `AttachmentsApi` satisfies this shape WITHOUT this package * importing `@startsimpli/api` — `@startsimpli/ui` stays dependency-free. */ export interface AttachmentUploader { uploadAttachment( documentId: string, file: File, opts?: { purpose?: string; onProgress?: (fraction: number) => void } ): Promise<{ id: string; fileUrl: string | null }> deleteAttachment(documentId: string, attachmentId: string): Promise } /** A successfully-uploaded reference, surfaced to the app via `onAttachmentsChange`. */ export interface UploadedAttachmentRef { /** Server attachment id. */ id: string fileUrl: string | null name: string } /** Hard ceiling on a single upload so a stalled request surfaces as an error * (an error chip) instead of sitting at 0% forever. Downscaled references are * small, so this is generous headroom, not a normal-case limit. */ const UPLOAD_TIMEOUT_MS = 90_000 function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('upload timed out')), ms) promise.then( (v) => { clearTimeout(t) resolve(v) }, (e) => { clearTimeout(t) reject(e) } ) }) } interface TrackedAttachment { /** Stable client id for the chip (also the composer key). */ id: string file: File name: string sizeBytes: number thumbnailUrl?: string /** `queued` = held client-side until a documentId exists. */ status: 'queued' | 'uploading' | 'ready' | 'error' progress: number /** Server attachment id once uploaded (for delete). */ serverId?: string /** Server asset url once uploaded. */ fileUrl?: string | null /** Failure reason when status==='error'. */ error?: string } function isRealId(id: string | undefined): id is string { return !!id && id !== 'new' } let seq = 0 const nextChipId = () => `pa-${Date.now().toString(36)}-${++seq}` function makeThumbnail(file: File): string | undefined { if (!file.type.startsWith('image/')) return undefined try { return URL.createObjectURL(file) } catch { return undefined } } export interface UseComposerUploadsOptions { /** Upload purpose tag forwarded to the backend. Default 'reference'. */ purpose?: string /** Fired whenever the set of successfully-uploaded attachments changes. */ onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void } export interface UseComposerUploadsResult { /** Chips for the composer's controlled `attachments` rendering. */ attachments: PendingAttachment[] /** Add files (attach button / drop / paste). */ attachFiles: (files: File[]) => void /** Remove a chip by id (deletes server-side if already uploaded). */ removeAttachment: (id: string) => void /** Retry a failed chip's upload. */ retryAttachment: (id: string) => void /** Clear all chips (e.g. after a send that consumed them). */ clear: () => void /** True while any chip is still queued/uploading. */ isUploading: boolean } /** * App-agnostic upload-state manager for chat reference attachments. Bridges a * document-scoped {@link AttachmentUploader} to the composer's chip rendering, * so the composer can self-manage uploads when an `uploader` prop is supplied. * * NEW-DECK handling: the upload endpoint is document-scoped, but a brand-new * deck has no document until the first turn creates it. When `documentId` is * absent (or 'new'), selected files are held client-side as queued chips (shown * as `uploading`/indeterminate). The moment a real `documentId` arrives — i.e. * the first turn created the document — every queued file auto-uploads onto it. */ export function useComposerUploads( uploader: AttachmentUploader, documentId: string | undefined, options: UseComposerUploadsOptions = {} ): UseComposerUploadsResult { const { purpose = 'reference', onAttachmentsChange } = options const [items, setItems] = React.useState([]) const itemsRef = React.useRef(items) itemsRef.current = items // Track which chips have an upload in flight to avoid double-uploading. const inFlight = React.useRef>(new Set()) const patch = React.useCallback((id: string, next: Partial) => { setItems((cur) => cur.map((it) => (it.id === id ? { ...it, ...next } : it))) }, []) const upload = React.useCallback( async (item: TrackedAttachment, docId: string) => { if (inFlight.current.has(item.id)) return inFlight.current.add(item.id) patch(item.id, { status: 'uploading', progress: 0, error: undefined }) try { // Shrink big reference images before upload (backend normalizes to // ~1536px anyway); best-effort, falls back to the original. const file = await downscaleImage(item.file) const res = await withTimeout( uploader.uploadAttachment(docId, file, { purpose, onProgress: (fraction: number) => patch(item.id, { progress: fraction }), }), UPLOAD_TIMEOUT_MS ) patch(item.id, { status: 'ready', progress: 1, serverId: res.id, fileUrl: res.fileUrl }) } catch (err) { patch(item.id, { status: 'error', error: err instanceof Error ? err.message : 'Upload failed', }) } finally { inFlight.current.delete(item.id) } }, [uploader, patch, purpose] ) const attachFiles = React.useCallback( (files: File[]) => { if (files.length === 0) return const docId = documentId const added: TrackedAttachment[] = files.map((file) => ({ id: nextChipId(), file, name: file.name, sizeBytes: file.size, thumbnailUrl: makeThumbnail(file), // No document yet → hold queued; otherwise upload right away. status: isRealId(docId) ? 'uploading' : 'queued', progress: 0, })) setItems((cur) => [...cur, ...added]) if (isRealId(docId)) { added.forEach((it) => void upload(it, docId)) } }, [documentId, upload] ) // NEW-DECK: when a real documentId arrives, flush any queued files onto it. React.useEffect(() => { if (!isRealId(documentId)) return itemsRef.current .filter((it) => it.status === 'queued') .forEach((it) => void upload(it, documentId)) }, [documentId, upload]) /** Retry a failed chip's upload (no-op unless it's errored + a doc exists). */ const retryAttachment = React.useCallback( (id: string) => { const item = itemsRef.current.find((it) => it.id === id) if (!item || item.status !== 'error') return if (isRealId(documentId)) { void upload(item, documentId) } else { // No document yet → requeue; the documentId effect will flush it. patch(id, { status: 'queued', progress: 0, error: undefined }) } }, [documentId, upload, patch] ) const removeAttachment = React.useCallback( (id: string) => { const item = itemsRef.current.find((it) => it.id === id) setItems((cur) => cur.filter((it) => it.id !== id)) if (item?.thumbnailUrl) { try { URL.revokeObjectURL(item.thumbnailUrl) } catch { /* noop */ } } // If it was already uploaded, delete it server-side too. if (item?.serverId && isRealId(documentId)) { void uploader.deleteAttachment(documentId, item.serverId) } }, [uploader, documentId] ) const clear = React.useCallback(() => { itemsRef.current.forEach((it) => { if (it.thumbnailUrl) { try { URL.revokeObjectURL(it.thumbnailUrl) } catch { /* noop */ } } }) setItems([]) }, []) // Revoke object urls on unmount. React.useEffect( () => () => { itemsRef.current.forEach((it) => { if (it.thumbnailUrl) { try { URL.revokeObjectURL(it.thumbnailUrl) } catch { /* noop */ } } }) }, [] ) const attachments: PendingAttachment[] = React.useMemo( () => items.map((it) => ({ id: it.id, name: it.name, // A queued chip is parked (waiting for a document), NOT actively // uploading — surface it honestly so it doesn't read as a stuck 0% upload. status: it.status, progress: it.progress, thumbnailUrl: it.thumbnailUrl, sizeBytes: it.sizeBytes, error: it.error, })), [items] ) // Surface the uploaded refs to the app whenever the ready set changes. const readyRefs = React.useMemo( () => items .filter((it): it is TrackedAttachment & { serverId: string } => it.status === 'ready' && !!it.serverId) .map((it) => ({ id: it.serverId, fileUrl: it.fileUrl ?? null, name: it.name })), [items] ) const onChangeRef = React.useRef(onAttachmentsChange) onChangeRef.current = onAttachmentsChange React.useEffect(() => { onChangeRef.current?.(readyRefs) }, [readyRefs]) const isUploading = items.some((it) => it.status === 'queued' || it.status === 'uploading') return { attachments, attachFiles, removeAttachment, retryAttachment, clear, isUploading } }