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).
92 lines
3.2 KiB
TypeScript
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();
|
|
}
|