import { type AttachmentHandle, downloadAndDecryptAttachment, downloadAndDecryptAttachmentThumb, } 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'; import { ViewOnceImage } from './ViewOnceImage'; interface Props { handle: AttachmentHandle; /** True iff the local user sent this message. View-once images use this * to suppress the burn (senders never burn their own attachment) and to * pick the right preview chrome. Defaults to false so existing callers * (e.g. MediaFilesDrawer) keep working without modification. */ mine?: boolean; } // 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, mine = false }: Props) { const [fullUrl, setFullUrl] = useState(null); const [thumbUrl, setThumbUrl] = useState(null); const [error, setError] = useState(null); const [lightboxOpen, setLightboxOpen] = useState(false); // Whether the sender shipped a pre-built WebP thumb alongside this attachment // (Phase 6B+). When true we render the bubble from just the thumb and only // fetch the full blob when the user opens the lightbox or for view-once. const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64); // View-once needs the full blob ready instantly the moment the recipient // taps (otherwise we'd show a spinner during the burn animation, then race // the "mark viewed" RPC). Same for the legacy path with no server thumb — // we have to download the whole thing just to render the client-side // makeThumbnail fallback. const needsEagerFull = handle.viewOnce === true || !hasServerThumb; 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; }; const thumbCacheId = handle.id + '-thumb'; void (async () => { // Phase 6B fast path: if the sender shipped a server-side WebP thumb, // grab it first so the bubble paints from ~20KB instead of waiting on // the multi-MB full blob. The OPFS cache is keyed separately so the // thumb survives independent of full-blob eviction. if (hasServerThumb) { try { let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId); if (!thumbBlob) { thumbBlob = await downloadAndDecryptAttachmentThumb({ client: supabase, handle, }); if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob); } if (cancelled) return; if (thumbBlob) { setThumbUrl(take(thumbBlob)); } } catch (err: unknown) { // Thumb decrypt failure isn't fatal — fall through to the full // blob path below so the user still sees the image. console.warn('thumb load failed', err); } } // Eagerly resolve the full blob when we need it for view-once or as // the only render source (no server thumb). For the thumb-first path // the full blob is deferred until the lightbox opens (see below). if (!needsEagerFull) return; 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); // Pre-Phase-6B fallback: no server thumb shipped, so re-derive a // smaller preview on the client to keep memory pressure down. if (!hasServerThumb) { 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, handle.id, handle.storagePath, handle.keyB64, handle.nonceB64, handle.thumbStoragePath, handle.thumbNonceB64, hasServerThumb, needsEagerFull, ]); // Lazy full-image fetch for click-to-expand (only kicks in when we // skipped the eager full-blob download above). Resolves into the same // `fullUrl` state that the Lightbox consumes; the thumb URL keeps // backing the bubble until the lightbox actually mounts. useEffect(() => { if (!lightboxOpen) return; if (fullUrl) return; let cancelled = false; const created: string[] = []; 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 u = URL.createObjectURL(blob); created.push(u); setFullUrl(u); })(); return () => { cancelled = true; for (const u of created) URL.revokeObjectURL(u); }; }, [lightboxOpen, fullUrl, handle]); const blobUrl = thumbUrl ?? fullUrl; if (error) { return (
{error}
); } if (!blobUrl) { return (
); } // View-once attachments swap the standard image preview for the ViewOnceImage // chrome (locked card → fullscreen lightbox + burn for recipient, normal // image with badge for sender). We still kick off the decrypt above so the // fullscreen lightbox has the decoded blob ready the moment the recipient // taps. `handle.viewedAt` is undefined in the current renderer (it lives on // the public attachment row, not in the encrypted payload) — the component // treats undefined as "not yet burned" and uses the RPC response to flip // the state locally after the recipient opens. if (handle.viewOnce) { return ( ); } return ( <> {lightboxOpen && ( setLightboxOpen(false)} /> )} ); } // Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and // any future surface can reuse the same dialog without duplication.