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,
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user