From 28acf98cda745b1eee15a069846e9421bc9e8650 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:38:08 -0400 Subject: [PATCH] Add uppercase comment filters and update validation logic --- js/nostr/commentEvents.js | 190 ++++++++++++++++++++++-- js/services/discussionCountService.js | 45 ++++++ tests/discussion-count-service.test.mjs | 44 +++++- tests/nostr/comment-events.test.mjs | 73 ++++++++- 4 files changed, 330 insertions(+), 22 deletions(-) diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 25356593..19580dc6 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -750,6 +750,48 @@ function createVideoCommentFilters(targetInput, options = {}) { }; filters.push(applyFilterOptions(eventFilter, options)); + const uppercaseFilter = { kinds: [COMMENT_EVENT_KIND] }; + let hasUppercasePointer = false; + + if (typeof descriptor.rootIdentifier === "string" && descriptor.rootIdentifier) { + uppercaseFilter["#I"] = [descriptor.rootIdentifier]; + hasUppercasePointer = true; + } else if ( + typeof descriptor.videoDefinitionAddress === "string" && + descriptor.videoDefinitionAddress + ) { + uppercaseFilter["#A"] = [descriptor.videoDefinitionAddress]; + hasUppercasePointer = true; + } else if ( + typeof descriptor.videoEventId === "string" && + descriptor.videoEventId + ) { + uppercaseFilter["#E"] = [descriptor.videoEventId]; + hasUppercasePointer = true; + } + + const normalizedRootKind = + typeof descriptor.rootKind === "string" + ? descriptor.rootKind + : Number.isFinite(descriptor.rootKind) + ? String(Math.floor(descriptor.rootKind)) + : ""; + if (normalizedRootKind) { + uppercaseFilter["#K"] = [normalizedRootKind]; + } + + const normalizedRootAuthor = + typeof descriptor.rootAuthorPubkey === "string" + ? descriptor.rootAuthorPubkey.trim() + : ""; + if (normalizedRootAuthor) { + uppercaseFilter["#P"] = [normalizedRootAuthor]; + } + + if (hasUppercasePointer) { + filters.push(applyFilterOptions(uppercaseFilter, options)); + } + if ( descriptor.parentCommentId && descriptor.parentCommentId !== descriptor.videoEventId @@ -810,21 +852,99 @@ function isVideoCommentEvent(event, descriptor) { descriptor && typeof descriptor === "object" ? descriptor : {}; const tags = Array.isArray(event.tags) ? event.tags : []; - const requiresParentTag = Boolean(targetDescriptor.parentCommentId); - const requiresAddressMatch = Boolean(targetDescriptor.videoDefinitionAddress); + const requiresRootIdentifierMatch = Boolean(targetDescriptor.rootIdentifier); + const requiresAddressMatch = + !requiresRootIdentifierMatch && Boolean(targetDescriptor.videoDefinitionAddress); const requiresEventMatch = - !requiresAddressMatch && Boolean(targetDescriptor.videoEventId); + !requiresRootIdentifierMatch && !requiresAddressMatch && + Boolean(targetDescriptor.videoEventId); + const requiresParentTag = Boolean(targetDescriptor.parentCommentId); - let hasEventTag = !requiresEventMatch; + let hasIdentifierTag = !requiresRootIdentifierMatch; let hasDefinitionTag = !requiresAddressMatch; + let hasEventTag = !requiresEventMatch; let hasParentTag = !requiresParentTag; + const expectedRootKind = (() => { + if (typeof targetDescriptor.rootKind === "string") { + return targetDescriptor.rootKind; + } + if (Number.isFinite(targetDescriptor.rootKind)) { + return String(Math.floor(targetDescriptor.rootKind)); + } + if (typeof targetDescriptor.videoKind === "string") { + return targetDescriptor.videoKind; + } + if (Number.isFinite(targetDescriptor.videoKind)) { + return String(Math.floor(targetDescriptor.videoKind)); + } + return ""; + })(); + + const expectedRootAuthor = + typeof targetDescriptor.rootAuthorPubkey === "string" + ? targetDescriptor.rootAuthorPubkey + : typeof targetDescriptor.videoAuthorPubkey === "string" + ? targetDescriptor.videoAuthorPubkey + : ""; + + const expectedParentKind = (() => { + if (typeof targetDescriptor.parentKind === "string") { + return targetDescriptor.parentKind; + } + if (Number.isFinite(targetDescriptor.parentKind)) { + return String(Math.floor(targetDescriptor.parentKind)); + } + return ""; + })(); + + const expectedParentAuthor = + typeof targetDescriptor.parentAuthorPubkey === "string" + ? targetDescriptor.parentAuthorPubkey + : ""; + + let sawRootKindTag = false; + let rootKindMatches = !expectedRootKind; + let rootKindMismatch = false; + + let sawRootAuthorTag = false; + let rootAuthorMatches = !expectedRootAuthor; + let rootAuthorMismatch = false; + + let sawParentKindTag = false; + let parentKindMatches = !expectedParentKind; + let parentKindMismatch = false; + + let sawParentAuthorTag = false; + let parentAuthorMatches = !expectedParentAuthor; + let parentAuthorMismatch = false; + for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const [name, value] = tag; - if (name === "e" && typeof value === "string") { + if (typeof value !== "string") { + continue; + } + + if (name === "I") { + if (value === targetDescriptor.rootIdentifier) { + hasIdentifierTag = true; + } + } else if (name === "A") { + if (value === targetDescriptor.videoDefinitionAddress) { + hasDefinitionTag = true; + } + } else if (name === "a") { + if (value === targetDescriptor.videoDefinitionAddress) { + hasDefinitionTag = true; + } + } else if (name === "E") { + if (value === targetDescriptor.videoEventId) { + hasEventTag = true; + } + } else if (name === "e") { if (value === targetDescriptor.videoEventId) { hasEventTag = true; } @@ -834,14 +954,66 @@ function isVideoCommentEvent(event, descriptor) { ) { hasParentTag = true; } - } else if (name === "a" && typeof value === "string") { - if (value === targetDescriptor.videoDefinitionAddress) { - hasDefinitionTag = true; + } else if (name === "K") { + sawRootKindTag = true; + if (!expectedRootKind || value === expectedRootKind) { + rootKindMatches = true; + } else { + rootKindMismatch = true; + } + } else if (name === "P") { + sawRootAuthorTag = true; + if (!expectedRootAuthor || value === expectedRootAuthor) { + rootAuthorMatches = true; + } else { + rootAuthorMismatch = true; + } + } else if (name === "k") { + sawParentKindTag = true; + if (!expectedParentKind || value === expectedParentKind) { + parentKindMatches = true; + } else { + parentKindMismatch = true; + } + } else if (name === "p") { + sawParentAuthorTag = true; + if (!expectedParentAuthor || value === expectedParentAuthor) { + parentAuthorMatches = true; + } else { + parentAuthorMismatch = true; } } } - return hasEventTag && hasDefinitionTag && hasParentTag; + if (!hasIdentifierTag || !hasDefinitionTag || !hasEventTag || !hasParentTag) { + return false; + } + + if ((sawRootKindTag && rootKindMismatch) || (sawRootAuthorTag && rootAuthorMismatch)) { + return false; + } + + if ((sawParentKindTag && parentKindMismatch) || (sawParentAuthorTag && parentAuthorMismatch)) { + return false; + } + + if (sawRootKindTag && expectedRootKind && !rootKindMatches) { + return false; + } + + if (sawRootAuthorTag && expectedRootAuthor && !rootAuthorMatches) { + return false; + } + + if (sawParentKindTag && expectedParentKind && !parentKindMatches) { + return false; + } + + if (sawParentAuthorTag && expectedParentAuthor && !parentAuthorMatches) { + return false; + } + + return true; } export async function publishComment( diff --git a/js/services/discussionCountService.js b/js/services/discussionCountService.js index 7c8173c2..6ddca0e2 100644 --- a/js/services/discussionCountService.js +++ b/js/services/discussionCountService.js @@ -135,6 +135,51 @@ export default class DiscussionCountService { } const address = buildVideoAddressPointer(video); + const rootIdentifier = + typeof video.videoRootId === "string" ? video.videoRootId.trim() : ""; + + const uppercaseFilter = { kinds: [COMMENT_EVENT_KIND] }; + let hasUppercasePointer = false; + + if (rootIdentifier) { + uppercaseFilter["#I"] = [rootIdentifier]; + hasUppercasePointer = true; + } else if (address) { + uppercaseFilter["#A"] = [address]; + hasUppercasePointer = true; + } else if (eventId) { + uppercaseFilter["#E"] = [eventId]; + hasUppercasePointer = true; + } + + const rootKind = (() => { + if (Number.isFinite(video.kind)) { + return String(Math.floor(video.kind)); + } + if (typeof video.kind === "string" && video.kind.trim()) { + const parsed = Number(video.kind); + if (Number.isFinite(parsed)) { + return String(Math.floor(parsed)); + } + return video.kind.trim(); + } + return ""; + })(); + + if (rootKind) { + uppercaseFilter["#K"] = [rootKind]; + } + + const rootAuthor = + typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + if (rootAuthor) { + uppercaseFilter["#P"] = [rootAuthor]; + } + + if (hasUppercasePointer) { + filters.push(uppercaseFilter); + } + if (address) { filters.push({ kinds: [COMMENT_EVENT_KIND], "#a": [address] }); } diff --git a/tests/discussion-count-service.test.mjs b/tests/discussion-count-service.test.mjs index 75652af2..8f94dbd0 100644 --- a/tests/discussion-count-service.test.mjs +++ b/tests/discussion-count-service.test.mjs @@ -6,6 +6,7 @@ import { JSDOM } from "jsdom"; import DiscussionCountService, { COUNT_UNSUPPORTED_TITLE, } from "../js/services/discussionCountService.js"; +import { COMMENT_EVENT_KIND } from "../js/nostr/commentEvents.js"; function createDomWithCountElement(videoId) { const dom = new JSDOM(` @@ -35,7 +36,22 @@ async function testRefreshCountsCachesResults() { const nostrClient = { pool: {}, countEventsAcrossRelays: async (filters) => { - assert.equal(filters.length, 2, "should request both id and address filters"); + assert.equal(filters.length, 3, "should request id, uppercase, and address filters"); + const [eventFilter, uppercaseFilter, addressFilter] = filters; + assert.deepEqual(eventFilter, { + kinds: [COMMENT_EVENT_KIND], + "#e": ["count-video-1"], + }); + assert.deepEqual(uppercaseFilter, { + kinds: [COMMENT_EVENT_KIND], + "#A": ["30078:pubkey-1:identifier-1"], + "#K": ["30078"], + "#P": ["pubkey-1"], + }); + assert.deepEqual(addressFilter, { + kinds: [COMMENT_EVENT_KIND], + "#a": ["30078:pubkey-1:identifier-1"], + }); return { total: 5, perRelay: [{ ok: true }], @@ -102,10 +118,28 @@ async function testUnsupportedRelaysUpdateDomState() { const service = new DiscussionCountService(); const nostrClient = { pool: {}, - countEventsAcrossRelays: async () => ({ - total: 7, - perRelay: [{ ok: false }], - }), + countEventsAcrossRelays: async (filters) => { + assert.equal(filters.length, 3, "unsupported relays should still receive all filters"); + const [eventFilter, uppercaseFilter, addressFilter] = filters; + assert.deepEqual(eventFilter, { + kinds: [COMMENT_EVENT_KIND], + "#e": ["unsupported-video"], + }); + assert.deepEqual(uppercaseFilter, { + kinds: [COMMENT_EVENT_KIND], + "#A": ["30078:pubkey-2:identifier-2"], + "#K": ["30078"], + "#P": ["pubkey-2"], + }); + assert.deepEqual(addressFilter, { + kinds: [COMMENT_EVENT_KIND], + "#a": ["30078:pubkey-2:identifier-2"], + }); + return { + total: 7, + perRelay: [{ ok: false }], + }; + }, }; service.refreshCounts([video], { diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs index c6a99474..46c6c390 100644 --- a/tests/nostr/comment-events.test.mjs +++ b/tests/nostr/comment-events.test.mjs @@ -308,14 +308,18 @@ test("publishComment emits only video event #e tag when address and parent are a ); }); -test("listVideoComments builds filters with #e primary and #a secondary", async () => { +test("listVideoComments builds filters with uppercase roots plus legacy fallbacks", async () => { const matchingEventLatest = { id: "comment-2", kind: COMMENT_EVENT_KIND, created_at: 1700000200, tags: [ - ["a", "30078:author:clip"], + ["A", "30078:author:clip"], + ["K", "30078"], + ["P", "author"], ["e", "parent-1"], + ["k", String(COMMENT_EVENT_KIND)], + ["p", "thread-participant"], ], }; @@ -353,6 +357,8 @@ test("listVideoComments builds filters with #e primary and #a secondary", async videoEventId: "video-1", videoDefinitionAddress: "30078:author:clip", parentCommentId: "parent-1", + parentKind: COMMENT_EVENT_KIND, + parentAuthorPubkey: "thread-participant", }, { relays: ["wss://history"], since: 1700000000, limit: 10 }, ); @@ -369,10 +375,11 @@ test("listVideoComments builds filters with #e primary and #a secondary", async ); assert.equal( receivedFilters.filters.length, - 3, - "event, parent, and address filters should be emitted", + 4, + "event, uppercase, parent, and address filters should be emitted", ); - const [eventFilter, parentFilter, definitionFilter] = receivedFilters.filters; + const [eventFilter, uppercaseFilter, parentFilter, definitionFilter] = + receivedFilters.filters; assert.equal( eventFilter.kinds[0], COMMENT_EVENT_KIND, @@ -383,6 +390,36 @@ test("listVideoComments builds filters with #e primary and #a secondary", async ["video-1"], "event filter should target the video via #e", ); + assert.equal( + uppercaseFilter.kinds[0], + COMMENT_EVENT_KIND, + "uppercase filter should target comment kind", + ); + assert.deepEqual( + uppercaseFilter["#A"], + ["30078:author:clip"], + "uppercase filter should target the video definition via #A", + ); + assert.deepEqual( + uppercaseFilter["#K"], + ["30078"], + "uppercase filter should scope the root kind via #K", + ); + assert.deepEqual( + uppercaseFilter["#P"], + ["author"], + "uppercase filter should scope the root author via #P", + ); + assert.equal( + uppercaseFilter.since, + 1700000000, + "since option should propagate to uppercase filter", + ); + assert.equal( + uppercaseFilter.limit, + 10, + "limit option should propagate to uppercase filter", + ); assert.deepEqual( parentFilter, { @@ -465,8 +502,12 @@ test("listVideoComments supports legacy targets without a definition address", a kinds: [COMMENT_EVENT_KIND], "#e": ["legacy-video"], }, + { + kinds: [COMMENT_EVENT_KIND], + "#E": ["legacy-video"], + }, ], - "legacy listing should target the video event id via #e", + "legacy listing should target the video event id via #e/#E", ); }); @@ -503,6 +544,8 @@ test("subscribeVideoComments forwards matching events and cleans up unsubscribe" videoEventId: "video-1", videoDefinitionAddress: "30078:author:clip", parentCommentId: "parent-1", + parentKind: COMMENT_EVENT_KIND, + parentAuthorPubkey: "thread-participant", }, { relays: ["wss://live"], @@ -527,6 +570,12 @@ test("subscribeVideoComments forwards matching events and cleans up unsubscribe" kinds: [COMMENT_EVENT_KIND], "#e": ["video-1"], }, + { + kinds: [COMMENT_EVENT_KIND], + "#A": ["30078:author:clip"], + "#K": ["30078"], + "#P": ["author"], + }, { kinds: [COMMENT_EVENT_KIND], "#e": ["parent-1"], @@ -544,8 +593,12 @@ test("subscribeVideoComments forwards matching events and cleans up unsubscribe" id: "comment-accepted", kind: COMMENT_EVENT_KIND, tags: [ - ["a", "30078:author:clip"], + ["A", "30078:author:clip"], + ["K", "30078"], + ["P", "author"], ["e", "parent-1"], + ["k", String(COMMENT_EVENT_KIND)], + ["p", "thread-participant"], ], }); @@ -600,8 +653,12 @@ test("subscribeVideoComments supports video targets without a definition address kinds: [COMMENT_EVENT_KIND], "#e": ["legacy-video"], }, + { + kinds: [COMMENT_EVENT_KIND], + "#E": ["legacy-video"], + }, ], - "subscription should emit only the video #e filter when address is absent", + "subscription should emit both legacy and uppercase video filters when address is absent", ); assert.ok(handler, "event handler should be registered");