From 8c4412a562da50bcbddedfbc82a94af010222e11 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:07:18 -0400 Subject: [PATCH] perf(watch-history): cache decrypted chunk plaintext by event id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch history is ~150 separately encrypted chunk events; decrypting each one is a serialized round-trip to the nip-07 extension (the key lives in the extension and cannot be cached locally), so a full re-decrypt cost 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. Add a persisted cache (watchHistoryDecryptCache.js) keyed by chunk event id. The fetch path checks it before calling the extension and stores successful decryptions, so reloads decrypt only the chunk(s) that actually changed (typically one) and skip the extension for the rest — across sessions. Bounded to 400 entries; cleared on logout alongside the conversation-key cache. The cached plaintext is no more sensitive than the watch-history items already persisted by the manager. Adds watchHistoryDecryptCache.test.mjs (round-trip, persistence/re-hydrate, bounds, clear). Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 10 +- js/nostr/watchHistory.js | 43 ++++++--- js/nostr/watchHistoryDecryptCache.js | 95 +++++++++++++++++++ tests/nostr/watchHistoryDecryptCache.test.mjs | 66 +++++++++++++ 4 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 js/nostr/watchHistoryDecryptCache.js create mode 100644 tests/nostr/watchHistoryDecryptCache.test.mjs 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"); +});