From 5e81bc9aefb7620eac4f5a372a5d64049b04bc8e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:52:25 -0400 Subject: [PATCH 1/2] Fix watch history pointer classification --- js/nostr.js | 39 ++++++++++++++++--- tests/watch-history.test.mjs | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index c0043c40..02e1c9fe 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2579,19 +2579,46 @@ class NostrClient { ? Math.max(1, normalizedBatchSize) : 1; - const classifyWatchHistoryPointer = (pointer) => { + const classifyWatchHistoryPointer = (pointer, ownerPubkey) => { if (!pointer || pointer.type !== "a") { return "none"; } - const [kindStr, pubkey, identifier] = pointer.value.split(":"); + const value = + typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return "none"; + } + const parts = value.split(":"); + if (parts.length < 3) { + return "none"; + } + const [kindStr, pubkeyRaw, ...identifierParts] = parts; const kind = Number.parseInt(kindStr, 10); if (!Number.isFinite(kind) || kind !== WATCH_HISTORY_KIND) { return "none"; } - if (identifier === WATCH_HISTORY_LIST_IDENTIFIER) { + const identifier = identifierParts.join(":"); + const normalizedIdentifier = + typeof identifier === "string" ? identifier.trim() : ""; + if (!normalizedIdentifier) { + return "none"; + } + const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); + if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) { return "head"; } - if (typeof identifier === "string" && identifier) { + if (!normalizedIdentifierLower.startsWith("watch-history:")) { + return "none"; + } + const normalizedOwner = + typeof ownerPubkey === "string" && ownerPubkey.trim() + ? ownerPubkey.trim().toLowerCase() + : ""; + const normalizedPubkey = + typeof pubkeyRaw === "string" && pubkeyRaw.trim() + ? pubkeyRaw.trim().toLowerCase() + : ""; + if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) { return "chunk"; } return "none"; @@ -2628,7 +2655,7 @@ class NostrClient { if (!pointer) { continue; } - const pointerType = classifyWatchHistoryPointer(pointer); + const pointerType = classifyWatchHistoryPointer(pointer, actor); if (pointerType === "head") { addPointerToMap(pointer, headPointerMap); continue; @@ -2776,7 +2803,7 @@ class NostrClient { if (!key) { continue; } - const pointerType = classifyWatchHistoryPointer(pointer); + const pointerType = classifyWatchHistoryPointer(pointer, actor); const event = pointerMatches.get(key); if (pointerType === "chunk" || pointerType === "head") { if (event) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index caefe962..ab464639 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -398,6 +398,81 @@ try { localStorage.clear(); +const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`; +const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier"; +const videoPointer = { + type: "a", + value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`, + relay: null, +}; + +const pointerClient = createDecryptClient(ACTOR); +const pointerEntry = pointerClient.createWatchHistoryEntry( + null, + [videoPointer], + Date.now() +); +pointerClient.watchHistoryCache.set(ACTOR, pointerEntry); +pointerClient.fetchWatchHistory = async () => {}; + +const observedResolveFilters = []; +const videoEvent = { + id: "video-pointer-event", + kind: WATCH_HISTORY_KIND, + pubkey: VIDEO_POINTER_AUTHOR, + created_at: 1_700_555_000, + tags: [["d", VIDEO_POINTER_IDENTIFIER]], + content: JSON.stringify({ + version: 3, + title: "Pointer Video", + url: "https://cdn.example.com/video.mp4", + }), +}; + +pointerClient.pool = { + list: async (_relays, filters) => { + observedResolveFilters.push(filters); + return [videoEvent]; + }, +}; + +const resolvedFromPointer = await pointerClient.resolveWatchHistory(1); + +assert.equal( + resolvedFromPointer.length, + 1, + "resolveWatchHistory should return results for 'a' style video pointers" +); +assert.equal( + resolvedFromPointer[0].id, + videoEvent.id, + "resolved video should match the pointer event id" +); +assert.equal( + resolvedFromPointer[0].title, + "Pointer Video", + "resolved video should parse title from the event payload" +); + +const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; +const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); +assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video"); +assert.equal( + cachedEntry.resolved.get(pointerKey)?.id, + videoEvent.id, + "resolved cache should store the converted video result" +); + +assert( + observedResolveFilters.some((filters) => + filters.some((filter) => + Array.isArray(filter?.["#d"]) && + filter["#d"].includes(VIDEO_POINTER_IDENTIFIER) + ) + ), + "resolveWatchHistory should request the video identifier instead of treating it as a chunk" +); + const originalDateNow = Date.now; try { const baseTime = 1_700_000_000_000; From 6039ad2fd7123a1e6ddf83c468c8d9dbd53bf614 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 20:09:23 -0400 Subject: [PATCH 2/2] Improve watch history caching and reuse --- js/historyView.js | 4 + js/nostr.js | 158 ++++++++++++++++++++++++++--------- tests/watch-history.test.mjs | 43 +++++++++- 3 files changed, 163 insertions(+), 42 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 6484fb30..1c422cb8 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -950,6 +950,10 @@ export function createWatchHistoryRenderer(config = {}) { state.actor = beforeResult.actor.trim(); } + if (state.actor) { + nostrClient.resetWatchHistoryProgress(state.actor); + } + let fingerprint; if (typeof fingerprintOverride !== "undefined") { fingerprint = fingerprintOverride; diff --git a/js/nostr.js b/js/nostr.js index 02e1c9fe..18e66c04 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1102,9 +1102,15 @@ class NostrClient { resolved: previousEntry?.resolved ? new Map(previousEntry.resolved) : new Map(), + resolvedVideos: previousEntry?.resolvedVideos + ? new Map(previousEntry.resolvedVideos) + : new Map(), resolving: previousEntry?.resolving ? new Set(previousEntry.resolving) : new Set(), + delivered: previousEntry?.delivered instanceof Set + ? new Set(previousEntry.delivered) + : new Set(), }; const validKeys = new Set(entry.items.map((item) => pointerKey(item))); @@ -1113,11 +1119,21 @@ class NostrClient { entry.resolved.delete(key); } } + for (const key of entry.resolvedVideos.keys()) { + if (!validKeys.has(key)) { + entry.resolvedVideos.delete(key); + } + } for (const key of [...entry.resolving]) { if (!validKeys.has(key)) { entry.resolving.delete(key); } } + for (const key of [...entry.delivered]) { + if (!validKeys.has(key)) { + entry.delivered.delete(key); + } + } const previousChunkPointers = previousEntry?.chunkPointers; const headPointer = previousChunkPointers?.head @@ -1214,6 +1230,40 @@ class NostrClient { return `${pointerId}:${pointerCreated}:${itemKeys}`; } + resetWatchHistoryProgress(pubkeyOrSession = null) { + let actor = + typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() + ? pubkeyOrSession.trim() + : ""; + + if (!actor) { + if (this.sessionActor?.pubkey) { + actor = this.sessionActor.pubkey.trim(); + } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { + actor = this.pubkey.trim(); + } + } + + if (!actor) { + return; + } + + const entry = this.watchHistoryCache.get(actor); + if (!entry) { + return; + } + + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } else { + entry.delivered.clear(); + } + + if (entry.resolving instanceof Set) { + entry.resolving.clear(); + } + } + async encryptWatchHistoryPayload(actorPubkey, payload) { const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim() @@ -2518,7 +2568,10 @@ class NostrClient { } for (const key of Array.from(entry.resolved.keys())) { - if (!key || !key.startsWith("a:")) { + if ( + !key || + !key.startsWith(`a:${WATCH_HISTORY_KIND}:`) + ) { continue; } if (!chunkPointerKeys.has(key)) { @@ -2570,6 +2623,13 @@ class NostrClient { return []; } + if (!(entry.resolvedVideos instanceof Map)) { + entry.resolvedVideos = new Map(); + } + if (!(entry.delivered instanceof Set)) { + entry.delivered = new Set(); + } + const parsedBatchSize = Number(batchSize); const normalizedBatchSize = Number.isFinite(parsedBatchSize) && parsedBatchSize > 0 @@ -2691,12 +2751,18 @@ class NostrClient { if (!key) { return false; } - if (entry.resolved.has(key)) { + if (entry.delivered.has(key)) { return false; } if (entry.resolving.has(key)) { return false; } + if (entry.resolvedVideos.has(key)) { + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + return false; + } + } return true; }); @@ -2727,56 +2793,53 @@ class NostrClient { const videoBatch = available.slice(0, effectiveBatchSize); const batch = [...videoBatch, ...chunkAvailable]; - if (!batch.length) { - return []; - } - - batch.forEach((item) => { - const key = pointerKey(item); - if (key) { - entry.resolving.add(key); - } - }); const relaySet = new Set( Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS ); const filters = []; - for (const pointer of batch) { - if (pointer.relay) { - relaySet.add(pointer.relay); - } - if (pointer.type === "e") { - filters.push({ ids: [pointer.value] }); - } else if (pointer.type === "a") { - const [kindStr, pubkey, identifier] = pointer.value.split(":"); - const kind = Number(kindStr); - if (Number.isFinite(kind) && pubkey) { - const filter = { kinds: [kind], authors: [pubkey] }; - if (identifier) { - filter["#d"] = [identifier]; + if (batch.length) { + batch.forEach((item) => { + const key = pointerKey(item); + if (key) { + entry.resolving.add(key); + } + }); + + for (const pointer of batch) { + if (pointer.relay) { + relaySet.add(pointer.relay); + } + if (pointer.type === "e") { + filters.push({ ids: [pointer.value] }); + } else if (pointer.type === "a") { + const [kindStr, pubkey, identifier] = pointer.value.split(":"); + const kind = Number(kindStr); + if (Number.isFinite(kind) && pubkey) { + const filter = { kinds: [kind], authors: [pubkey] }; + if (identifier) { + filter["#d"] = [identifier]; + } + filters.push(filter); } - filters.push(filter); } } } - if (!filters.length) { - batch.forEach((item) => entry.resolving.delete(pointerKey(item))); - return []; - } - let events = []; - try { - events = await this.pool.list(Array.from(relaySet), filters); - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to resolve watch history batch:", error); + if (filters.length) { + try { + events = await this.pool.list(Array.from(relaySet), filters); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to resolve watch history batch:", error); + } } + } else if (batch.length) { + batch.forEach((item) => entry.resolving.delete(pointerKey(item))); } - const resolvedVideos = []; const pointerMatches = new Map(); for (const evt of events || []) { @@ -2830,8 +2893,7 @@ class NostrClient { this.activeMap.set(activeKey, video); } } - entry.resolved.set(key, video); - resolvedVideos.push(video); + entry.resolvedVideos.set(key, video); } } catch (err) { if (isDevMode) { @@ -2842,7 +2904,23 @@ class NostrClient { entry.resolving.delete(key); } - return resolvedVideos; + const deliverableVideos = []; + for (const pointer of videoCandidates) { + if (deliverableVideos.length >= effectiveBatchSize) { + break; + } + const key = pointerKey(pointer); + if (!key || entry.delivered.has(key)) { + continue; + } + const cachedVideo = entry.resolvedVideos.get(key); + if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { + deliverableVideos.push(cachedVideo); + entry.delivered.add(key); + } + } + + return deliverableVideos; } async recordVideoView(videoPointer, options = {}) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index ab464639..5140e3ab 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -456,12 +456,19 @@ assert.equal( const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); -assert(cachedEntry.resolved.has(pointerKey), "resolved cache should retain the video"); +assert( + cachedEntry.resolvedVideos.has(pointerKey), + "resolved cache should retain the video" +); assert.equal( - cachedEntry.resolved.get(pointerKey)?.id, + cachedEntry.resolvedVideos.get(pointerKey)?.id, videoEvent.id, "resolved cache should store the converted video result" ); +assert( + cachedEntry.delivered.has(pointerKey), + "delivery set should mark the pointer as delivered" +); assert( observedResolveFilters.some((filters) => @@ -473,6 +480,38 @@ assert( "resolveWatchHistory should request the video identifier instead of treating it as a chunk" ); +const secondResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + secondResolve.length, + 0, + "subsequent resolve calls should avoid refetching already delivered videos" +); + +pointerClient.resetWatchHistoryProgress(ACTOR); +assert.equal( + cachedEntry.delivered.size, + 0, + "resetWatchHistoryProgress should clear delivered pointers" +); + +pointerClient.pool = { + list: async () => { + throw new Error("cached resolve should not trigger network fetch"); + }, +}; + +const cachedResolve = await pointerClient.resolveWatchHistory(1); +assert.equal( + cachedResolve.length, + 1, + "reset should allow cached videos to be delivered again" +); +assert.equal( + cachedResolve[0].id, + videoEvent.id, + "cached resolve should reuse the stored video payload" +); + const originalDateNow = Date.now; try { const baseTime = 1_700_000_000_000;