268 lines
9.3 KiB
TypeScript
268 lines
9.3 KiB
TypeScript
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<Blob | null> {
|
||
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<string | null>(null);
|
||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||
<AlertIcon className="h-4 w-4" />
|
||
<span>{error}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!blobUrl) {
|
||
return (
|
||
<div className="mt-2 flex h-28 w-28 items-center justify-center rounded-lg border border-white/10 bg-ink-800/60">
|
||
<SpinnerIcon className="h-5 w-5 text-brand-400" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<ViewOnceImage
|
||
attachmentId={handle.id}
|
||
viewedAt={handle.viewedAt ?? null}
|
||
isSender={mine}
|
||
src={blobUrl}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setLightboxOpen(true)}
|
||
aria-label="Bild öffnen"
|
||
className="mt-2 block w-fit max-w-full cursor-pointer overflow-hidden rounded-lg border border-white/10 bg-ink-800/40 transition hover:border-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||
>
|
||
<img
|
||
src={blobUrl}
|
||
alt="attachment"
|
||
loading="lazy"
|
||
decoding="async"
|
||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||
/>
|
||
</button>
|
||
{lightboxOpen && (
|
||
<Lightbox
|
||
// Prefer the full blob the moment it's available; otherwise show
|
||
// the thumb so the user sees *something* during the lazy fetch
|
||
// (typical full-blob fetch is 100ms–2s depending on size).
|
||
url={fullUrl ?? blobUrl}
|
||
onClose={() => setLightboxOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||
// any future surface can reuse the same dialog without duplication.
|