diff --git a/AGENTS.md b/AGENTS.md
index b7f73e3..31486f1 100644
--- a/AGENTS.md
+++ b/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
diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx
index f7d0970..956ccb6 100644
--- a/src/components/auth/AccountSwitcher.tsx
+++ b/src/components/auth/AccountSwitcher.tsx
@@ -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) {
{t('settingsUi.accountSwitcher.addAccount')}
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'
>
diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts
index c0a744c..4f9ae6e 100644
--- a/src/hooks/useDirectMessages.ts
+++ b/src/hooks/useDirectMessages.ts
@@ -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();
const dmDecryptInFlight = new Map>();
+// 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();
+const dmCacheHydrating = new Map>();
+
+/** Load an account's persisted decrypt cache into memory (once per session). */
+async function ensureDmCacheHydrated(viewerPk: string): Promise {
+ 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 {
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.
diff --git a/src/lib/dmDecryptCache.ts b/src/lib/dmDecryptCache.ts
new file mode 100644
index 0000000..61f4ce1
--- /dev/null
+++ b/src/lib/dmDecryptCache.ts
@@ -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;
+
+ 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();
+}