feat(composer): persist text + reply target per chat across restarts
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user