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).
This commit is contained in:
parent
707dcaf644
commit
10299d07f2
4 changed files with 148 additions and 15 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -543,8 +543,14 @@ session with **no other memory of this project**. So:
|
|||
`lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their
|
||||
headers) — re-wiring is a deliberate decision, not a one-liner, because it
|
||||
split chat history. Group reads widened to **500** wraps (1:1 stays 200).
|
||||
(Still not done: persisting the decrypt cache across app restarts, so the first
|
||||
load per launch still decrypts everything.)
|
||||
**Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie
|
||||
`SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned
|
||||
oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is
|
||||
decrypted **once, ever**, killing the first-load-per-launch Amber storm.
|
||||
⚠️ It stores **plaintext DMs at rest** in IndexedDB (device-local, never
|
||||
published) — the trade-off for not re-prompting; it is cleared on **logout**
|
||||
(`AccountSwitcher`) so another account on the device can't read it.
|
||||
`clearDmDecryptCache()` is exported for a future "clear cache" action.
|
||||
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
|
||||
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
|
||||
does **not** use the ContextVM server. AI goes through our own **server-side
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu.tsx';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
||||
import { WalletModal } from '@/components/WalletModal';
|
||||
import { clearDmDecryptCache } from '@/lib/dmDecryptCache';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
|
@ -97,7 +98,12 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
|||
<span>{t('settingsUi.accountSwitcher.addAccount')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeLogin(currentUser.id)}
|
||||
onClick={() => {
|
||||
// Drop this account's plaintext DM cache (device-local) on logout so
|
||||
// another account on the same device can't read it from IndexedDB.
|
||||
void clearDmDecryptCache(currentUser.id).catch(() => {});
|
||||
removeLogin(currentUser.id);
|
||||
}}
|
||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
|
||||
>
|
||||
<LogOut className='w-4 h-4' />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabil
|
|||
import { useRelays } from '@/lib/relays';
|
||||
import { SKATE_RELAY } from '@/lib/types';
|
||||
import type { RelayMetadata } from '@/contexts/AppContext';
|
||||
import {
|
||||
loadDmDecryptCache,
|
||||
saveDmDecryptKey,
|
||||
pruneDmDecryptCache,
|
||||
type CachedDm,
|
||||
} from '@/lib/dmDecryptCache';
|
||||
|
||||
/**
|
||||
* Read relays for DMs: the user's NIP-65 read relays plus their own NIP-17
|
||||
|
|
@ -220,18 +226,7 @@ function createRumor(
|
|||
}
|
||||
|
||||
/** A decrypted NIP-17 message (the fields every reader needs). */
|
||||
interface DecryptedDm {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
/** The rumor's `p` tags (raw recipients, sender NOT necessarily included). */
|
||||
recipients: string[];
|
||||
/** Sender + recipients, deduped (the group identity). */
|
||||
participants: string[];
|
||||
subject?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
type DecryptedDm = CachedDm;
|
||||
|
||||
/**
|
||||
* Session cache of decrypted gift wraps, keyed by `viewerPk:wrapId`.
|
||||
|
|
@ -250,6 +245,35 @@ const DM_DECRYPT_CACHE_MAX = 4000;
|
|||
const dmDecryptCache = new Map<string, DecryptedDm>();
|
||||
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
|
||||
|
||||
// Which accounts' persisted caches have been loaded into memory this session,
|
||||
// and any hydration in flight (so concurrent readers await the same load).
|
||||
const dmCacheHydrated = new Set<string>();
|
||||
const dmCacheHydrating = new Map<string, Promise<void>>();
|
||||
|
||||
/** Load an account's persisted decrypt cache into memory (once per session). */
|
||||
async function ensureDmCacheHydrated(viewerPk: string): Promise<void> {
|
||||
if (dmCacheHydrated.has(viewerPk)) return;
|
||||
const flying = dmCacheHydrating.get(viewerPk);
|
||||
if (flying) return flying;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const rows = await loadDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX);
|
||||
for (const [k, dm] of rows) {
|
||||
if (!dmDecryptCache.has(k)) dmDecryptCache.set(k, dm);
|
||||
}
|
||||
dmCacheHydrated.add(viewerPk);
|
||||
// Keep the on-disk set bounded (oldest dropped first).
|
||||
void pruneDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX).catch(() => {});
|
||||
} catch {
|
||||
// A cache read failure just means we decrypt again — never fatal.
|
||||
} finally {
|
||||
dmCacheHydrating.delete(viewerPk);
|
||||
}
|
||||
})();
|
||||
dmCacheHydrating.set(viewerPk, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** The signer surface DM reading needs (NIP-44 decrypt). */
|
||||
interface DmSigner {
|
||||
nip44?: {
|
||||
|
|
@ -263,6 +287,9 @@ async function decryptGiftWrap(
|
|||
wrap: NostrEvent,
|
||||
): Promise<DecryptedDm | null> {
|
||||
const key = `${viewerPk}:${wrap.id}`;
|
||||
// Hydrate the persisted cache once per account before the first lookup, so a
|
||||
// relaunch reuses decryptions that already happened on this device.
|
||||
await ensureDmCacheHydrated(viewerPk);
|
||||
const cached = dmDecryptCache.get(key);
|
||||
if (cached) return cached;
|
||||
const flying = dmDecryptInFlight.get(key);
|
||||
|
|
@ -298,6 +325,8 @@ async function decryptGiftWrap(
|
|||
if (oldest !== undefined) dmDecryptCache.delete(oldest);
|
||||
}
|
||||
dmDecryptCache.set(key, dm);
|
||||
// Persist so this wrap is never decrypted again (fire-and-forget).
|
||||
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
|
||||
return dm;
|
||||
} catch {
|
||||
// Not cached — a transient failure may succeed on the next attempt.
|
||||
|
|
|
|||
92
src/lib/dmDecryptCache.ts
Normal file
92
src/lib/dmDecryptCache.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
Loading…
Reference in a new issue