feat(chat-switch): hydrate useConversationMessages from in-memory cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user