diff --git a/apps/desktop/src/lib/messageMemoryCache.test.ts b/apps/desktop/src/lib/messageMemoryCache.test.ts new file mode 100644 index 0000000..e7c4b58 --- /dev/null +++ b/apps/desktop/src/lib/messageMemoryCache.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { DecryptedMessage } from '@chat-app/shared/chat'; + +import { + __resetForTests, + getCachedMessages, + hasCachedMessages, + setCachedMessages, +} from './messageMemoryCache'; + +function msg(id: string): DecryptedMessage { + return { + id, + conversationId: 'conv-1', + senderId: 'sender-1', + senderDeviceId: null, + replyToId: null, + editedAt: null, + deletedAt: null, + createdAt: '2026-05-17T00:00:00Z', + plaintext: 'hi ' + id, + }; +} + +describe('messageMemoryCache', () => { + afterEach(() => { + __resetForTests(); + }); + + it('returns empty array when nothing is cached', () => { + expect(getCachedMessages('unknown')).toEqual([]); + expect(hasCachedMessages('unknown')).toBe(false); + }); + + it('stores and returns messages per conversation', () => { + setCachedMessages('a', [msg('m1'), msg('m2')]); + expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']); + expect(hasCachedMessages('a')).toBe(true); + }); + + it('isolates conversations', () => { + setCachedMessages('a', [msg('m1')]); + setCachedMessages('b', [msg('m9')]); + expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']); + expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']); + }); + + it('overwrites prior cache when set again', () => { + setCachedMessages('a', [msg('m1')]); + setCachedMessages('a', [msg('m1'), msg('m2')]); + expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']); + }); + + it('treats an explicit empty list as "cached"', () => { + // A conversation that genuinely has zero messages should still be + // flagged as cached so the hook skips the loading spinner on re-entry. + setCachedMessages('a', []); + expect(hasCachedMessages('a')).toBe(true); + expect(getCachedMessages('a')).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/messageMemoryCache.ts b/apps/desktop/src/lib/messageMemoryCache.ts new file mode 100644 index 0000000..e17854d --- /dev/null +++ b/apps/desktop/src/lib/messageMemoryCache.ts @@ -0,0 +1,37 @@ +// In-memory cache of the most-recently-rendered messages for each +// conversation. Survives React component unmount/remount (used by +// `useConversationMessages` to initialize state synchronously when +// ConversationPage is remounted on chat switch). Session-scoped — lost +// on full app reload. The SQLite cache (`messageCache.ts`) is still the +// source of truth for cross-session persistence; this layer just shaves +// off the round-trip-to-disk spinner flash. +// +// Two-tier semantics: +// * `hasCachedMessages(id)` returns true even for a known-empty chat +// so the hook can suppress the loading spinner on re-entry. +// * `getCachedMessages(id)` returns a defensive copy so callers can't +// mutate the cached array. + +import type { DecryptedMessage } from '@chat-app/shared/chat'; + +const cache = new Map(); + +export function getCachedMessages(conversationId: string): DecryptedMessage[] { + const stored = cache.get(conversationId); + return stored ? stored.slice() : []; +} + +export function hasCachedMessages(conversationId: string): boolean { + return cache.has(conversationId); +} + +export function setCachedMessages( + conversationId: string, + messages: DecryptedMessage[], +): void { + cache.set(conversationId, messages.slice()); +} + +export function __resetForTests(): void { + cache.clear(); +}