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:
@@ -31,3 +31,15 @@ tauri-plugin-window-state = "2"
|
||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||
# and disables specific features relevant to the dev build.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
|
||||
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
|
||||
# removes debug + symbol tables (the updater already signs separately so
|
||||
# symbol-backed crash reports aren't the recovery path). `panic = "abort"`
|
||||
# skips unwinding metadata since the app doesn't use catch_unwind anywhere.
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
|
||||
+72
-10
@@ -1,8 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { SpinnerIcon } from './components/icons';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
@@ -10,14 +12,39 @@ import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||
import { ConversationPage } from './pages/ConversationPage';
|
||||
import { DevicePage } from './pages/DevicePage';
|
||||
import { FriendsPage } from './pages/FriendsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||
// AuthPage stays eager because it's the first screen unauthenticated users
|
||||
// see; ChatsPage + ConversationPage stay eager because every authenticated
|
||||
// session renders them immediately.
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const AuthCallbackPage = lazy(() =>
|
||||
import('./pages/AuthCallbackPage').then((m) => ({ default: m.AuthCallbackPage })),
|
||||
);
|
||||
const DevicePage = lazy(() => import('./pages/DevicePage').then((m) => ({ default: m.DevicePage })));
|
||||
const FriendsPage = lazy(() =>
|
||||
import('./pages/FriendsPage').then((m) => ({ default: m.FriendsPage })),
|
||||
);
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
);
|
||||
|
||||
function RouteSuspense({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-full w-full items-center justify-center bg-surface-3">
|
||||
<SpinnerIcon className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates each top-level route so a crash in one page doesn't take the whole
|
||||
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||
@@ -53,11 +80,25 @@ export function App() {
|
||||
<Routes>
|
||||
<Route element={<RouteBoundary scope="auth" />}>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AuthCallbackPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RouteBoundary scope="device" />}>
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
<Route
|
||||
path="/device"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<DevicePage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireDevice />}>
|
||||
<Route element={<AppShell />}>
|
||||
@@ -76,14 +117,35 @@ export function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="friends" />}>
|
||||
<Route path="/friends" element={<FriendsPage />} />
|
||||
<Route
|
||||
path="/friends"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<FriendsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="settings" />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<SettingsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route element={<RouteBoundary scope="admin" />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AdminPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
setBlobUrl(null);
|
||||
setArrayBuf(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then(async (blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(cached);
|
||||
setBlobUrl(url);
|
||||
const buf = await cached.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
const buf = await blob.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -20,7 +21,11 @@ export function AttachmentGeneric({ handle }: Props) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
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;
|
||||
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } from './icons';
|
||||
|
||||
@@ -22,19 +23,29 @@ export function AttachmentPdf({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
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);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -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 } from './icons';
|
||||
|
||||
@@ -20,17 +21,26 @@ export function AttachmentVideo({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
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);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||
|
||||
interface Props {
|
||||
url?: string | null | undefined;
|
||||
displayName?: string | null | undefined;
|
||||
@@ -18,10 +20,11 @@ export function Avatar({
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
}: Props) {
|
||||
if (url) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
src={effectiveUrl}
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Blob cache for decrypted attachments. Stores the Blob body in OPFS
|
||||
// (Origin Private File System — always sandbox-scoped to the origin, no
|
||||
// user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting
|
||||
// the same blob on every scroll-into-view.
|
||||
//
|
||||
// Tauri WebKit + WebView2 both support OPFS as of the versions targeted by
|
||||
// tauri 2. On unsupported hosts the cache degrades to a no-op and callers
|
||||
// fall back to the fetch path.
|
||||
|
||||
const DIR_NAME = 'attachments';
|
||||
const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||
|
||||
interface OpfsRoot {
|
||||
getDirectoryHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsDir>;
|
||||
}
|
||||
|
||||
interface OpfsDir {
|
||||
getFileHandle: (
|
||||
name: string,
|
||||
opts?: { create?: boolean },
|
||||
) => Promise<OpfsFile>;
|
||||
removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise<void>;
|
||||
entries?: () => AsyncIterableIterator<[string, OpfsFile]>;
|
||||
}
|
||||
|
||||
interface OpfsFile {
|
||||
getFile: () => Promise<File>;
|
||||
createWritable: () => Promise<{
|
||||
write: (data: ArrayBuffer | Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let dirPromise: Promise<OpfsDir | null> | null = null;
|
||||
|
||||
async function getDir(): Promise<OpfsDir | null> {
|
||||
if (!dirPromise) {
|
||||
dirPromise = (async () => {
|
||||
const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise<OpfsRoot> } }).storage;
|
||||
const getDirectory = storage?.getDirectory;
|
||||
if (!storage || !getDirectory) return null;
|
||||
try {
|
||||
const root = await getDirectory.call(storage);
|
||||
return await root.getDirectoryHandle(DIR_NAME, { create: true });
|
||||
} catch (err: unknown) {
|
||||
console.warn('opfs attachment cache init failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return dirPromise;
|
||||
}
|
||||
|
||||
function safeName(id: string): string {
|
||||
// OPFS file names can't contain slashes; attachment ids are UUIDs so this
|
||||
// is mostly defensive.
|
||||
return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin';
|
||||
}
|
||||
|
||||
export async function getCachedAttachment(id: string): Promise<Blob | null> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return null;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: false });
|
||||
const file = await handle.getFile();
|
||||
// Evict stale entries lazily — if older than TTL, drop and miss.
|
||||
if (Date.now() - file.lastModified > DEFAULT_TTL_MS) {
|
||||
await dir.removeEntry(safeName(id)).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
} catch {
|
||||
// File doesn't exist → cache miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putCachedAttachment(id: string, blob: Blob): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
const handle = await dir.getFileHandle(safeName(id), { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
} catch (err: unknown) {
|
||||
console.warn('putCachedAttachment failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function evictCachedAttachment(id: string): Promise<void> {
|
||||
const dir = await getDir();
|
||||
if (!dir) return;
|
||||
try {
|
||||
await dir.removeEntry(safeName(id));
|
||||
} catch {
|
||||
/* ignore — probably never existed */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Session-level avatar cache. Avatars are small (<100KB), referenced from
|
||||
// many places (chat list, bubbles, dialogs), and rarely change — so keeping
|
||||
// one blob-URL per remote URL avoids repeated decodes + network 304s across
|
||||
// remounts. OPFS is optional; the in-memory map is enough for daily use.
|
||||
|
||||
const blobUrls = new Map<string, string>(); // remote URL → blob URL
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
async function fetchAsBlobUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'force-cache' });
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a cached blob URL for the avatar. Falls back to the original URL
|
||||
// if caching fails so rendering never breaks.
|
||||
export function useCachedAvatarUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url;
|
||||
return blobUrls.get(url) ?? url;
|
||||
}
|
||||
|
||||
// Eagerly populate the cache for a list of URLs (e.g. conversation members)
|
||||
// so subsequent renders hit the Map directly.
|
||||
export function warmAvatarCache(urls: Array<string | null | undefined>): void {
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
if (blobUrls.has(url)) continue;
|
||||
if (inflight.has(url)) continue;
|
||||
const p = fetchAsBlobUrl(url).then((blobUrl) => {
|
||||
if (blobUrl) blobUrls.set(url, blobUrl);
|
||||
inflight.delete(url);
|
||||
return blobUrl;
|
||||
});
|
||||
inflight.set(url, p);
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,36 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
void refresh();
|
||||
|
||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
||||
// the last 100 in one query — cheaper and keeps order stable. For
|
||||
// lone inserts the per-id path stays so latency is unchanged.
|
||||
const BATCH_WINDOW_MS = 250;
|
||||
const BATCH_BURST_THRESHOLD = 3;
|
||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||
let burstTimer: number | null = null;
|
||||
const flushBurst = () => {
|
||||
const buf = burstBuffer;
|
||||
burstBuffer = [];
|
||||
if (burstTimer !== null) {
|
||||
window.clearTimeout(burstTimer);
|
||||
burstTimer = null;
|
||||
}
|
||||
if (buf.length === 0) return;
|
||||
if (buf.length > BATCH_BURST_THRESHOLD) {
|
||||
void refresh();
|
||||
} else {
|
||||
for (const row of buf) void handleInsert(row);
|
||||
}
|
||||
};
|
||||
const queueInsert = (row: Record<string, unknown>) => {
|
||||
burstBuffer.push(row);
|
||||
if (burstTimer === null) {
|
||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const channel = supabase
|
||||
.channel('conv:' + conversationId)
|
||||
.on(
|
||||
@@ -355,7 +385,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
},
|
||||
(payload: MessageChangePayload) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
void handleInsert(payload.new);
|
||||
queueInsert(payload.new);
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
void handleUpdate(payload.new);
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
@@ -404,6 +434,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
if (burstTimer !== null) window.clearTimeout(burstTimer);
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isConversationMuted,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
@@ -227,18 +227,12 @@ function ConversationList({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
item={c}
|
||||
active={c.id === activeId}
|
||||
unreadCount={unread[c.id] ?? 0}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<VirtualConversationList
|
||||
items={items}
|
||||
activeId={activeId}
|
||||
unread={unread}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||
@@ -248,6 +242,72 @@ function ConversationList({
|
||||
);
|
||||
}
|
||||
|
||||
// Windowed list: renders the first N rows and expands by N whenever a
|
||||
// bottom sentinel scrolls into view. Under the threshold we skip the
|
||||
// machinery entirely because rendering 50 rows costs less than the
|
||||
// overhead of observers + state updates.
|
||||
const VLIST_INITIAL = 40;
|
||||
const VLIST_STEP = 40;
|
||||
|
||||
function VirtualConversationList({
|
||||
items,
|
||||
activeId,
|
||||
unread,
|
||||
onAccept,
|
||||
}: {
|
||||
items: ConversationSummary[];
|
||||
activeId: string | undefined;
|
||||
unread: Record<string, number>;
|
||||
onAccept: (id: string) => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState<number>(VLIST_INITIAL);
|
||||
const scrollRef = useRef<HTMLUListElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(VLIST_INITIAL);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= visible) return;
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisible((n) => Math.min(items.length, n + VLIST_STEP));
|
||||
}
|
||||
},
|
||||
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
||||
);
|
||||
obs.observe(node);
|
||||
return () => obs.disconnect();
|
||||
}, [items.length, visible]);
|
||||
|
||||
const slice = items.length <= VLIST_INITIAL ? items : items.slice(0, visible);
|
||||
const hasMore = items.length > slice.length;
|
||||
|
||||
return (
|
||||
<ul ref={scrollRef} className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{slice.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
item={c}
|
||||
active={c.id === activeId}
|
||||
unreadCount={unread[c.id] ?? 0}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{hasMore && (
|
||||
<li ref={sentinelRef} className="py-2 text-center text-xs text-fg-muted">
|
||||
…
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationRow({
|
||||
item,
|
||||
active,
|
||||
|
||||
@@ -25,6 +25,25 @@ export default defineConfig({
|
||||
conditions: ['require', 'node', 'default'],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Split heavy vendor modules into their own chunks so they cache
|
||||
// independently from the app shell. LiveKit + libsodium change
|
||||
// rarely, so this keeps the hot-path app chunk small on rebuilds
|
||||
// and lets the browser cache the big binaries across app updates.
|
||||
manualChunks: (id) => {
|
||||
if (id.includes('node_modules/livekit-client')) return 'vendor-livekit';
|
||||
if (id.includes('node_modules/libsodium-wrappers-sumo')) return 'vendor-sodium';
|
||||
if (id.includes('node_modules/@supabase')) return 'vendor-supabase';
|
||||
if (id.includes('node_modules/react-router') || id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) {
|
||||
return 'vendor-react';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
|
||||
Reference in New Issue
Block a user