/** * 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; 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> { 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 { 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 { 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 { if (!viewerPk) { await getDB().dm.clear(); return; } await getDB().dm.where('viewerPk').equals(viewerPk).delete(); }