1303c8e26f
- backup/restore dialog + user profile popover components - image compression, video blur, wake lock utilities - message cache + conversation messages hook refinements - call context, active speakers, screen share dialog tweaks - audio + screen share settings persistence - refreshed app icons (smaller sizes) across all platforms
269 lines
8.8 KiB
TypeScript
269 lines
8.8 KiB
TypeScript
// Local SQLite message cache. Persists decrypted messages so startup +
|
|
// conversation-switch hydrate from disk instantly instead of waiting on a
|
|
// network round-trip + batch decrypt.
|
|
//
|
|
// The cache is per-device-local (app-local-data dir, same trust boundary as
|
|
// the device private key). Plaintext is stored because the threat model
|
|
// already assumes local-disk access means compromise — same as the existing
|
|
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
|
|
// key-rotation migration can re-derive plaintext when a new bundle arrives.
|
|
|
|
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
|
|
|
import { isTauriRuntime } from './globalShortcut';
|
|
|
|
interface Database {
|
|
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
|
|
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
|
|
close: () => Promise<boolean>;
|
|
}
|
|
|
|
interface DatabaseStatic {
|
|
load: (path: string) => Promise<Database>;
|
|
}
|
|
|
|
const DB_NAME = 'chatapp-cache.db';
|
|
|
|
let dbPromise: Promise<Database | null> | null = null;
|
|
|
|
async function loadDb(): Promise<Database | null> {
|
|
if (!isTauriRuntime()) return null;
|
|
try {
|
|
// Dynamic import so browser-preview builds don't choke on the tauri
|
|
// plugin module. Vite will statically analyse this + split it into a
|
|
// chunk that only loads inside Tauri.
|
|
const mod = (await import('@tauri-apps/plugin-sql')) as {
|
|
default: DatabaseStatic;
|
|
Database?: DatabaseStatic;
|
|
};
|
|
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
|
|
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
|
|
await db.execute(`
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id TEXT PRIMARY KEY,
|
|
conversation_id TEXT NOT NULL,
|
|
sender_id TEXT NOT NULL,
|
|
sender_device_id TEXT,
|
|
reply_to_id TEXT,
|
|
edited_at TEXT,
|
|
deleted_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
plaintext TEXT
|
|
);
|
|
`);
|
|
await db.execute(
|
|
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
|
|
);
|
|
// FTS5 virtual table for instant full-text search across the cache.
|
|
// Keeps only the searchable text columns (plaintext + sender), keyed
|
|
// by the message id so we can join back to the main row. Triggers
|
|
// mirror inserts/updates/deletes so the index never drifts.
|
|
//
|
|
// Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps
|
|
// the call idempotent, and the surrounding try/catch already handles
|
|
// a CREATE failure by skipping the whole cache.
|
|
try {
|
|
await db.execute(
|
|
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
message_id UNINDEXED,
|
|
conversation_id UNINDEXED,
|
|
plaintext,
|
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
);`,
|
|
);
|
|
await db.execute(
|
|
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
|
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
|
END;`,
|
|
);
|
|
await db.execute(
|
|
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
DELETE FROM messages_fts WHERE message_id = old.id;
|
|
END;`,
|
|
);
|
|
await db.execute(
|
|
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
DELETE FROM messages_fts WHERE message_id = old.id;
|
|
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
|
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
|
END;`,
|
|
);
|
|
} catch (err: unknown) {
|
|
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
|
|
}
|
|
return db;
|
|
} catch (err: unknown) {
|
|
console.warn('messageCache init failed — falling back to memory-only', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getDb(): Promise<Database | null> {
|
|
if (!dbPromise) dbPromise = loadDb();
|
|
return dbPromise;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API — all calls are no-ops when the cache isn't available (browser
|
|
// preview, init error, etc.), so callers never need to guard.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Row {
|
|
id: string;
|
|
conversation_id: string;
|
|
sender_id: string;
|
|
sender_device_id: string | null;
|
|
reply_to_id: string | null;
|
|
edited_at: string | null;
|
|
deleted_at: string | null;
|
|
created_at: string;
|
|
plaintext: string | null;
|
|
}
|
|
|
|
export async function loadCachedMessages(
|
|
conversationId: string,
|
|
limit = 500,
|
|
): Promise<DecryptedMessage[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
try {
|
|
const rows = await db.select<Row>(
|
|
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
|
|
[conversationId, limit],
|
|
);
|
|
return rows
|
|
.reverse()
|
|
.map((r) => ({
|
|
id: r.id,
|
|
conversationId: r.conversation_id,
|
|
senderId: r.sender_id,
|
|
senderDeviceId: r.sender_device_id,
|
|
replyToId: r.reply_to_id,
|
|
editedAt: r.edited_at,
|
|
deletedAt: r.deleted_at,
|
|
createdAt: r.created_at,
|
|
plaintext: r.plaintext,
|
|
}));
|
|
} catch (err: unknown) {
|
|
console.warn('loadCachedMessages failed', err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function persistMessages(
|
|
conversationId: string,
|
|
messages: DecryptedMessage[],
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
if (!db || messages.length === 0) return;
|
|
try {
|
|
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
|
|
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
|
|
// nothing lives outside this table.
|
|
for (const m of messages) {
|
|
await db.execute(
|
|
`INSERT OR REPLACE INTO messages
|
|
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
|
|
edited_at, deleted_at, created_at, plaintext)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
[
|
|
m.id,
|
|
conversationId,
|
|
m.senderId,
|
|
m.senderDeviceId,
|
|
m.replyToId,
|
|
m.editedAt,
|
|
m.deletedAt,
|
|
m.createdAt,
|
|
m.plaintext,
|
|
],
|
|
);
|
|
}
|
|
} catch (err: unknown) {
|
|
console.warn('persistMessages failed', err);
|
|
}
|
|
}
|
|
|
|
export async function deleteCachedMessage(id: string): Promise<void> {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
try {
|
|
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
|
|
} catch (err: unknown) {
|
|
console.warn('deleteCachedMessage failed', err);
|
|
}
|
|
}
|
|
|
|
// Full-text search across cached messages. Returns rows in newest-first
|
|
// order; caller maps to DecryptedMessage. Empty result on cache-miss or
|
|
// FTS5 not available — caller should fall back to in-memory regex.
|
|
export async function searchCachedMessages(
|
|
conversationId: string,
|
|
query: string,
|
|
limit = 200,
|
|
): Promise<DecryptedMessage[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
const trimmed = query.trim();
|
|
if (trimmed.length === 0) return [];
|
|
// Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in
|
|
// an OR over each token with a trailing wildcard so partial words match.
|
|
// Hyphens + colons are FTS5 operators so we drop them.
|
|
const tokens = trimmed
|
|
.replace(/["'\u0000-\u001f]/g, ' ')
|
|
.replace(/[-:^]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
if (tokens.length === 0) return [];
|
|
const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND ');
|
|
try {
|
|
const rows = await db.select<Row>(
|
|
`SELECT m.* FROM messages m
|
|
JOIN messages_fts f ON f.message_id = m.id
|
|
WHERE f.conversation_id = $1 AND messages_fts MATCH $2
|
|
ORDER BY m.created_at DESC
|
|
LIMIT $3`,
|
|
[conversationId, matchExpr, limit],
|
|
);
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
conversationId: r.conversation_id,
|
|
senderId: r.sender_id,
|
|
senderDeviceId: r.sender_device_id,
|
|
replyToId: r.reply_to_id,
|
|
editedAt: r.edited_at,
|
|
deletedAt: r.deleted_at,
|
|
createdAt: r.created_at,
|
|
plaintext: r.plaintext,
|
|
}));
|
|
} catch (err: unknown) {
|
|
console.warn('searchCachedMessages failed', err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Housekeeping — run once per session to bound the cache size. Keeps the
|
|
// latest KEEP_PER_CONV messages per conversation.
|
|
const KEEP_PER_CONV = 1000;
|
|
|
|
export async function pruneCache(): Promise<void> {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
try {
|
|
await db.execute(
|
|
`DELETE FROM messages WHERE id IN (
|
|
SELECT id FROM messages m
|
|
WHERE (
|
|
SELECT COUNT(*) FROM messages m2
|
|
WHERE m2.conversation_id = m.conversation_id
|
|
AND m2.created_at > m.created_at
|
|
) >= $1
|
|
)`,
|
|
[KEEP_PER_CONV],
|
|
);
|
|
} catch (err: unknown) {
|
|
console.warn('pruneCache failed', err);
|
|
}
|
|
}
|