perf: bundle splitting, caches, thumbnails, batching, virtualization, release tuning
Route splitting - React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage, AuthCallbackPage; ChatsPage + ConversationPage stay eager - RouteSuspense wrapper with spinner fallback Vendor chunking - Vite manualChunks splits livekit-client, libsodium, @supabase, react into dedicated cacheable chunks Image thumbnails - createImageBitmap + OffscreenCanvas downscales inline preview to max 640px, emits webp; full blob reserved for the lightbox - Passes through gif/apng/webp so animation is preserved - decoding="async" on the inline img Attachment cache - lib/attachmentCache.ts backed by OPFS; 7-day TTL - AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on miss, write-through on success; graceful no-op when OPFS missing Avatar cache - lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache() helper for bulk preload Message batching - Realtime INSERT burst collapses to a single refresh() when >3 ids land within a 250ms window; solo inserts keep the per-id path for latency parity Conversation-list virtualization - VirtualConversationList with IntersectionObserver sentinel, initial 40 rows + 40 per batch; no overhead under threshold Rust release tuning - Cargo [profile.release]: lto, codegen-units=1, strip=symbols, panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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, XIcon } from './icons';
|
||||
|
||||
@@ -8,35 +9,95 @@ 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 [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
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;
|
||||
let url: string | null = null;
|
||||
const created: string[] = [];
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
setFullUrl(null);
|
||||
setThumbUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
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;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
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">
|
||||
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
|
||||
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 url={blobUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user