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 { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
@@ -73,6 +74,10 @@ function ConversationRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrateDrafts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary scope="root">
|
<ErrorBoundary scope="root">
|
||||||
<ThemeProvider>
|
<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 { usePeerPresence } from '../lib/usePeerPresence';
|
||||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||||
|
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
|
||||||
|
|
||||||
// Discriminated union for rows inside the virtualized message list. Keeping
|
// Discriminated union for rows inside the virtualized message list. Keeping
|
||||||
// pending bubbles and the "load older" tile inside the same Virtuoso
|
// pending bubbles and the "load older" tile inside the same Virtuoso
|
||||||
@@ -200,7 +201,10 @@ export function ConversationPage() {
|
|||||||
});
|
});
|
||||||
}, [id, messages, myId]);
|
}, [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 [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
@@ -304,6 +308,21 @@ export function ConversationPage() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(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(() => {
|
useEffect(() => {
|
||||||
if (firstUnreadComputedRef.current) return;
|
if (firstUnreadComputedRef.current) return;
|
||||||
if (!id || messages.length === 0) return;
|
if (!id || messages.length === 0) return;
|
||||||
@@ -753,6 +772,7 @@ export function ConversationPage() {
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
if (id) clearDraft(id);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
setSendError(
|
setSendError(
|
||||||
|
|||||||
Reference in New Issue
Block a user