feat(composer): persist text + reply target per chat across restarts

This commit is contained in:
byGalax
2026-05-17 16:59:34 +02:00
parent 93098a74ca
commit 82600915f1
5 changed files with 266 additions and 2 deletions
+6 -1
View File
@@ -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 (
<ErrorBoundary scope="root">
<ThemeProvider>
@@ -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');
});
});
+160
View File
@@ -0,0 +1,160 @@
// Composer-draft persistence. Two-tier semantics:
// * In-memory `Map<convId, Draft>` 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<string, Draft>();
const pendingWrites = new Map<string, ReturnType<typeof setTimeout>>();
let handlePromise: Promise<string | null> | null = null;
async function getHandle(): Promise<string | null> {
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<void> {
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<void> {
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;
}
Binary file not shown.
+21 -1
View File
@@ -78,6 +78,7 @@ import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useM
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
// Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso
@@ -200,7 +201,10 @@ export function ConversationPage() {
});
}, [id, messages, myId]);
const [text, setText] = useState('');
const [text, setText] = useState<string>(() => {
if (!id) return '';
return getDraftSync(id)?.text ?? '';
});
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
@@ -304,6 +308,21 @@ export function ConversationPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(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(