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:
2026-04-21 10:24:31 +02:00
parent 228608ef2c
commit 44088b35d7
13 changed files with 487 additions and 57 deletions
+12
View File
@@ -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
View File
@@ -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;
+77 -15
View File
@@ -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)} />}
</>
);
}
+16 -5
View File
@@ -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;
+5 -2
View File
@@ -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}
+102
View File
@@ -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 */
}
}
+41
View File
@@ -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);
+73 -13
View File
@@ -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,
+19
View File
@@ -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,