diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index d0007d26..0df14c03 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -11,6 +11,7 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; +import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import watchHistoryService from "../watchHistoryService.js"; import { FEED_TYPES } from "../constants.js"; @@ -420,11 +421,9 @@ export function createAuthSessionCoordinator(deps) { const parallelListTasks = []; - // Eagerly warm watch history in the background on login (NOT awaited / - // not in parallelListTasks — the feed must never wait for it). loadLatest - // schedules the background refresh, which emits "fingerprint" so the For - // You feed re-runs (see feedCoordinator) to suppress watched videos. - // Without this it only loaded when For You or the history tab was opened. + // Eagerly warm watch history in the background on login (NOT awaited). + // loadLatest schedules the refresh, which emits "fingerprint" so the For + // You feed re-runs (feedCoordinator) to suppress watched videos. if ( activePubkey && watchHistoryService && @@ -904,6 +903,7 @@ export function createAuthSessionCoordinator(deps) { this.resetPermissionPromptState(); clearDecryptionSchemeCache(); clearWatchHistoryConversationKeyCache(); + clearWatchHistoryDecryptedChunkCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 919f058a..b1680326 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -33,6 +33,11 @@ import { } from "../utils/pointerNormalization.js"; import { pMap } from "../utils/asyncUtils.js"; import { selectNewestListEvent } from "./listEventOrdering.js"; +import { + getCachedChunkPlaintext, + setCachedChunkPlaintext, + flushDecryptedChunkCache, +} from "./watchHistoryDecryptCache.js"; export { normalizePointerInput, pointerKey }; @@ -2042,6 +2047,11 @@ class WatchHistoryManager { return []; } let plaintext = looksLikeJsonStructure(ciphertext) ? ciphertext : ""; + // Reuse a previously-decrypted plaintext for this immutable chunk + // event id instead of round-tripping to the nip-07 extension again. + if (!plaintext) { + plaintext = getCachedChunkPlaintext(event.id) || ""; + } if (!plaintext) { plaintext = await decryptWatchHistoryCiphertext( { @@ -2054,6 +2064,9 @@ class WatchHistoryManager { }, toolkitCacheRef, ); + if (plaintext) { + setCachedChunkPlaintext(event.id, plaintext); + } } if (!plaintext) { return []; @@ -2066,6 +2079,7 @@ class WatchHistoryManager { }, { concurrency: 5 }, ); + flushDecryptedChunkCache(); decryptedItems.push(...chunkResults.flat()); } catch (error) { devLogger.warn("[nostr] Failed to fetch watch history chunks:", error); @@ -2091,17 +2105,24 @@ class WatchHistoryManager { let payloadContent = ciphertext; if (!looksLikeJsonStructure(payloadContent) && canAttemptDecrypt) { - const decrypted = await decryptWatchHistoryCiphertext( - { - event, - ciphertext: payloadContent, - actorKey, - decryptSigner, - sessionPrivateKey, - deps: this.deps, - }, - toolkitCacheRef, - ); + let decrypted = getCachedChunkPlaintext(event.id) || ""; + if (!decrypted) { + decrypted = await decryptWatchHistoryCiphertext( + { + event, + ciphertext: payloadContent, + actorKey, + decryptSigner, + sessionPrivateKey, + deps: this.deps, + }, + toolkitCacheRef, + ); + if (decrypted) { + setCachedChunkPlaintext(event.id, decrypted); + flushDecryptedChunkCache(); + } + } if (decrypted) { payloadContent = decrypted; } diff --git a/js/nostr/watchHistoryDecryptCache.js b/js/nostr/watchHistoryDecryptCache.js new file mode 100644 index 00000000..0c8b450a --- /dev/null +++ b/js/nostr/watchHistoryDecryptCache.js @@ -0,0 +1,95 @@ +// Persisted cache of decrypted watch-history chunk plaintext, keyed by the +// immutable chunk event id. +// +// Watch history is stored as ~150 separately encrypted chunk events. Decrypting +// each one means a round-trip to the nip-07 extension (the private key lives in +// the extension and cannot be cached locally), and the extension serializes +// these — so a full re-decrypt costs many seconds on EVERY load. Chunk events +// are addressable/replaceable: an unchanged chunk keeps its event id, while a +// modified chunk republishes under a new id. Caching plaintext by event id means +// reloads decrypt only the chunk(s) that actually changed (typically one), +// skipping the extension for the rest. +// +// The decrypted plaintext is no more sensitive than the watch-history `items` +// already persisted by the manager. Cleared on logout. + +const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; +const MAX_ENTRIES = 400; + +/** @type {Map | null} */ +let cache = null; +let dirty = false; + +function ensure() { + if (cache) { + return cache; + } + cache = new Map(); + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + cache = new Map(parsed); + } + } + } + } catch (_) { + cache = new Map(); + } + return cache; +} + +export function getCachedChunkPlaintext(eventId) { + if (!eventId) { + return null; + } + const c = ensure(); + return c.has(eventId) ? c.get(eventId) : null; +} + +export function setCachedChunkPlaintext(eventId, plaintext) { + if (!eventId || typeof plaintext !== "string" || !plaintext) { + return; + } + const c = ensure(); + if (c.get(eventId) === plaintext) { + return; + } + c.set(eventId, plaintext); + dirty = true; +} + +/** Persist pending writes once (call after a batch of decrypts). Bounds size. */ +export function flushDecryptedChunkCache() { + if (!dirty) { + return; + } + dirty = false; + try { + if (typeof localStorage === "undefined") { + return; + } + let entries = Array.from(cache.entries()); + if (entries.length > MAX_ENTRIES) { + entries = entries.slice(entries.length - MAX_ENTRIES); + cache = new Map(entries); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch (_) { + // ignore persistence failures (quota/availability) + } +} + +export function clearWatchHistoryDecryptedChunkCache() { + cache = new Map(); + dirty = false; + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + } catch (_) { + // ignore + } +} diff --git a/tests/nostr/watchHistoryDecryptCache.test.mjs b/tests/nostr/watchHistoryDecryptCache.test.mjs new file mode 100644 index 00000000..affdad57 --- /dev/null +++ b/tests/nostr/watchHistoryDecryptCache.test.mjs @@ -0,0 +1,66 @@ +// Verifies the decrypted watch-history chunk cache: plaintext is reused by +// event id (so reloads skip the nip-07 extension for unchanged chunks), +// persists across "sessions", is bounded, and clears on logout. + +import test from "node:test"; +import assert from "node:assert/strict"; +import "../test-helpers/setup-localstorage.mjs"; + +const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; + +async function freshModule() { + // Re-import to reset module-level cache (simulates a page reload that + // re-hydrates from localStorage). + return import(`../../js/nostr/watchHistoryDecryptCache.js?bust=${Math.random()}`); +} + +test("get/set round-trips plaintext by event id", async () => { + const m = await freshModule(); + assert.equal(m.getCachedChunkPlaintext("evt-a"), null); + m.setCachedChunkPlaintext("evt-a", '{"items":[1]}'); + assert.equal(m.getCachedChunkPlaintext("evt-a"), '{"items":[1]}'); +}); + +test("flush persists and a fresh module re-hydrates from localStorage", async () => { + localStorage.removeItem(STORAGE_KEY); + const m1 = await freshModule(); + m1.setCachedChunkPlaintext("evt-persist", "PLAINTEXT"); + m1.flushDecryptedChunkCache(); + assert.ok(localStorage.getItem(STORAGE_KEY), "should write to localStorage"); + + // New module instance (simulated reload) reads the persisted plaintext — + // meaning no extension decrypt would be needed for this chunk. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-persist"), "PLAINTEXT"); +}); + +test("ignores empty/invalid input", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("", "x"); + m.setCachedChunkPlaintext("evt", ""); + m.setCachedChunkPlaintext("evt", null); + assert.equal(m.getCachedChunkPlaintext("evt"), null); +}); + +test("clear empties the cache and localStorage", async () => { + const m = await freshModule(); + m.setCachedChunkPlaintext("evt-x", "data"); + m.flushDecryptedChunkCache(); + m.clearWatchHistoryDecryptedChunkCache(); + assert.equal(m.getCachedChunkPlaintext("evt-x"), null); + assert.equal(localStorage.getItem(STORAGE_KEY), null); +}); + +test("bounds the cache to a maximum number of entries", async () => { + localStorage.removeItem(STORAGE_KEY); + const m = await freshModule(); + for (let i = 0; i < 500; i++) { + m.setCachedChunkPlaintext(`evt-${i}`, `p${i}`); + } + m.flushDecryptedChunkCache(); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); + assert.ok(stored.length <= 400, `expected <=400 entries, got ${stored.length}`); + // Most recent entries are retained. + const m2 = await freshModule(); + assert.equal(m2.getCachedChunkPlaintext("evt-499"), "p499"); +});