feat(chat-switch): hydrate useConversationMessages from in-memory cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-17 14:43:05 +02:00
parent 37dd1b4f23
commit faa12a4ebb
+57 -27
View File
@@ -33,6 +33,11 @@ import {
shouldGiveUp, shouldGiveUp,
subscribeOutbox, subscribeOutbox,
} from './messageOutbox'; } from './messageOutbox';
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
import { supabase } from './supabase'; import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity'; import { cachedUserKey } from './userIdentity';
@@ -82,7 +87,20 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
retryPending: (id: string) => void; retryPending: (id: string) => void;
cancelPending: (id: string) => void; cancelPending: (id: string) => void;
} { } {
const [state, setState] = useState<State>({ 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<State>(() => {
if (conversationId && hasCachedMessages(conversationId)) {
return {
messages: getCachedMessages(conversationId),
loading: false,
error: null,
};
}
return { messages: [], loading: true, error: null };
});
const [pending, setPending] = useState<OutboxItem[]>(() => const [pending, setPending] = useState<OutboxItem[]>(() =>
conversationId ? getOutbox(conversationId) : [], conversationId ? getOutbox(conversationId) : [],
); );
@@ -229,6 +247,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const rows = await fetchConversationMessages(supabase, conversationId); const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows); const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null }); setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation // Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache // switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible. // 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. // when the server response lands. On cache-miss this is a ~5ms no-op.
useEffect(() => { useEffect(() => {
if (!conversationId) return; 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; let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => { void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return; if (cancelled || cached.length === 0) return;
setState((prev) => { setState((prev) => {
// Don't clobber a fresh server response that already landed. // Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev; if (prev.messages.length > 0) return prev;
setCachedMessages(conversationId, cached);
return { messages: cached, loading: false, error: null }; return { messages: cached, loading: false, error: null };
}); });
}); });
@@ -338,7 +362,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (!decrypted) return; if (!decrypted) return;
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return 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], [conversationId, deviceId, decryptBatch],
@@ -358,6 +384,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
editedAt: partial.editedAt, editedAt: partial.editedAt,
deletedAt: partial.deletedAt, deletedAt: partial.deletedAt,
}; };
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
if (partial.editedAt && !partial.deletedAt) { if (partial.editedAt && !partial.deletedAt) {
@@ -421,6 +448,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (idx === -1) return prev; if (idx === -1) return prev;
const next = [...prev.messages]; const next = [...prev.messages];
next[idx] = decrypted!; next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
} }
@@ -428,14 +456,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
[conversationId, deviceId, decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleDelete = useCallback((row: Record<string, unknown>) => { const handleDelete = useCallback(
const id = String(row.id); (row: Record<string, unknown>) => {
setState((prev) => ({ const id = String(row.id);
...prev, setState((prev) => {
messages: prev.messages.filter((m) => m.id !== id), const next = prev.messages.filter((m) => m.id !== id);
})); if (conversationId) setCachedMessages(conversationId, next);
void deleteCachedMessage(id); return { ...prev, messages: next };
}, []); });
void deleteCachedMessage(id);
},
[conversationId],
);
useEffect(() => { useEffect(() => {
if (!conversationId || !userId || !deviceId) return; if (!conversationId || !userId || !deviceId) return;
@@ -552,13 +584,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}); });
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev; if (prev.messages.some((m) => m.id === msg.id)) return prev;
return { const next = [
...prev, ...prev.messages,
messages: [ { ...msg, plaintext: text } as DecryptedMessage,
...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 }); : JSON.stringify({ v: 1, text: trimmed, attachments: handles });
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev; if (prev.messages.some((m) => m.id === msg.id)) return prev;
return { const next = [
...prev, ...prev.messages,
messages: [ {
...prev.messages, ...msg,
{ plaintext: attachmentsPayload,
...msg, } as DecryptedMessage,
plaintext: attachmentsPayload, ];
} as DecryptedMessage, setCachedMessages(conversationId, next);
], return { ...prev, messages: next };
};
}); });
// 4. Insert public attachment metadata rows pointing at the new message. // 4. Insert public attachment metadata rows pointing at the new message.