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;