SkateSpots/src/lib/dmDecryptCache.ts
SkateSpots 10299d07f2 perf(dm): persist the decrypt cache so each message decrypts once, ever
The last Amber pain: a fresh launch re-decrypted the whole inbox (2 signer ops
per wrap). Decrypted messages are now persisted to IndexedDB
(src/lib/dmDecryptCache.ts, keyed viewerPk:wrapId, bounded 4000/account, pruned
oldest-first) and hydrated once per session before the first lookup, so a wrap is
decrypted at most once ever. Cleared per-account on logout (plaintext at rest
otherwise readable by another account on the device).
2026-10-13 18:58:35 +07:00

92 lines
3.2 KiB
TypeScript

/**
* Persisted DM decrypt cache.
* ─────────────────────────────────────────────────────────────────────────────
* NIP-17 reading is expensive with an external signer: every gift wrap costs two
* signer operations (decrypt the seal, decrypt the rumor). The in-memory cache in
* `useDirectMessages` coalesces this within a session, but a fresh launch had to
* decrypt the whole inbox again — the last remaining Amber "pop-up storm".
*
* This module persists decrypted messages to IndexedDB so a message is decrypted
* **once, ever**. Keyed `viewerPk:wrapId`; bounded per account; pruned oldest-first.
*
* ⚠️ Privacy: this stores the **plaintext** of your DMs (sender, body, recipients)
* at rest in this origin's IndexedDB — the same content you already see on screen.
* It is device-local, never published, and scoped to the logged-in pubkey. Use
* {@link clearDmDecryptCache} to wipe it (e.g. from a future "clear cache" action).
*/
import Dexie, { type Table } from 'dexie';
export interface CachedDm {
id: string;
pubkey: string;
content: string;
createdAt: number;
/** The rumor's `p` tags (raw recipients). */
recipients: string[];
/** Sender + recipients, deduped (the group identity). */
participants: string[];
subject?: string;
replyTo?: string;
}
interface DmRow {
/** `${viewerPk}:${wrapId}` — unique per recipient, since decryption is per-key. */
key: string;
viewerPk: string;
cachedAt: number;
dm: CachedDm;
}
class SkateSpotsDmCacheDB extends Dexie {
dm!: Table<DmRow, string>;
constructor() {
super('SkateSpotsDmCacheDB');
this.version(1).stores({
dm: 'key, viewerPk, cachedAt',
});
}
}
let db: SkateSpotsDmCacheDB | null = null;
function getDB(): SkateSpotsDmCacheDB {
db ??= new SkateSpotsDmCacheDB();
return db;
}
/** Load this account's cached messages, newest first, capped at `limit`. */
export async function loadDmDecryptCache(
viewerPk: string,
limit: number,
): Promise<Array<[string, CachedDm]>> {
const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray();
rows.sort((a, b) => b.cachedAt - a.cachedAt);
return rows.slice(0, limit).map((r) => [r.key, r.dm]);
}
/** Persist one decrypted message (fire-and-forget friendly). */
export async function saveDmDecryptKey(
key: string,
viewerPk: string,
dm: CachedDm,
): Promise<void> {
await getDB().dm.put({ key, viewerPk, cachedAt: Date.now(), dm });
}
/** Keep at most `max` rows for this account (oldest dropped first). */
export async function pruneDmDecryptCache(viewerPk: string, max: number): Promise<void> {
const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray();
if (rows.length <= max) return;
rows.sort((a, b) => a.cachedAt - b.cachedAt);
await getDB().dm.bulkDelete(rows.slice(0, rows.length - max).map((r) => r.key));
}
/** Wipe the cache for one account, or all accounts when omitted. */
export async function clearDmDecryptCache(viewerPk?: string): Promise<void> {
if (!viewerPk) {
await getDB().dm.clear();
return;
}
await getDB().dm.where('viewerPk').equals(viewerPk).delete();
}