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
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
|
import { useState } from 'react';
|
|
|
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
|
import { supabase } from '../lib/supabase';
|
|
import { AlertIcon, SpinnerIcon } from './icons';
|
|
|
|
interface Props {
|
|
handle: AttachmentHandle;
|
|
}
|
|
|
|
// Catch-all card for attachments without a richer renderer (zip, docx,
|
|
// txt, etc). Decrypt is deferred to first download click — these can be
|
|
// large and there's no inline preview to justify auto-fetching them.
|
|
export function AttachmentGeneric({ handle }: Props) {
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const download = async () => {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
let blob = await getCachedAttachment(handle.id);
|
|
if (!blob) {
|
|
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
|
void putCachedAttachment(handle.id, blob);
|
|
}
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filenameFor(handle);
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
// Defer revoke so Safari has a chance to start the download.
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : 'download failed');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
|
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
|
|
<FileGlyph />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-semibold text-fg">
|
|
{prettyMime(handle.mimeType)}
|
|
</p>
|
|
<p className="truncate text-[10px] text-fg-muted">
|
|
{formatSize(handle.sizeBytes)}
|
|
</p>
|
|
{error && (
|
|
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
|
|
<AlertIcon className="h-3 w-3" />
|
|
<span>{error}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void download()}
|
|
disabled={busy}
|
|
aria-label="Download"
|
|
title="Download"
|
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
|
|
>
|
|
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FileGlyph() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
|
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DownloadGlyph() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function filenameFor(handle: AttachmentHandle): string {
|
|
const ext = extFor(handle.mimeType);
|
|
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
|
|
}
|
|
|
|
function extFor(mime: string): string | null {
|
|
const map: Record<string, string> = {
|
|
'application/zip': 'zip',
|
|
'application/x-zip-compressed': 'zip',
|
|
'application/x-7z-compressed': '7z',
|
|
'application/x-tar': 'tar',
|
|
'application/gzip': 'gz',
|
|
'application/json': 'json',
|
|
'application/xml': 'xml',
|
|
'text/plain': 'txt',
|
|
'text/markdown': 'md',
|
|
'text/csv': 'csv',
|
|
'application/msword': 'doc',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
'application/vnd.ms-excel': 'xls',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
'application/vnd.ms-powerpoint': 'ppt',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
|
};
|
|
return map[mime] ?? null;
|
|
}
|
|
|
|
function prettyMime(mime: string): string {
|
|
const ext = extFor(mime);
|
|
if (ext) return ext.toUpperCase() + '-Datei';
|
|
if (mime.startsWith('text/')) return 'Textdatei';
|
|
return mime || 'Datei';
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
|
}
|