From 7dc59af49b4943fcee9c1a7b78275c3f8cbdf47b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:09:04 -0400 Subject: [PATCH] Handle video-only comment targets --- js/nostr/commentEvents.js | 104 ++++++++++------- tests/nostr/comment-events.test.mjs | 169 ++++++++++++++++++++++++---- 2 files changed, 207 insertions(+), 66 deletions(-) diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 7a226e4c..0468b294 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -223,19 +223,38 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { target.participantRelay, ); - if (!videoEventId) { + const normalizedVideoEventId = + typeof videoEventId === "string" ? videoEventId.trim() : ""; + const normalizedVideoDefinitionAddress = + typeof videoDefinitionAddress === "string" ? videoDefinitionAddress.trim() : ""; + const normalizedVideoEventRelay = + typeof videoEventRelay === "string" ? videoEventRelay.trim() : ""; + const normalizedVideoDefinitionRelay = + typeof videoDefinitionRelay === "string" ? videoDefinitionRelay.trim() : ""; + const normalizedParentCommentId = + typeof parentCommentId === "string" ? parentCommentId.trim() : ""; + const normalizedParentCommentRelay = + typeof parentCommentRelay === "string" ? parentCommentRelay.trim() : ""; + const normalizedThreadParticipantPubkey = + typeof threadParticipantPubkey === "string" ? threadParticipantPubkey.trim() : ""; + const normalizedThreadParticipantRelay = + typeof threadParticipantRelay === "string" + ? threadParticipantRelay.trim() + : ""; + + if (!normalizedVideoEventId) { return null; } return { - videoEventId, - videoEventRelay, - videoDefinitionAddress, - videoDefinitionRelay, - parentCommentId, - parentCommentRelay, - threadParticipantPubkey, - threadParticipantRelay, + videoEventId: normalizedVideoEventId, + videoEventRelay: normalizedVideoEventRelay, + videoDefinitionAddress: normalizedVideoDefinitionAddress, + videoDefinitionRelay: normalizedVideoDefinitionRelay, + parentCommentId: normalizedParentCommentId, + parentCommentRelay: normalizedParentCommentRelay, + threadParticipantPubkey: normalizedThreadParticipantPubkey, + threadParticipantRelay: normalizedThreadParticipantRelay, }; } @@ -272,41 +291,35 @@ function createVideoCommentFilters(targetInput, options = {}) { } const filters = []; - const baseFilter = { kinds: [COMMENT_EVENT_KIND] }; + + const eventFilter = { + kinds: [COMMENT_EVENT_KIND], + "#e": [descriptor.videoEventId], + }; + filters.push(applyFilterOptions(eventFilter, options)); + + if ( + descriptor.parentCommentId && + descriptor.parentCommentId !== descriptor.videoEventId + ) { + const parentFilter = { + kinds: [COMMENT_EVENT_KIND], + "#e": [descriptor.parentCommentId], + }; + filters.push(applyFilterOptions(parentFilter, options)); + } if (descriptor.videoDefinitionAddress) { - baseFilter["#a"] = [descriptor.videoDefinitionAddress]; - } - - if (descriptor.parentCommentId) { - baseFilter["#e"] = [descriptor.parentCommentId]; - } - - if (!descriptor.videoDefinitionAddress && descriptor.videoEventId) { - baseFilter["#e"] = baseFilter["#e"] || []; - if (!baseFilter["#e"].includes(descriptor.videoEventId)) { - baseFilter["#e"].push(descriptor.videoEventId); - } - } - - filters.push(applyFilterOptions(baseFilter, options)); - - const shouldIncludeLegacyFilter = - Boolean(descriptor.videoDefinitionAddress) && Boolean(descriptor.videoEventId); - - if (shouldIncludeLegacyFilter) { - const legacyFilter = { + const definitionFilter = { kinds: [COMMENT_EVENT_KIND], - "#e": [descriptor.videoEventId], + "#a": [descriptor.videoDefinitionAddress], }; if (descriptor.parentCommentId) { - if (!legacyFilter["#e"].includes(descriptor.parentCommentId)) { - legacyFilter["#e"].push(descriptor.parentCommentId); - } + definitionFilter["#e"] = [descriptor.parentCommentId]; } - filters.push(applyFilterOptions(legacyFilter, options)); + filters.push(applyFilterOptions(definitionFilter, options)); } return { descriptor, filters }; @@ -341,10 +354,14 @@ function isVideoCommentEvent(event, descriptor) { return false; } + const targetDescriptor = + descriptor && typeof descriptor === "object" ? descriptor : {}; + const tags = Array.isArray(event.tags) ? event.tags : []; - const requiresParentTag = Boolean(descriptor.parentCommentId); - const requiresAddressMatch = Boolean(descriptor.videoDefinitionAddress); - const requiresEventMatch = !requiresAddressMatch && Boolean(descriptor.videoEventId); + const requiresParentTag = Boolean(targetDescriptor.parentCommentId); + const requiresAddressMatch = Boolean(targetDescriptor.videoDefinitionAddress); + const requiresEventMatch = + !requiresAddressMatch && Boolean(targetDescriptor.videoEventId); let hasEventTag = !requiresEventMatch; let hasDefinitionTag = !requiresAddressMatch; @@ -356,14 +373,17 @@ function isVideoCommentEvent(event, descriptor) { } const [name, value] = tag; if (name === "e" && typeof value === "string") { - if (value === descriptor.videoEventId) { + if (value === targetDescriptor.videoEventId) { hasEventTag = true; } - if (descriptor.parentCommentId && value === descriptor.parentCommentId) { + if ( + targetDescriptor.parentCommentId && + value === targetDescriptor.parentCommentId + ) { hasParentTag = true; } } else if (name === "a" && typeof value === "string") { - if (value === descriptor.videoDefinitionAddress) { + if (value === targetDescriptor.videoDefinitionAddress) { hasDefinitionTag = true; } } diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs index 4913d98e..df193f25 100644 --- a/tests/nostr/comment-events.test.mjs +++ b/tests/nostr/comment-events.test.mjs @@ -230,7 +230,42 @@ test("publishComment accepts legacy targets with only an event id", async () => assert.deepEqual(aTags, [], "no address tag should be emitted when unavailable"); }); -test("listVideoComments builds filters that prefer #a pointers", async () => { +test("publishComment emits only video event #e tag when address and parent are absent", async () => { + const { client, publishCalls } = createMockClient(); + + const signEventWithPrivateKey = (event) => ({ ...event, id: "solo" }); + + const result = await publishComment( + client, + { + videoEventId: "video-only", + }, + { content: "Solo" }, + { + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey, + DEFAULT_NIP07_PERMISSION_METHODS: [], + }, + ); + + assert.equal(result.ok, true, "publishing should succeed with only a video id"); + assert.equal(publishCalls.length, 1, "comment should be published once"); + + const publishedEvent = publishCalls[0]?.event; + const tags = buildCommentEventTags(publishedEvent); + const eTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "e"); + const aTags = tags.filter((tag) => Array.isArray(tag) && tag[0] === "a"); + + assert.deepEqual( + eTags, + [["e", "video-only"]], + "event should carry the video pointer via #e", + ); + assert.deepEqual(aTags, [], "event should omit #a tags when no address is provided"); +}); + +test("listVideoComments builds filters with #e primary and #a secondary", async () => { const matchingEventLatest = { id: "comment-2", kind: COMMENT_EVENT_KIND, @@ -289,45 +324,75 @@ test("listVideoComments builds filters that prefer #a pointers", async () => { ["wss://history"], "list should use caller provided relays", ); - assert.equal(receivedFilters.filters.length, 2, "legacy and preferred filters should be emitted"); - const [primaryFilter, legacyFilter] = receivedFilters.filters; assert.equal( - primaryFilter.kinds[0], + receivedFilters.filters.length, + 3, + "event, parent, and address filters should be emitted", + ); + const [eventFilter, parentFilter, definitionFilter] = receivedFilters.filters; + assert.equal( + eventFilter.kinds[0], COMMENT_EVENT_KIND, - "primary filter should target comment kind", + "event filter should target comment kind", ); assert.deepEqual( - primaryFilter["#a"], - ["30078:author:clip"], - "primary filter should bind the video definition via #a", + eventFilter["#e"], + ["video-1"], + "event filter should target the video via #e", ); assert.deepEqual( - primaryFilter["#e"], - ["parent-1"], - "primary filter should include the parent pointer via #e", + parentFilter, + { + kinds: [COMMENT_EVENT_KIND], + "#e": ["parent-1"], + since: 1700000000, + limit: 10, + }, + "parent filter should scope the thread via #e", + ); + assert.equal( + eventFilter.since, + 1700000000, + "since option should propagate to event filter", + ); + assert.equal( + eventFilter.limit, + 10, + "limit option should propagate to event filter", ); - assert.equal(primaryFilter.since, 1700000000, "since option should propagate to primary filter"); - assert.equal(primaryFilter.limit, 10, "limit option should propagate to primary filter"); assert.equal( - legacyFilter.kinds[0], + definitionFilter.kinds[0], COMMENT_EVENT_KIND, - "legacy filter should target comment kind", + "definition filter should target comment kind", ); assert.deepEqual( - legacyFilter["#e"], - ["video-1", "parent-1"], - "legacy filter should target the video id for backward compatibility", + definitionFilter["#a"], + ["30078:author:clip"], + "definition filter should bind the video definition via #a", + ); + assert.deepEqual( + definitionFilter["#e"], + ["parent-1"], + "definition filter should continue scoping to the parent comment", + ); + assert.equal( + definitionFilter.since, + 1700000000, + "since option should propagate to definition filter", + ); + assert.equal( + definitionFilter.limit, + 10, + "limit option should propagate to definition filter", ); - assert.equal(legacyFilter.since, 1700000000, "since option should propagate to legacy filter"); - assert.equal(legacyFilter.limit, 10, "limit option should propagate to legacy filter"); }); test("listVideoComments supports legacy targets without a definition address", async () => { const legacyEvent = { id: "legacy-comment", kind: COMMENT_EVENT_KIND, - tags: [["e", "legacy-video"], ["e", "legacy-parent"]], + tags: [["e", "legacy-video"]], }; const { client, pool } = createMockClient(); @@ -417,15 +482,19 @@ test("subscribeVideoComments forwards matching events and cleans up unsubscribe" [ { kinds: [COMMENT_EVENT_KIND], - "#a": ["30078:author:clip"], + "#e": ["video-1"], + }, + { + kinds: [COMMENT_EVENT_KIND], "#e": ["parent-1"], }, { kinds: [COMMENT_EVENT_KIND], - "#e": ["video-1", "parent-1"], + "#a": ["30078:author:clip"], + "#e": ["parent-1"], }, ], - "subscription should emit preferred and legacy filters", + "subscription should emit event, parent, and address filters", ); handler({ @@ -451,3 +520,55 @@ test("subscribeVideoComments forwards matching events and cleans up unsubscribe" assert.equal(unsubCalls, 1, "underlying unsubscribe should only run once"); }); + +test("subscribeVideoComments supports video targets without a definition address", async () => { + const { client, pool } = createMockClient(); + + let handler = null; + let subscriptionArgs = null; + pool.sub = (relays, filters) => { + subscriptionArgs = { relays, filters }; + return { + on(name, cb) { + if (name === "event") { + handler = cb; + } + }, + unsub() {}, + }; + }; + + const receivedEvents = []; + subscribeVideoComments( + client, + { videoEventId: "legacy-video" }, + { + onEvent: (event) => { + receivedEvents.push(event); + }, + }, + ); + + assert.ok(subscriptionArgs, "subscription should capture filters"); + assert.deepEqual( + subscriptionArgs.filters, + [ + { + kinds: [COMMENT_EVENT_KIND], + "#e": ["legacy-video"], + }, + ], + "subscription should emit only the video #e filter when address is absent", + ); + + assert.ok(handler, "event handler should be registered"); + + handler({ + id: "legacy-comment", + kind: COMMENT_EVENT_KIND, + tags: [["e", "legacy-video"]], + }); + + assert.equal(receivedEvents.length, 1, "legacy comment should be forwarded"); + assert.equal(receivedEvents[0].id, "legacy-comment", "comment payload should be delivered"); +});