From 74bbe8e078bea3cf47e4ec7bb4ff71f05a3d3761 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:31:43 -0500 Subject: [PATCH] Add root identifier plumbing to modal comments --- js/services/commentThreadService.js | 56 ++++++++++++++ js/ui/videoModalCommentController.js | 47 ++++++++++++ tests/comment-thread-service.test.mjs | 56 +++++++++++++- tests/video-modal-comments.test.mjs | 101 ++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index e02f62bf..83c0cd6d 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -24,6 +24,13 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeRelay(value) { + if (typeof value === "string") { + return value.trim(); + } + return ""; +} + function normalizePubkey(pubkey) { const normalized = normalizeString(pubkey); return normalized ? normalized.toLowerCase() : ""; @@ -132,6 +139,8 @@ export default class CommentThreadService { this.videoKind = ""; this.videoAuthorPubkeyRaw = ""; this.videoAuthorPubkey = ""; + this.videoRootIdentifier = ""; + this.videoRootRelay = ""; this.parentCommentId = ""; this.parentCommentKind = ""; this.parentCommentPubkey = ""; @@ -182,6 +191,10 @@ export default class CommentThreadService { this.videoKind = normalizeKind(video?.kind); this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey; this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey); + this.videoRootIdentifier = normalizeString(video?.videoRootId); + this.videoRootRelay = normalizeRelay( + video?.videoRootRelay || video?.rootIdentifierRelay, + ); this.parentCommentId = normalizeString(parentCommentId); this.parentCommentKind = this.parentCommentId ? String(COMMENT_EVENT_KIND) : ""; this.parentCommentPubkey = ""; @@ -213,6 +226,12 @@ export default class CommentThreadService { if (this.videoKind) { target.videoKind = this.videoKind; } + if (this.videoRootIdentifier) { + target.rootIdentifier = this.videoRootIdentifier; + } + if (this.videoRootRelay) { + target.rootIdentifierRelay = this.videoRootRelay; + } const targetVideoAuthorPubkey = this.getVideoAuthorPubkeyForOutput(); if (targetVideoAuthorPubkey) { target.videoAuthorPubkey = targetVideoAuthorPubkey; @@ -563,6 +582,14 @@ export default class CommentThreadService { const parentKey = toParentKey(parentId); const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; const pubkey = normalizePubkey(event.pubkey); + const { identifier: rootIdentifier, relay: rootRelay } = + this.extractRootIdentifier(event); + if (rootIdentifier) { + this.videoRootIdentifier = rootIdentifier; + if (rootRelay) { + this.videoRootRelay = rootRelay; + } + } if (this.parentCommentId && eventId === this.parentCommentId) { const kindValue = normalizeKind(event.kind); if (kindValue) { @@ -627,6 +654,29 @@ export default class CommentThreadService { return ""; } + extractRootIdentifier(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (let index = tags.length - 1; index >= 0; index -= 1) { + const tag = tags[index]; + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const name = typeof tag[0] === "string" ? tag[0].trim() : ""; + if (name !== "I") { + continue; + } + const value = normalizeString(tag[1]); + if (!value) { + continue; + } + return { + identifier: value, + relay: normalizeRelay(tag[2]), + }; + } + return { identifier: "", relay: "" }; + } + insertIntoParentList(parentKey, eventId, createdAt) { const list = this.getParentList(parentKey); if (list.includes(eventId)) { @@ -782,6 +832,8 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, topLevelIds: this.getCommentIdsForParent(null), @@ -800,6 +852,8 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentIds: [eventId], @@ -825,6 +879,8 @@ export default class CommentThreadService { videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, + rootIdentifier: this.videoRootIdentifier || null, + rootIdentifierRelay: this.videoRootRelay || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentsById: this.cloneCommentsMap(), diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index 5d7f11d3..72aaffc5 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -8,6 +8,13 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeRelay(value) { + if (typeof value === "string") { + return value.trim(); + } + return ""; +} + function normalizeKind(value) { if (Number.isFinite(value)) { return String(Math.floor(value)); @@ -86,6 +93,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: null, videoKind: null, videoAuthorPubkey: null, + videoRootId: null, + videoRootRelay: null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -152,6 +161,10 @@ export default class VideoModalCommentController { } const videoKind = normalizeKind(video.kind); const videoAuthorPubkey = normalizeString(video.pubkey); + const videoRootId = normalizeString(video.videoRootId); + const videoRootRelay = normalizeRelay( + video.videoRootRelay || video.rootIdentifierRelay, + ); if (!videoEventId) { this.resetModalCommentState({ hide: false }); @@ -166,6 +179,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: videoDefinitionAddress || null, videoKind: videoKind || null, videoAuthorPubkey: videoAuthorPubkey || null, + videoRootId: videoRootId || null, + videoRootRelay: videoRootRelay || null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -248,6 +263,8 @@ export default class VideoModalCommentController { videoDefinitionAddress: null, videoKind: null, videoAuthorPubkey: null, + videoRootId: null, + videoRootRelay: null, parentCommentId: null, parentCommentKind: null, parentCommentPubkey: null, @@ -354,6 +371,14 @@ export default class VideoModalCommentController { if (snapshotVideoAuthor) { this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor; } + const snapshotRootIdentifier = normalizeString(snapshot.rootIdentifier); + if (snapshotRootIdentifier) { + this.modalCommentState.videoRootId = snapshotRootIdentifier; + } + const snapshotRootRelay = normalizeRelay(snapshot.rootIdentifierRelay); + if (snapshotRootRelay) { + this.modalCommentState.videoRootRelay = snapshotRootRelay; + } const snapshotParentKind = normalizeKind(snapshot.parentCommentKind); if (snapshotParentKind) { this.modalCommentState.parentCommentKind = snapshotParentKind; @@ -384,6 +409,15 @@ export default class VideoModalCommentController { this.modalCommentProfiles.set(pubkey, profile); }); + const payloadRootIdentifier = normalizeString(payload.rootIdentifier); + if (payloadRootIdentifier) { + this.modalCommentState.videoRootId = payloadRootIdentifier; + } + const payloadRootRelay = normalizeRelay(payload.rootIdentifierRelay); + if (payloadRootRelay) { + this.modalCommentState.videoRootRelay = payloadRootRelay; + } + const commentIds = Array.isArray(payload.commentIds) ? payload.commentIds : []; @@ -520,6 +554,8 @@ export default class VideoModalCommentController { return { videoEventId: snapshot.videoEventId, parentCommentId: snapshot.parentCommentId || null, + rootIdentifier: snapshot.rootIdentifier || null, + rootIdentifierRelay: snapshot.rootIdentifierRelay || null, commentsById: sanitizedComments, childrenByParent: sanitizedChildren, profiles: this.modalCommentProfiles, @@ -575,6 +611,11 @@ export default class VideoModalCommentController { this.modalCommentState.videoKind || normalizeKind(video.kind); const videoAuthorPubkey = this.modalCommentState.videoAuthorPubkey || normalizeString(video.pubkey); + const videoRootId = + this.modalCommentState.videoRootId || normalizeString(video.videoRootId); + const videoRootRelay = + this.modalCommentState.videoRootRelay || + normalizeRelay(video.videoRootRelay || video.rootIdentifierRelay); if (!videoEventId) { this.callbacks.showError("Comments are unavailable for this video."); @@ -632,6 +673,12 @@ export default class VideoModalCommentController { publishPayload.videoAuthorPubkey = videoAuthorPubkey; publishPayload.rootAuthorPubkey = videoAuthorPubkey; } + if (videoRootId) { + publishPayload.rootIdentifier = videoRootId; + } + if (videoRootRelay) { + publishPayload.rootIdentifierRelay = videoRootRelay; + } if (parentCommentKind) { publishPayload.parentCommentKind = parentCommentKind; } diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 6efa2caf..230571f6 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import CommentThreadService from "../js/services/commentThreadService.js"; -import { listVideoComments } from "../js/nostr/commentEvents.js"; +import { COMMENT_EVENT_KIND, listVideoComments } from "../js/nostr/commentEvents.js"; import { buildCommentEvent } from "../js/nostrEventSchemas.js"; function createBaseVideo() { @@ -11,6 +11,7 @@ function createBaseVideo() { pubkey: "authorpk", kind: 30078, tags: [["d", "video-root"], ["alt", "ignored"]], + videoRootId: "video-root", }; } @@ -256,6 +257,7 @@ test( const hydrationRequests = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments, subscribeVideoComments, getProfileCacheEntry: (pubkey) => @@ -290,6 +292,7 @@ test( videoDefinitionAddress: "30078:authorpk:video-root", videoKind: "30078", videoAuthorPubkey: "authorpk", + rootIdentifier: "video-root", parentCommentId: "", }); assert.equal(lastFetchOptions.limit, service.defaultLimit); @@ -308,6 +311,7 @@ test( assert.equal(initialSnapshot.videoDefinitionAddress, "30078:authorpk:video-root"); assert.equal(initialSnapshot.videoKind, "30078"); assert.equal(initialSnapshot.videoAuthorPubkey, "authorpk"); + assert.equal(initialSnapshot.rootIdentifier, "video-root"); assert.equal(initialSnapshot.parentCommentKind, null); assert.equal(initialSnapshot.parentCommentPubkey, null); assert.deepStrictEqual(initialSnapshot.topLevelIds, [ @@ -358,6 +362,7 @@ test( assert.equal(appendPayload.parentCommentId, "comment-2"); assert.deepStrictEqual(appendPayload.commentIds, ["reply-2"]); assert.ok(appendPayload.commentsById instanceof Map); + assert.equal(appendPayload.rootIdentifier, "video-root"); assert.ok( appendPayload.commentsById.has("reply-2"), "appended payload should contain the new reply", @@ -401,6 +406,7 @@ test( const errors = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments: async (target, options) => { fetchTargets.push(target); fetchOptions.push(options); @@ -424,6 +430,7 @@ test( videoEventId: "video123", videoKind: "30078", videoAuthorPubkey: "authorpk", + rootIdentifier: "video-root", parentCommentId: "", }); assert.equal( @@ -457,6 +464,51 @@ test( }, ); +test( + "CommentThreadService requests and snapshots comments by root identifier", + async () => { + const video = { + id: "root-only-video", + pubkey: "rootpk", + kind: 30078, + videoRootId: "root-only", + tags: [], + }; + + const rootOnlyComment = { + id: "comment-root", + kind: COMMENT_EVENT_KIND, + pubkey: "commenter", + created_at: 1700000000, + content: "Root scoped", + tags: [["I", "root-only"], ["p", "rootpk"]], + }; + + let fetchTarget = null; + const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, + fetchVideoComments: async (target) => { + fetchTarget = target; + return [rootOnlyComment]; + }, + subscribeVideoComments: () => () => {}, + }); + + const threadReadyPayloads = []; + service.setCallbacks({ + onThreadReady: (payload) => threadReadyPayloads.push(payload), + }); + + const snapshot = await service.loadThread({ video }); + + assert.ok(fetchTarget, "fetch should run when only root id is provided"); + assert.equal(fetchTarget.rootIdentifier, "root-only"); + assert.equal(snapshot.rootIdentifier, "root-only"); + assert.equal(snapshot.commentsById.get("comment-root").content, "Root scoped"); + assert.equal(threadReadyPayloads[0].rootIdentifier, "root-only"); + }, +); + test( "CommentThreadService preserves raw video author pubkeys during hydration fetches", async () => { @@ -498,6 +550,7 @@ test( const subscribeTargets = []; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments, subscribeVideoComments: (target, _options = {}) => { subscribeTargets.push(target); @@ -555,6 +608,7 @@ test( let subscriptionHandler = null; const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, fetchVideoComments: async () => [], subscribeVideoComments: (_target, options = {}) => { subscriptionHandler = typeof options.onEvent === "function" ? options.onEvent : null; diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 1888dd76..e2927ee7 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -230,6 +230,8 @@ test( enableComments: true, kind: 30078, tags: [["d", "video-root"]], + videoRootId: "video-root", + videoRootRelay: "wss://root.example", }; controller.load(video); @@ -242,6 +244,8 @@ test( assert.equal(payload.videoEventId, "video123"); assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); assert.equal(payload.videoDefinitionAddress, "30078:AUTHORPK:video-root"); + assert.equal(payload.rootIdentifier, "video-root"); + assert.equal(payload.rootIdentifierRelay, "wss://root.example"); }, ); @@ -287,12 +291,16 @@ test( pubkey: "AUTHORPK", enableComments: true, kind: 30078, + videoRootId: "video-root", + videoRootRelay: "wss://root.example", // No tags provided; buildVideoAddressPointer should return an empty string. }; controller.load(video); controller.modalCommentState.videoDefinitionAddress = " 30078:AUTHORPK:video-root "; + controller.modalCommentState.videoRootId = " video-root "; + controller.modalCommentState.videoRootRelay = " wss://root.example "; controller.submit({ text: "Fallback pointer" }); @@ -305,6 +313,97 @@ test( "30078:AUTHORPK:video-root", "publish payload should reuse pointer from the loaded thread", ); + assert.equal(payload.rootIdentifier, "video-root"); + assert.equal(payload.rootIdentifierRelay, "wss://root.example"); + }, +); + +test( + "VideoModalCommentController preserves videoRootId when only root pointer is provided", + async () => { + const publishCalls = []; + const loadTargets = []; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: ({ onThreadReady }) => { + controller.commentThreadServiceReady = onThreadReady; + }, + teardown: () => {}, + defaultLimit: 10, + loadThread: async (target) => { + loadTargets.push(target); + return {}; + }, + processIncomingEvent: () => {}, + }, + videoModal: { + setCommentSectionCallbacks: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + setCommentsVisibility: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: () => {}, + }, + auth: { + isLoggedIn: () => true, + }, + services: { + publishComment: async (payload) => { + publishCalls.push(payload); + return { ok: true, event: { id: "comment", tags: [] } }; + }, + }, + utils: { + normalizeHexPubkey: (value) => value, + }, + }); + + const video = { + id: "video789", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + videoRootId: "root-only", + videoRootRelay: "wss://root-only", + tags: [], + }; + + controller.load(video); + await Promise.resolve(); + + assert.equal(loadTargets.length, 1, "loadThread should be invoked once"); + assert.equal(loadTargets[0].rootIdentifier, "root-only"); + assert.equal(loadTargets[0].rootIdentifierRelay, "wss://root-only"); + + const commentEvent = { + id: "comment-root", + pubkey: "commenter", + kind: COMMENT_EVENT_KIND, + content: "Root pointer only", + tags: [["I", "root-only"], ["p", "AUTHORPK"]], + }; + + controller.handleCommentThreadReady({ + videoEventId: "video789", + parentCommentId: null, + rootIdentifier: "root-only", + rootIdentifierRelay: "wss://root-only", + commentsById: new Map([["comment-root", commentEvent]]), + childrenByParent: new Map([[null, ["comment-root"]]]), + profiles: new Map(), + }); + + controller.submit({ text: "Posting" }); + await controller.modalCommentPublishPromise; + + assert.equal(publishCalls.length, 1, "publishComment should use root pointer"); + const payload = publishCalls[0]; + assert.equal(payload.rootIdentifier, "root-only"); + assert.equal(payload.rootIdentifierRelay, "wss://root-only"); }, ); @@ -557,6 +656,8 @@ test( videoDefinitionAddress: null, videoKind: "30078", videoAuthorPubkey: "AUTHORPK", + videoRootId: null, + videoRootRelay: null, parentCommentId: "parent-1", parentCommentKind: String(COMMENT_EVENT_KIND), parentCommentPubkey: null,