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,
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<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[]>(() =>
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<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
const handleDelete = useCallback(
(row: Record<string, unknown>) => {
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.