diff --git a/js/app.js b/js/app.js index adf62dd1..08d52500 100644 --- a/js/app.js +++ b/js/app.js @@ -71,6 +71,7 @@ import ProfileModalController from "./ui/profileModalController.js"; import ZapController from "./ui/zapController.js"; import { MediaLoader } from "./utils/mediaLoader.js"; import { pointerArrayToKey } from "./utils/pointer.js"; +import { resolveVideoPointer } from "./utils/videoPointer.js"; import { isValidMagnetUri } from "./utils/magnetValidators.js"; import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; import { @@ -1309,34 +1310,14 @@ class Application { } const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); - const normalizedPubkey = - typeof video.pubkey === "string" ? video.pubkey.trim() : ""; - const kind = - typeof video.kind === "number" && Number.isFinite(video.kind) - ? video.kind - : VIDEO_EVENT_KIND; - if (dTagValue && normalizedPubkey) { - const pointer = ["a", `${kind}:${normalizedPubkey}:${dTagValue}`]; - const key = pointerArrayToKey(pointer); - if (key) { - return { pointer, key }; - } - } - - const fallbackId = - typeof video.id === "string" && video.id.trim() - ? video.id.trim() - : ""; - if (fallbackId) { - const pointer = ["e", fallbackId]; - const key = pointerArrayToKey(pointer); - if (key) { - return { pointer, key }; - } - } - - return null; + return resolveVideoPointer({ + kind: video.kind, + pubkey: video.pubkey, + videoRootId: video.videoRootId, + dTag: dTagValue, + fallbackEventId: video.id, + }); } formatViewCountLabel(total) { @@ -6257,50 +6238,16 @@ class Application { }; const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); - const normalizedPubkey = - typeof video.pubkey === "string" ? video.pubkey.trim() : ""; - const primaryPointer = - dTagValue && normalizedPubkey - ? [ - "a", - `${ - typeof video.kind === "number" && Number.isFinite(video.kind) - ? video.kind - : 30078 - }:${normalizedPubkey}:${dTagValue}`, - ] - : null; - const fallbackId = - typeof (video.id || eventId) === "string" - ? (video.id || eventId).trim() - : ""; - const fallbackPointer = fallbackId ? ["e", fallbackId] : null; + const pointerInfo = resolveVideoPointer({ + kind: video.kind, + pubkey: video.pubkey, + videoRootId: video.videoRootId, + dTag: dTagValue, + fallbackEventId: video.id || eventId, + }); - let resolvedPointer = null; - let resolvedPointerKey = ""; - const pointerCandidates = []; - if (primaryPointer) { - pointerCandidates.push(primaryPointer); - } - if (fallbackPointer) { - pointerCandidates.push(fallbackPointer); - } - - for (const candidate of pointerCandidates) { - const key = pointerArrayToKey(candidate); - if (key) { - resolvedPointer = candidate; - resolvedPointerKey = key; - break; - } - } - - this.currentVideoPointer = resolvedPointer && resolvedPointerKey - ? resolvedPointer - : null; - this.currentVideoPointerKey = this.currentVideoPointer - ? resolvedPointerKey - : null; + this.currentVideoPointer = pointerInfo?.pointer || null; + this.currentVideoPointerKey = pointerInfo?.key || null; if (this.currentVideo) { this.currentVideo.pointer = this.currentVideoPointer; diff --git a/js/utils/videoPointer.js b/js/utils/videoPointer.js new file mode 100644 index 00000000..35193e92 --- /dev/null +++ b/js/utils/videoPointer.js @@ -0,0 +1,51 @@ +// js/utils/videoPointer.js + +import { pointerArrayToKey } from "./pointer.js"; + +const DEFAULT_VIDEO_KIND = 30078; + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function resolveVideoPointer({ + kind, + pubkey, + videoRootId, + dTag, + fallbackEventId, +} = {}) { + const normalizedPubkey = normalizeString(pubkey).toLowerCase(); + const normalizedKind = + typeof kind === "number" && Number.isFinite(kind) ? kind : DEFAULT_VIDEO_KIND; + const pointerCandidates = []; + + const rootId = normalizeString(videoRootId); + if (rootId && normalizedPubkey) { + pointerCandidates.push(["a", `${normalizedKind}:${normalizedPubkey}:${rootId}`]); + } + + const normalizedDTag = normalizeString(dTag); + if (normalizedDTag && normalizedPubkey) { + pointerCandidates.push([ + "a", + `${normalizedKind}:${normalizedPubkey}:${normalizedDTag}`, + ]); + } + + const fallbackId = normalizeString(fallbackEventId); + if (fallbackId) { + pointerCandidates.push(["e", fallbackId]); + } + + for (const pointer of pointerCandidates) { + const key = pointerArrayToKey(pointer); + if (key) { + return { pointer, key }; + } + } + + return null; +} + +export default resolveVideoPointer; diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index ac78e4a4..a797e44d 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -47,6 +47,14 @@ if (!globalThis.window.NostrTools) { globalThis.window.NostrTools = {}; } +if (typeof globalThis.window.NostrTools.getEventHash !== "function") { + globalThis.window.NostrTools.getEventHash = () => "test-event-hash"; +} + +if (typeof globalThis.window.NostrTools.signEvent !== "function") { + globalThis.window.NostrTools.signEvent = () => "test-event-sig"; +} + setNostrEventSchemaOverrides({}); const canonicalViewEvent = buildViewEvent({ @@ -275,11 +283,79 @@ const { unsubscribeFromVideoViewCount, ingestLocalViewEvent, } = await import("../js/viewCounter.js"); +const { resolveVideoPointer } = await import("../js/utils/videoPointer.js"); initViewCounter({ nostrClient }); const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); +function testPointerPrefersVideoRootId() { + const info = resolveVideoPointer({ + pubkey: "ABCDEF1234", + videoRootId: "ROOT-POINTER", + dTag: "legacy-d-tag", + fallbackEventId: "event-fallback", + kind: 30078, + }); + + assert.ok(info, "expected pointer info for root-backed video"); + assert.deepEqual( + info.pointer, + ["a", "30078:abcdef1234:ROOT-POINTER"], + "videos with a videoRootId should resolve to the root pointer" + ); + assert.equal( + info.key, + "a:30078:abcdef1234:root-pointer", + "root pointer key should be normalized for view counter lookups" + ); +} + +function testPointerFallsBackToDTag() { + const info = resolveVideoPointer({ + pubkey: "ABCDEF1234", + dTag: "legacy-pointer", + fallbackEventId: "event-fallback", + kind: 30078, + }); + + assert.ok(info, "expected pointer info when only d-tag is present"); + assert.deepEqual( + info.pointer, + ["a", "30078:abcdef1234:legacy-pointer"], + "videos without a root should fall back to the d-tag pointer" + ); + assert.equal( + info.key, + "a:30078:abcdef1234:legacy-pointer", + "d-tag pointer key should match the normalized value" + ); +} + +function testPointerFallsBackToEventId() { + const info = resolveVideoPointer({ + pubkey: "ABCDEF1234", + fallbackEventId: "legacy-event-id", + kind: 30078, + }); + + assert.ok(info, "expected pointer info when only event id is present"); + assert.deepEqual( + info.pointer, + ["e", "legacy-event-id"], + "videos without root or d-tag should fall back to the event pointer" + ); + assert.equal( + info.key, + "e:legacy-event-id", + "event pointer key should normalize correctly" + ); +} + +testPointerPrefersVideoRootId(); +testPointerFallsBackToDTag(); +testPointerFallsBackToEventId(); + async function testDedupesWithinWindow() { localStorage.clear(); harness.reset();