From 82600915f1d6f9dc766343cfc388358dc02eb2f3 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 16:59:34 +0200 Subject: [PATCH] feat(composer): persist text + reply target per chat across restarts --- apps/desktop/src/App.tsx | 7 +- .../src/lib/composerDraftStore.test.ts | 79 +++++++++ apps/desktop/src/lib/composerDraftStore.ts | 160 ++++++++++++++++++ apps/desktop/src/lib/messageCache.ts | Bin 7760 -> 8015 bytes apps/desktop/src/pages/ConversationPage.tsx | 22 ++- 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/composerDraftStore.test.ts create mode 100644 apps/desktop/src/lib/composerDraftStore.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4dea5d3..b4a8b2a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom'; import { AppShell } from './components/AppShell'; @@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext'; import { ConversationsProvider } from './context/ConversationsContext'; import { FriendshipsProvider } from './context/FriendshipsContext'; import { ThemeProvider } from './context/ThemeContext'; +import { hydrateDrafts } from './lib/composerDraftStore'; import { AuthPage } from './pages/AuthPage'; import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage'; import { ConversationPage } from './pages/ConversationPage'; @@ -73,6 +74,10 @@ function ConversationRoute() { } export function App() { + useEffect(() => { + void hydrateDrafts(); + }, []); + return ( diff --git a/apps/desktop/src/lib/composerDraftStore.test.ts b/apps/desktop/src/lib/composerDraftStore.test.ts new file mode 100644 index 0000000..9c8300e --- /dev/null +++ b/apps/desktop/src/lib/composerDraftStore.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetForTests, + clearDraft, + getDraftSync, + hasDraft, + hydrateDrafts, + setDraft, +} from './composerDraftStore'; + +const sqlExecuteMock = vi.fn().mockResolvedValue(undefined); +const sqlSelectMock = vi.fn().mockResolvedValue([]); +const sqlLoadMock = vi.fn().mockResolvedValue('mock-handle'); + +vi.stubGlobal('window', { + electronAPI: { + platform: 'electron-chatapp-v1', + sqlLoad: sqlLoadMock, + sqlExecute: sqlExecuteMock, + sqlSelect: sqlSelectMock, + }, +}); + +describe('composerDraftStore', () => { + beforeEach(() => { + sqlExecuteMock.mockClear(); + sqlSelectMock.mockClear(); + sqlLoadMock.mockClear(); + __resetForTests(); + }); + + afterEach(() => { + __resetForTests(); + }); + + it('returns null for an unknown conversation', () => { + expect(getDraftSync('unknown')).toBeNull(); + expect(hasDraft('unknown')).toBe(false); + }); + + it('stores and returns a draft synchronously after set', () => { + setDraft('a', { text: 'hi', replyToId: null }); + const draft = getDraftSync('a'); + expect(draft).not.toBeNull(); + expect(draft?.text).toBe('hi'); + expect(draft?.replyToId).toBeNull(); + expect(hasDraft('a')).toBe(true); + }); + + it('isolates drafts per conversation', () => { + setDraft('a', { text: 'one', replyToId: null }); + setDraft('b', { text: 'two', replyToId: 'msg-9' }); + expect(getDraftSync('a')?.text).toBe('one'); + expect(getDraftSync('b')?.replyToId).toBe('msg-9'); + }); + + it('clearDraft removes the draft from memory', () => { + setDraft('a', { text: 'one', replyToId: null }); + clearDraft('a'); + expect(getDraftSync('a')).toBeNull(); + expect(hasDraft('a')).toBe(false); + }); + + it('treats an empty-string text + null reply as "no draft"', () => { + setDraft('a', { text: '', replyToId: null }); + expect(getDraftSync('a')).toBeNull(); + expect(hasDraft('a')).toBe(false); + }); + + it('hydrateDrafts populates the in-memory map from SQLite rows', async () => { + sqlSelectMock.mockResolvedValueOnce([ + { conversation_id: 'a', text: 'persisted', reply_to_id: 'msg-1', updated_at: '2026-05-17T00:00:00Z' }, + ]); + await hydrateDrafts(); + expect(getDraftSync('a')?.text).toBe('persisted'); + expect(getDraftSync('a')?.replyToId).toBe('msg-1'); + }); +}); diff --git a/apps/desktop/src/lib/composerDraftStore.ts b/apps/desktop/src/lib/composerDraftStore.ts new file mode 100644 index 0000000..78d6bf6 --- /dev/null +++ b/apps/desktop/src/lib/composerDraftStore.ts @@ -0,0 +1,160 @@ +// Composer-draft persistence. Two-tier semantics: +// * In-memory `Map` for instant synchronous reads on +// mount (mirrors the `messageMemoryCache` pattern from Phase 7). +// * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for +// cross-restart persistence. Writes are debounced and fire-and-forget +// — losing the last 400ms of typing on a hard crash is acceptable; +// blocking the keystroke handler is not. +// +// Attachments are intentionally NOT serialized: +// * Files don't round-trip through SQLite cleanly (binary blobs blow +// up the cache size). +// * `replyToId` IS persisted; the consuming page looks up the actual +// message by id at render time. + +import { isTauriRuntime } from './globalShortcut'; + +const DB_NAME = 'chatapp-cache'; +const WRITE_DEBOUNCE_MS = 400; + +interface Draft { + text: string; + replyToId: string | null; +} + +interface DraftRow { + conversation_id: string; + text: string; + reply_to_id: string | null; + updated_at: string; +} + +const drafts = new Map(); +const pendingWrites = new Map>(); +let handlePromise: Promise | null = null; + +async function getHandle(): Promise { + if (handlePromise) return handlePromise; + if (!isTauriRuntime()) { + handlePromise = Promise.resolve(null); + return handlePromise; + } + handlePromise = (async () => { + try { + const handle = await window.electronAPI.sqlLoad({ name: DB_NAME }); + // Self-contained DDL — the same statement also runs from + // `messageCache.ts`'s init path, but we don't want to depend on + // call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent + // so the double-creation is safe. + await window.electronAPI.sqlExecute({ + handle, + query: + `CREATE TABLE IF NOT EXISTS composer_drafts ( + conversation_id TEXT PRIMARY KEY, + text TEXT NOT NULL, + reply_to_id TEXT, + updated_at TEXT NOT NULL + )`, + bindings: [], + }); + return handle; + } catch (err: unknown) { + console.warn('composerDraftStore: sqlLoad failed', err); + return null; + } + })(); + return handlePromise; +} + +export function getDraftSync(conversationId: string): Draft | null { + const stored = drafts.get(conversationId); + if (!stored) return null; + return { text: stored.text, replyToId: stored.replyToId }; +} + +export function hasDraft(conversationId: string): boolean { + return drafts.has(conversationId); +} + +export function setDraft(conversationId: string, draft: Draft): void { + if (draft.text.length === 0 && draft.replyToId === null) { + if (drafts.has(conversationId)) { + drafts.delete(conversationId); + scheduleWrite(conversationId); + } + return; + } + drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId }); + scheduleWrite(conversationId); +} + +export function clearDraft(conversationId: string): void { + if (!drafts.has(conversationId)) return; + drafts.delete(conversationId); + scheduleWrite(conversationId); +} + +function scheduleWrite(conversationId: string): void { + const existing = pendingWrites.get(conversationId); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + pendingWrites.delete(conversationId); + void flushOne(conversationId); + }, WRITE_DEBOUNCE_MS); + pendingWrites.set(conversationId, timer); +} + +async function flushOne(conversationId: string): Promise { + const handle = await getHandle(); + if (!handle) return; + const draft = drafts.get(conversationId); + try { + if (draft) { + await window.electronAPI.sqlExecute({ + handle, + query: + `INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(conversation_id) DO UPDATE SET + text = excluded.text, + reply_to_id = excluded.reply_to_id, + updated_at = excluded.updated_at`, + bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()], + }); + } else { + await window.electronAPI.sqlExecute({ + handle, + query: 'DELETE FROM composer_drafts WHERE conversation_id = $1', + bindings: [conversationId], + }); + } + } catch (err: unknown) { + console.warn('composerDraftStore: flush failed', err); + } +} + +export async function hydrateDrafts(): Promise { + const handle = await getHandle(); + if (!handle) return; + try { + const rows = (await window.electronAPI.sqlSelect({ + handle, + query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts', + bindings: [], + })) as unknown as DraftRow[]; + for (const r of rows) { + if (!r.conversation_id || typeof r.text !== 'string') continue; + if (r.text.length === 0 && r.reply_to_id === null) continue; + drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id }); + } + } catch (err: unknown) { + console.warn('composerDraftStore: hydrate failed', err); + } +} + +export function __resetForTests(): void { + for (const t of pendingWrites.values()) clearTimeout(t); + pendingWrites.clear(); + drafts.clear(); + handlePromise = null; +} diff --git a/apps/desktop/src/lib/messageCache.ts b/apps/desktop/src/lib/messageCache.ts index b16b46fbe15d28017e9b102f3ab85d99432b0fbe..5a3fe417ff4a2f753b296cdc611eda0142ad565f 100644 GIT binary patch delta 103 zcmca$bKY*la@NUV9O9GbFzbjW=jRsW7pE4*rxYcol@u#za49G#OkN-$!WcjK9(() => { + if (!id) return ''; + return getDraftSync(id)?.text ?? ''; + }); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [stickToBottom, setStickToBottom] = useState(true); @@ -304,6 +308,21 @@ export function ConversationPage() { const fileInputRef = useRef(null); const composerRef = useRef(null); + useEffect(() => { + if (!id) return; + const draft = getDraftSync(id); + const savedReplyToId = draft?.replyToId ?? null; + if (!savedReplyToId) return; + if (replyTo?.id === savedReplyToId) return; + const match = messages.find((m) => m.id === savedReplyToId); + if (match) setReplyTo(match); + }, [id, messages, replyTo?.id]); + + useEffect(() => { + if (!id) return; + setDraft(id, { text, replyToId: replyTo?.id ?? null }); + }, [id, text, replyTo?.id]); + useEffect(() => { if (firstUnreadComputedRef.current) return; if (!id || messages.length === 0) return; @@ -753,6 +772,7 @@ export function ConversationPage() { if (fileInputRef.current) fileInputRef.current.value = ''; setStickToBottom(true); notifyStopTyping(); + if (id) clearDraft(id); } catch (err: unknown) { const code = extractErrorCode(err); setSendError(