diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 96fc4dd..b0baec1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index cb15c48..f70c493 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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 ( + + + + } + > + {children} + + ); +} // 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() { }> } /> - } /> + + + + } + /> }> }> - } /> + + + + } + /> }> }> @@ -76,14 +117,35 @@ export function App() { }> - } /> + + + + } + /> }> - } /> + + + + } + /> }> }> - } /> + + + + } + /> diff --git a/apps/desktop/src/components/AttachmentAudio.tsx b/apps/desktop/src/components/AttachmentAudio.tsx index fdc3d3d..cd6c099 100644 --- a/apps/desktop/src/components/AttachmentAudio.tsx +++ b/apps/desktop/src/components/AttachmentAudio.tsx @@ -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; diff --git a/apps/desktop/src/components/AttachmentGeneric.tsx b/apps/desktop/src/components/AttachmentGeneric.tsx index 491195b..5b310f1 100644 --- a/apps/desktop/src/components/AttachmentGeneric.tsx +++ b/apps/desktop/src/components/AttachmentGeneric.tsx @@ -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; diff --git a/apps/desktop/src/components/AttachmentImage.tsx b/apps/desktop/src/components/AttachmentImage.tsx index 4188631..6f3400c 100644 --- a/apps/desktop/src/components/AttachmentImage.tsx +++ b/apps/desktop/src/components/AttachmentImage.tsx @@ -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 { + 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(null); + const [fullUrl, setFullUrl] = useState(null); + const [thumbUrl, setThumbUrl] = useState(null); const [error, setError] = useState(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 (
@@ -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" /> - {lightboxOpen && setLightboxOpen(false)} />} + {lightboxOpen && fullUrl && setLightboxOpen(false)} />} ); } diff --git a/apps/desktop/src/components/AttachmentPdf.tsx b/apps/desktop/src/components/AttachmentPdf.tsx index 9d82e80..1695099 100644 --- a/apps/desktop/src/components/AttachmentPdf.tsx +++ b/apps/desktop/src/components/AttachmentPdf.tsx @@ -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; diff --git a/apps/desktop/src/components/AttachmentVideo.tsx b/apps/desktop/src/components/AttachmentVideo.tsx index 490b236..7ee0638 100644 --- a/apps/desktop/src/components/AttachmentVideo.tsx +++ b/apps/desktop/src/components/AttachmentVideo.tsx @@ -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; diff --git a/apps/desktop/src/components/Avatar.tsx b/apps/desktop/src/components/Avatar.tsx index d005f28..0e9cf06 100644 --- a/apps/desktop/src/components/Avatar.tsx +++ b/apps/desktop/src/components/Avatar.tsx @@ -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 ( {alt Promise; +} + +interface OpfsDir { + getFileHandle: ( + name: string, + opts?: { create?: boolean }, + ) => Promise; + removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise; + entries?: () => AsyncIterableIterator<[string, OpfsFile]>; +} + +interface OpfsFile { + getFile: () => Promise; + createWritable: () => Promise<{ + write: (data: ArrayBuffer | Blob) => Promise; + close: () => Promise; + }>; +} + +let dirPromise: Promise | null = null; + +async function getDir(): Promise { + if (!dirPromise) { + dirPromise = (async () => { + const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise } }).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 { + 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 { + 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 { + const dir = await getDir(); + if (!dir) return; + try { + await dir.removeEntry(safeName(id)); + } catch { + /* ignore — probably never existed */ + } +} diff --git a/apps/desktop/src/lib/avatarCache.ts b/apps/desktop/src/lib/avatarCache.ts new file mode 100644 index 0000000..4962550 --- /dev/null +++ b/apps/desktop/src/lib/avatarCache.ts @@ -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(); // remote URL → blob URL +const inflight = new Map>(); + +async function fetchAsBlobUrl(url: string): Promise { + 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): 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); + } +} diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index ed70dd7..d8b801e 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -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> = []; + 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) => { + 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); diff --git a/apps/desktop/src/pages/ChatsPage.tsx b/apps/desktop/src/pages/ChatsPage.tsx index 5bdbc69..dea9df9 100644 --- a/apps/desktop/src/pages/ChatsPage.tsx +++ b/apps/desktop/src/pages/ChatsPage.tsx @@ -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({

) : ( -
    - {items.map((c) => ( -
  • - -
  • - ))} -
+ )}
@@ -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; + onAccept: (id: string) => void; +}) { + const [visible, setVisible] = useState(VLIST_INITIAL); + const scrollRef = useRef(null); + const sentinelRef = useRef(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 ( +
    + {slice.map((c) => ( +
  • + +
  • + ))} + {hasMore && ( +
  • + … +
  • + )} +
+ ); +} + function ConversationRow({ item, active, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index cb1d64f..1811681 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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,