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
121 lines
4.2 KiB
TypeScript
121 lines
4.2 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;
|
|
}
|
|
|
|
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
|
|
// Safari both ship one). Embedding via <object> with a fallback link keeps
|
|
// the implementation tiny — no pdf.js dependency.
|
|
export function AttachmentPdf({ handle }: Props) {
|
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
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;
|
|
const typed = new Blob([cached], { type: 'application/pdf' });
|
|
url = URL.createObjectURL(typed);
|
|
setBlobUrl(url);
|
|
return;
|
|
}
|
|
try {
|
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
|
if (cancelled) return;
|
|
// Force the application/pdf type so the browser plugin engages.
|
|
const typed = new Blob([blob], { type: 'application/pdf' });
|
|
url = URL.createObjectURL(typed);
|
|
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-24 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 (
|
|
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
|
|
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
|
|
<span className="flex items-center gap-2 truncate text-fg">
|
|
<PdfGlyph />
|
|
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
|
|
</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
|
|
>
|
|
{expanded ? 'Einklappen' : 'Vorschau'}
|
|
</button>
|
|
<a
|
|
href={blobUrl}
|
|
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
|
|
>
|
|
Download
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{expanded && (
|
|
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
|
|
<p className="p-4 text-xs text-fg-muted">
|
|
Vorschau nicht verfügbar — bitte herunterladen.
|
|
</p>
|
|
</object>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PdfGlyph() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" 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-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|