From faa12a4ebb17dcafeefcbe68cbb91a12b8a48ab8 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 14:43:05 +0200 Subject: [PATCH] feat(chat-switch): hydrate useConversationMessages from in-memory cache Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/useConversationMessages.ts | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 716f3b0..4ae88bd 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -33,6 +33,11 @@ import { shouldGiveUp, subscribeOutbox, } from './messageOutbox'; +import { + getCachedMessages, + hasCachedMessages, + setCachedMessages, +} from './messageMemoryCache'; import { supabase } from './supabase'; import { cachedUserKey } from './userIdentity'; @@ -82,7 +87,20 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar retryPending: (id: string) => void; cancelPending: (id: string) => void; } { - const [state, setState] = useState({ messages: [], loading: true, error: null }); + // Initialize from the in-memory cache so a previously-viewed chat shows + // content on the very first render after the parent remounts on `:id` + // change. `loading` stays true ONLY for never-seen conversations (cache + // miss) so the spinner doesn't flash on every chat switch. + const [state, setState] = useState(() => { + if (conversationId && hasCachedMessages(conversationId)) { + return { + messages: getCachedMessages(conversationId), + loading: false, + error: null, + }; + } + return { messages: [], loading: true, error: null }; + }); const [pending, setPending] = useState(() => conversationId ? getOutbox(conversationId) : [], ); @@ -229,6 +247,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar const rows = await fetchConversationMessages(supabase, conversationId); const decrypted = await decryptBatch(rows); setState({ messages: decrypted, loading: false, error: null }); + setCachedMessages(conversationId, decrypted); // Persist the fresh batch to the local cache so next conversation // switch / app start can hydrate instantly. Fire-and-forget — cache // write failure is never user-visible. @@ -248,12 +267,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar // when the server response lands. On cache-miss this is a ~5ms no-op. useEffect(() => { if (!conversationId) return; + // Memory cache already populated state synchronously — skip the disk + // round-trip entirely. The canonical data lands shortly via refresh(); + // the SQLite cache only matters for cold-start hydration. + if (hasCachedMessages(conversationId)) return; let cancelled = false; void loadCachedMessages(conversationId).then((cached) => { if (cancelled || cached.length === 0) return; setState((prev) => { // Don't clobber a fresh server response that already landed. if (prev.messages.length > 0) return prev; + setCachedMessages(conversationId, cached); return { messages: cached, loading: false, error: null }; }); }); @@ -338,7 +362,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar if (!decrypted) return; setState((prev) => { if (prev.messages.some((m) => m.id === decrypted!.id)) return prev; - return { ...prev, messages: [...prev.messages, decrypted!] }; + const next = [...prev.messages, decrypted!]; + if (conversationId) setCachedMessages(conversationId, next); + return { ...prev, messages: next }; }); }, [conversationId, deviceId, decryptBatch], @@ -358,6 +384,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar editedAt: partial.editedAt, deletedAt: partial.deletedAt, }; + if (conversationId) setCachedMessages(conversationId, next); return { ...prev, messages: next }; }); if (partial.editedAt && !partial.deletedAt) { @@ -421,6 +448,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar if (idx === -1) return prev; const next = [...prev.messages]; next[idx] = decrypted!; + if (conversationId) setCachedMessages(conversationId, next); return { ...prev, messages: next }; }); } @@ -428,14 +456,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar [conversationId, deviceId, decryptBatch], ); - const handleDelete = useCallback((row: Record) => { - const id = String(row.id); - setState((prev) => ({ - ...prev, - messages: prev.messages.filter((m) => m.id !== id), - })); - void deleteCachedMessage(id); - }, []); + const handleDelete = useCallback( + (row: Record) => { + const id = String(row.id); + setState((prev) => { + const next = prev.messages.filter((m) => m.id !== id); + if (conversationId) setCachedMessages(conversationId, next); + return { ...prev, messages: next }; + }); + void deleteCachedMessage(id); + }, + [conversationId], + ); useEffect(() => { if (!conversationId || !userId || !deviceId) return; @@ -552,13 +584,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar }); setState((prev) => { if (prev.messages.some((m) => m.id === msg.id)) return prev; - return { - ...prev, - messages: [ - ...prev.messages, - { ...msg, plaintext: text } as DecryptedMessage, - ], - }; + const next = [ + ...prev.messages, + { ...msg, plaintext: text } as DecryptedMessage, + ]; + setCachedMessages(convId, next); + return { ...prev, messages: next }; }); }, [], @@ -686,16 +717,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar : JSON.stringify({ v: 1, text: trimmed, attachments: handles }); setState((prev) => { if (prev.messages.some((m) => m.id === msg.id)) return prev; - return { - ...prev, - messages: [ - ...prev.messages, - { - ...msg, - plaintext: attachmentsPayload, - } as DecryptedMessage, - ], - }; + const next = [ + ...prev.messages, + { + ...msg, + plaintext: attachmentsPayload, + } as DecryptedMessage, + ]; + setCachedMessages(conversationId, next); + return { ...prev, messages: next }; }); // 4. Insert public attachment metadata rows pointing at the new message.