0d65a134fd
Lightbox was previously a file-private component inside AttachmentImage (used for enlarging chat image attachments). Extracted to a standalone components/Lightbox.tsx so other surfaces can reuse the same dialog without duplicating Esc/backdrop/body-overflow plumbing. In SettingsPage's profile live-preview, the round avatar overlapping the banner is now wrapped in a transparent button that opens the Lightbox with the full-resolution avatar URL on click. Cursor switches to zoom-in. Disabled when the user only has the initial-letter placeholder (nothing meaningful to enlarge). Native button chrome (border, padding, button-face background) is reset to keep the avatar circle's appearance unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
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<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 }: 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);
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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 && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
|
// any future surface can reuse the same dialog without duplication.
|