@@ -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 (
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({
) : (
-
@@ -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,