44088b35d7
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
79 lines
2.3 KiB
TypeScript
79 lines
2.3 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';
|
|
|
|
interface Props {
|
|
handle: AttachmentHandle;
|
|
}
|
|
|
|
// Decrypt the blob, render a native <video controls>. Loads on demand —
|
|
// metadata-only preload so we don't burn bandwidth until the user hits play.
|
|
export function AttachmentVideo({ handle }: Props) {
|
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let url: string | null = null;
|
|
setError(null);
|
|
setBlobUrl(null);
|
|
|
|
void (async () => {
|
|
const cached = await getCachedAttachment(handle.id);
|
|
if (cached) {
|
|
if (cancelled) return;
|
|
url = URL.createObjectURL(cached);
|
|
setBlobUrl(url);
|
|
return;
|
|
}
|
|
try {
|
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
|
if (cancelled) return;
|
|
url = URL.createObjectURL(blob);
|
|
setBlobUrl(url);
|
|
void putCachedAttachment(handle.id, blob);
|
|
} catch (err: unknown) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : 'download failed');
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (url) URL.revokeObjectURL(url);
|
|
};
|
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
|
|
|
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-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
|
<SpinnerIcon className="h-5 w-5" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<video
|
|
controls
|
|
preload="metadata"
|
|
src={blobUrl}
|
|
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
|
|
>
|
|
<track kind="captions" />
|
|
</video>
|
|
);
|
|
}
|