| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | |
| 3 | import { encodeImage, type EncodeFailure } from "@/lib/imageEncode"; |
| 4 | |
| 5 | /** Lifecycle stages of one attachment: |
| 6 | * |
| 7 | * - ``encoding`` — posted to the Worker; chip shows a spinner |
| 8 | * - ``ready`` — ``dataUrl`` available; safe to submit |
| 9 | * - ``error`` — validation / decode failure; chip shows inline error |
| 10 | */ |
| 11 | export type AttachmentStatus = "encoding" | "ready" | "error"; |
| 12 | |
| 13 | export interface AttachedImage { |
| 14 | id: string; |
| 15 | file: File; |
| 16 | /** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` / |
| 17 | * unmount. */ |
| 18 | previewUrl: string; |
| 19 | status: AttachmentStatus; |
| 20 | /** Populated when ``status === "ready"``. */ |
| 21 | dataUrl?: string; |
| 22 | /** Size of the final encoded payload (base64 bytes decoded). */ |
| 23 | encodedBytes?: number; |
| 24 | /** Whether the Worker re-encoded the image to hit the size budget. */ |
| 25 | normalized?: boolean; |
| 26 | /** Human-readable validation / encoding error when ``status === "error"``. */ |
| 27 | error?: AttachmentError; |
| 28 | } |
| 29 | |
| 30 | /** Machine-readable rejection reasons surfaced as inline chip errors. |
| 31 | * |
| 32 | * Callers localize these via the ``composer.imageRejected.*`` i18n table. */ |
| 33 | export type AttachmentError = |
| 34 | | "unsupported_type" // server whitelist excludes this MIME |
| 35 | | "too_many_images" // per-message cap (4) reached before enqueue |
| 36 | | "magic_mismatch" // extension lies about the real content |
| 37 | | "decode_failed" // Worker couldn't decode / re-encode |
| 38 | | "too_large" // even after normalization we exceed the budget |
| 39 | | "io"; // file read failed at the browser layer |
| 40 | |
| 41 | export const MAX_IMAGES_PER_MESSAGE = 4; |
| 42 | |
| 43 | /** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */ |
| 44 | const ACCEPTED_MIMES: ReadonlySet<string> = new Set([ |
| 45 | "image/png", |
| 46 | "image/jpeg", |
| 47 | "image/webp", |
| 48 | "image/gif", |
| 49 | ]); |
| 50 | |
| 51 | function uuid(): string { |
| 52 | if (typeof crypto !== "undefined" && "randomUUID" in crypto) { |
| 53 | return (crypto as Crypto).randomUUID(); |
| 54 | } |
| 55 | return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 56 | } |
| 57 | |
| 58 | function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError { |
| 59 | switch (reason) { |
| 60 | case "invalid_mime": |
| 61 | case "magic_mismatch": |
| 62 | return "magic_mismatch"; |
| 63 | case "too_large_after_normalize": |
| 64 | return "too_large"; |
| 65 | case "io": |
| 66 | return "io"; |
| 67 | case "decode_failed": |
| 68 | default: |
| 69 | return "decode_failed"; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | export interface UseAttachedImagesApi { |
| 74 | images: AttachedImage[]; |
| 75 | /** Enqueue new files. Returns the list of rejected files so the caller can |
| 76 | * surface inline errors. Files rejected client-side (wrong MIME, limit) are |
| 77 | * *not* added to ``images`` — only recoverable encoding failures show up as |
| 78 | * error chips. */ |
| 79 | enqueue: (files: Iterable<File>) => { |
| 80 | rejected: Array<{ file: File; reason: AttachmentError }>; |
| 81 | }; |
| 82 | remove: (id: string) => { nextFocusId: string | null }; |
| 83 | /** Revoke every staged blob URL and drop all attachments. Called after a |
| 84 | * successful submit — the optimistic bubble holds onto an independent |
| 85 | * ``data:`` URL so tearing down blob previews here is safe. */ |
| 86 | clear: () => void; |
| 87 | /** ``true`` when at least one image is still encoding — Send should wait. */ |
| 88 | encoding: boolean; |
| 89 | /** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */ |
| 90 | full: boolean; |
| 91 | } |
| 92 | |
| 93 | /** Manage the lifecycle of images attached to the Composer. |
| 94 | * |
| 95 | * Responsibilities in one place: |
| 96 | * - validation (MIME whitelist, count cap) |
| 97 | * - blob URL creation + revocation |
| 98 | * - Worker orchestration |
| 99 | * - focus bookkeeping so keyboard delete doesn't strand the user |
| 100 | */ |
| 101 | export function useAttachedImages(): UseAttachedImagesApi { |
| 102 | const [images, setImages] = useState<AttachedImage[]>([]); |
| 103 | // Ref mirror so ``enqueue`` can see the authoritative length when invoked |
| 104 | // multiple times in a single tick (rapid file selection, drag of many |
| 105 | // files, paste storms). ``state`` is stale for that second + call. |
| 106 | const imagesRef = useRef<AttachedImage[]>([]); |
| 107 | imagesRef.current = images; |
| 108 | |
| 109 | const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => { |
| 110 | setImages((prev) => { |
| 111 | const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img)); |
| 112 | imagesRef.current = next; |
| 113 | return next; |
| 114 | }); |
| 115 | }, []); |
| 116 | |
| 117 | const enqueue = useCallback( |
| 118 | (files: Iterable<File>) => { |
| 119 | const rejected: Array<{ file: File; reason: AttachmentError }> = []; |
| 120 | const toAdd: AttachedImage[] = []; |
| 121 | let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length; |
| 122 | |
| 123 | for (const file of files) { |
| 124 | if (!ACCEPTED_MIMES.has(file.type)) { |
| 125 | rejected.push({ file, reason: "unsupported_type" }); |
| 126 | continue; |
| 127 | } |
| 128 | if (slot <= 0) { |
| 129 | rejected.push({ file, reason: "too_many_images" }); |
| 130 | continue; |
| 131 | } |
| 132 | slot -= 1; |
| 133 | toAdd.push({ |
| 134 | id: uuid(), |
| 135 | file, |
| 136 | previewUrl: URL.createObjectURL(file), |
| 137 | status: "encoding", |
| 138 | }); |
| 139 | } |
| 140 | |
| 141 | if (toAdd.length > 0) { |
| 142 | const next = [...imagesRef.current, ...toAdd]; |
| 143 | imagesRef.current = next; |
| 144 | setImages(next); |
| 145 | // Fire the Worker after the commit so chips render first (good INP). |
| 146 | for (const entry of toAdd) { |
| 147 | queueMicrotask(() => { |
| 148 | encodeImage(entry.file).then( |
| 149 | (result) => { |
| 150 | if (result.ok) { |
| 151 | setEntry(entry.id, { |
| 152 | status: "ready", |
| 153 | dataUrl: result.dataUrl, |
| 154 | encodedBytes: result.bytes, |
| 155 | normalized: result.normalized, |
| 156 | }); |
| 157 | } else { |
| 158 | setEntry(entry.id, { |
| 159 | status: "error", |
| 160 | error: mapEncodeFailure(result.reason), |
| 161 | }); |
| 162 | } |
| 163 | }, |
| 164 | () => { |
| 165 | setEntry(entry.id, { |
| 166 | status: "error", |
| 167 | error: "decode_failed", |
| 168 | }); |
| 169 | }, |
| 170 | ); |
| 171 | }); |
| 172 | } |
| 173 | } |
| 174 | return { rejected }; |
| 175 | }, |
| 176 | [setEntry], |
| 177 | ); |
| 178 | |
| 179 | const remove = useCallback((id: string) => { |
| 180 | let nextFocusId: string | null = null; |
| 181 | setImages((prev) => { |
| 182 | const idx = prev.findIndex((img) => img.id === id); |
| 183 | if (idx === -1) return prev; |
| 184 | const target = prev[idx]; |
| 185 | try { |
| 186 | URL.revokeObjectURL(target.previewUrl); |
| 187 | } catch { |
| 188 | // No-op: previewUrl revocation is best-effort. |
| 189 | } |
| 190 | const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)]; |
| 191 | imagesRef.current = next; |
| 192 | // Prefer moving focus to the chip at the same index, else previous. |
| 193 | const candidate = next[idx] ?? next[idx - 1]; |
| 194 | nextFocusId = candidate?.id ?? null; |
| 195 | return next; |
| 196 | }); |
| 197 | return { nextFocusId }; |
| 198 | }, []); |
| 199 | |
| 200 | const clear = useCallback(() => { |
| 201 | setImages((prev) => { |
| 202 | for (const img of prev) { |
| 203 | try { |
| 204 | URL.revokeObjectURL(img.previewUrl); |
| 205 | } catch { |
| 206 | // revoke is best-effort |
| 207 | } |
| 208 | } |
| 209 | imagesRef.current = []; |
| 210 | return []; |
| 211 | }); |
| 212 | }, []); |
| 213 | |
| 214 | // Final safety net: revoke any outstanding blob URLs on unmount. Safe |
| 215 | // under StrictMode double-invoke because revoked blob URLs are only |
| 216 | // referenced from in-hook chip state, which is rebuilt on remount. |
| 217 | useEffect(() => { |
| 218 | return () => { |
| 219 | for (const img of imagesRef.current) { |
| 220 | try { |
| 221 | URL.revokeObjectURL(img.previewUrl); |
| 222 | } catch { |
| 223 | // best-effort cleanup on unmount |
| 224 | } |
| 225 | } |
| 226 | }; |
| 227 | }, []); |
| 228 | |
| 229 | const encoding = images.some((img) => img.status === "encoding"); |
| 230 | const full = images.length >= MAX_IMAGES_PER_MESSAGE; |
| 231 | |
| 232 | return { images, enqueue, remove, clear, encoding, full }; |
| 233 | } |
| 234 |