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
|
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||||
# and disables specific features relevant to the dev build.
|
# and disables specific features relevant to the dev build.
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
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 { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
import { CrashToast } from './components/CrashToast';
|
import { CrashToast } from './components/CrashToast';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { SpinnerIcon } from './components/icons';
|
||||||
import { UpdateToast } from './components/UpdateToast';
|
import { UpdateToast } from './components/UpdateToast';
|
||||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
@@ -10,14 +12,39 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
import { DevicePage } from './pages/DevicePage';
|
|
||||||
import { FriendsPage } from './pages/FriendsPage';
|
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
// 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
|
// 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.
|
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||||
@@ -53,11 +80,25 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<RouteBoundary scope="auth" />}>
|
<Route element={<RouteBoundary scope="auth" />}>
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
<Route
|
||||||
|
path="/auth/callback"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AuthCallbackPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<RouteBoundary scope="device" />}>
|
<Route element={<RouteBoundary scope="device" />}>
|
||||||
<Route path="/device" element={<DevicePage />} />
|
<Route
|
||||||
|
path="/device"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<DevicePage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireDevice />}>
|
<Route element={<RequireDevice />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
@@ -76,14 +117,35 @@ export function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RouteBoundary scope="friends" />}>
|
<Route element={<RouteBoundary scope="friends" />}>
|
||||||
<Route path="/friends" element={<FriendsPage />} />
|
<Route
|
||||||
|
path="/friends"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<FriendsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RouteBoundary scope="settings" />}>
|
<Route element={<RouteBoundary scope="settings" />}>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<SettingsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route element={<RouteBoundary scope="admin" />}>
|
<Route element={<RouteBoundary scope="admin" />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AdminPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
setBlobUrl(null);
|
setBlobUrl(null);
|
||||||
setArrayBuf(null);
|
setArrayBuf(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
void (async () => {
|
||||||
.then(async (blob) => {
|
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;
|
if (cancelled) return;
|
||||||
url = URL.createObjectURL(blob);
|
url = URL.createObjectURL(blob);
|
||||||
setBlobUrl(url);
|
setBlobUrl(url);
|
||||||
const buf = await blob.arrayBuffer();
|
const buf = await blob.arrayBuffer();
|
||||||
if (!cancelled) setArrayBuf(buf);
|
if (!cancelled) setArrayBuf(buf);
|
||||||
})
|
void putCachedAttachment(handle.id, blob);
|
||||||
.catch((err: unknown) => {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -20,7 +21,11 @@ export function AttachmentGeneric({ handle }: Props) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -8,35 +9,95 @@ interface Props {
|
|||||||
handle: AttachmentHandle;
|
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) {
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let url: string | null = null;
|
const created: string[] = [];
|
||||||
setError(null);
|
setError(null);
|
||||||
setBlobUrl(null);
|
setFullUrl(null);
|
||||||
|
setThumbUrl(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
const take = (blob: Blob): string => {
|
||||||
.then((blob) => {
|
const u = URL.createObjectURL(blob);
|
||||||
if (cancelled) return;
|
created.push(u);
|
||||||
url = URL.createObjectURL(blob);
|
return u;
|
||||||
setBlobUrl(url);
|
};
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
// 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) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (url) URL.revokeObjectURL(url);
|
for (const u of created) URL.revokeObjectURL(u);
|
||||||
};
|
};
|
||||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
const blobUrl = thumbUrl ?? fullUrl;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<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}
|
src={blobUrl}
|
||||||
alt="attachment"
|
alt="attachment"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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 { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -22,19 +23,29 @@ export function AttachmentPdf({ handle }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBlobUrl(null);
|
setBlobUrl(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
void (async () => {
|
||||||
.then((blob) => {
|
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;
|
if (cancelled) return;
|
||||||
// Force the application/pdf type so the browser plugin engages.
|
// Force the application/pdf type so the browser plugin engages.
|
||||||
const typed = new Blob([blob], { type: 'application/pdf' });
|
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||||
url = URL.createObjectURL(typed);
|
url = URL.createObjectURL(typed);
|
||||||
setBlobUrl(url);
|
setBlobUrl(url);
|
||||||
})
|
void putCachedAttachment(handle.id, blob);
|
||||||
.catch((err: unknown) => {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -20,17 +21,26 @@ export function AttachmentVideo({ handle }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBlobUrl(null);
|
setBlobUrl(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
void (async () => {
|
||||||
.then((blob) => {
|
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;
|
if (cancelled) return;
|
||||||
url = URL.createObjectURL(blob);
|
url = URL.createObjectURL(blob);
|
||||||
setBlobUrl(url);
|
setBlobUrl(url);
|
||||||
})
|
void putCachedAttachment(handle.id, blob);
|
||||||
.catch((err: unknown) => {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
// 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.
|
// letter circle. Use this everywhere the app needs to render a profile.
|
||||||
|
|
||||||
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url?: string | null | undefined;
|
url?: string | null | undefined;
|
||||||
displayName?: string | null | undefined;
|
displayName?: string | null | undefined;
|
||||||
@@ -18,10 +20,11 @@ export function Avatar({
|
|||||||
fallbackClass = 'bg-accent/20 text-accent',
|
fallbackClass = 'bg-accent/20 text-accent',
|
||||||
alt,
|
alt,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (url) {
|
const effectiveUrl = useCachedAvatarUrl(url);
|
||||||
|
if (effectiveUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={effectiveUrl}
|
||||||
alt={alt ?? displayName ?? ''}
|
alt={alt ?? displayName ?? ''}
|
||||||
className={'shrink-0 rounded-full object-cover ' + className}
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
draggable={false}
|
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;
|
if (!conversationId || !userId || !deviceId) return;
|
||||||
void refresh();
|
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
|
const channel = supabase
|
||||||
.channel('conv:' + conversationId)
|
.channel('conv:' + conversationId)
|
||||||
.on(
|
.on(
|
||||||
@@ -355,7 +385,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
},
|
},
|
||||||
(payload: MessageChangePayload) => {
|
(payload: MessageChangePayload) => {
|
||||||
if (payload.eventType === 'INSERT') {
|
if (payload.eventType === 'INSERT') {
|
||||||
void handleInsert(payload.new);
|
queueInsert(payload.new);
|
||||||
} else if (payload.eventType === 'UPDATE') {
|
} else if (payload.eventType === 'UPDATE') {
|
||||||
void handleUpdate(payload.new);
|
void handleUpdate(payload.new);
|
||||||
} else if (payload.eventType === 'DELETE') {
|
} else if (payload.eventType === 'DELETE') {
|
||||||
@@ -404,6 +434,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
window.addEventListener('online', onAwake);
|
window.addEventListener('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (burstTimer !== null) window.clearTimeout(burstTimer);
|
||||||
document.removeEventListener('visibilitychange', onAwake);
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
window.removeEventListener('online', onAwake);
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
isConversationMuted,
|
isConversationMuted,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -227,8 +227,69 @@ function ConversationList({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
<VirtualConversationList
|
||||||
{items.map((c) => (
|
items={items}
|
||||||
|
activeId={activeId}
|
||||||
|
unread={unread}
|
||||||
|
onAccept={onAccept}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||||
|
<UserBar />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}>
|
<li key={c.id}>
|
||||||
<ConversationRow
|
<ConversationRow
|
||||||
item={c}
|
item={c}
|
||||||
@@ -238,13 +299,12 @@ function ConversationList({
|
|||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
{hasMore && (
|
||||||
|
<li ref={sentinelRef} className="py-2 text-center text-xs text-fg-muted">
|
||||||
|
…
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
|
</ul>
|
||||||
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
|
||||||
<UserBar />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ export default defineConfig({
|
|||||||
conditions: ['require', 'node', 'default'],
|
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,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
|
|||||||
Reference in New Issue
Block a user