161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
// 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;
|
|
}
|