import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat'; import { useEffect, useState } from 'react'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; import { supabase } from '../lib/supabase'; import { AlertIcon, SpinnerIcon } from './icons'; import { Lightbox } from './Lightbox'; interface Props { handle: AttachmentHandle; } // Max inline-preview dimension. Full-resolution stays available for the // lightbox. Animated formats (gif/webp/apng) are passed through untouched // so animation isn't lost; everything else is downscaled to this box. const THUMB_MAX_DIM = 640; const ANIMATED_MIME = /^image\/(gif|apng|webp)/; async function makeThumbnail(blob: Blob): Promise { if (ANIMATED_MIME.test(blob.type)) return null; if (typeof createImageBitmap !== 'function') return null; if (typeof OffscreenCanvas !== 'function') return null; try { const bitmap = await createImageBitmap(blob); const largest = Math.max(bitmap.width, bitmap.height); if (largest <= THUMB_MAX_DIM) { bitmap.close(); return null; } const scale = THUMB_MAX_DIM / largest; const w = Math.max(1, Math.round(bitmap.width * scale)); const h = Math.max(1, Math.round(bitmap.height * scale)); const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) { bitmap.close(); return null; } ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close(); return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 }); } catch { return null; } } export function AttachmentImage({ handle }: Props) { const [fullUrl, setFullUrl] = useState(null); const [thumbUrl, setThumbUrl] = useState(null); const [error, setError] = useState(null); const [lightboxOpen, setLightboxOpen] = useState(false); useEffect(() => { let cancelled = false; const created: string[] = []; setError(null); setFullUrl(null); setThumbUrl(null); const take = (blob: Blob): string => { const u = URL.createObjectURL(blob); created.push(u); return u; }; // OPFS cache → decrypt → generate thumbnail for inline display. // Lightbox swaps to the full blob when opened. void (async () => { const cached = await getCachedAttachment(handle.id); let blob: Blob; if (cached) { blob = cached; } else { try { blob = await downloadAndDecryptAttachment({ client: supabase, handle }); void putCachedAttachment(handle.id, blob); } catch (err: unknown) { if (!cancelled) { setError(err instanceof Error ? err.message : 'download failed'); } return; } } if (cancelled) return; const full = take(blob); setFullUrl(full); const thumb = await makeThumbnail(blob); if (cancelled) return; if (thumb) { setThumbUrl(take(thumb)); } })(); return () => { cancelled = true; for (const u of created) URL.revokeObjectURL(u); }; }, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]); const blobUrl = thumbUrl ?? fullUrl; if (error) { return (
{error}
); } if (!blobUrl) { return (
); } return ( <> {lightboxOpen && fullUrl && setLightboxOpen(false)} />} ); } // Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and // any future surface can reuse the same dialog without duplication.