From a4e6db7151390df87b262b25852f33bfcbc66414 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:20:05 -0400 Subject: [PATCH] Allow comments without definition address --- js/services/commentThreadService.js | 7 +- js/ui/videoModalCommentController.js | 21 +++--- tests/comment-thread-service.test.mjs | 66 ++++++++++++++++++ tests/video-modal-comments.test.mjs | 96 +++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index 8d0a0feb..1e2ae21c 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -141,7 +141,7 @@ export default class CommentThreadService { this.parentCommentId = normalizeString(parentCommentId); this.activeRelays = Array.isArray(relays) ? [...relays] : null; - if (!this.videoEventId || !this.videoAddressPointer) { + if (!this.videoEventId) { this.emitError( new Error("Unable to resolve video pointer for comment thread."), ); @@ -158,10 +158,13 @@ export default class CommentThreadService { const fetchLimit = toPositiveInteger(limit, this.defaultLimit); const target = { videoEventId: this.videoEventId, - videoDefinitionAddress: this.videoAddressPointer, parentCommentId: this.parentCommentId, }; + if (this.videoAddressPointer) { + target.videoDefinitionAddress = this.videoAddressPointer; + } + let events = []; try { events = await this.fetchVideoComments(target, { diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index fde9ba10..4f156a76 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -132,7 +132,7 @@ export default class VideoModalCommentController { const videoEventId = normalizeString(video.id); const videoDefinitionAddress = buildVideoAddressPointer(video); - if (!videoEventId || !videoDefinitionAddress) { + if (!videoEventId) { this.resetModalCommentState({ hide: false }); this.videoModal.setCommentStatus?.( "Comments are unavailable for this video.", @@ -142,7 +142,7 @@ export default class VideoModalCommentController { this.modalCommentState = { videoEventId, - videoDefinitionAddress, + videoDefinitionAddress: videoDefinitionAddress || null, parentCommentId: null, }; this.modalCommentProfiles = new Map(); @@ -487,7 +487,7 @@ export default class VideoModalCommentController { const videoEventId = normalizeString(video.id); const videoDefinitionAddress = buildVideoAddressPointer(video); - if (!videoEventId || !videoDefinitionAddress) { + if (!videoEventId) { this.callbacks.showError("Comments are unavailable for this video."); return; } @@ -499,13 +499,18 @@ export default class VideoModalCommentController { reason: "submitting", }); + const publishPayload = { + videoEventId, + parentCommentId, + }; + + if (videoDefinitionAddress) { + publishPayload.videoDefinitionAddress = videoDefinitionAddress; + } + const publishPromise = Promise.resolve( this.services.publishComment( - { - videoEventId, - videoDefinitionAddress, - parentCommentId, - }, + publishPayload, { content: text, }, diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index aa1fb3e1..ac186682 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -205,6 +205,72 @@ test( }, ); +test( + "CommentThreadService loadThread falls back to event id when address is missing", + async () => { + const video = { + ...createBaseVideo(), + tags: [["alt", "ignored"]], + }; + + const fetchTargets = []; + const fetchOptions = []; + const subscribeTargets = []; + const subscribeOptions = []; + const errors = []; + + const service = new CommentThreadService({ + fetchVideoComments: async (target, options) => { + fetchTargets.push(target); + fetchOptions.push(options); + return []; + }, + subscribeVideoComments: (target, options = {}) => { + subscribeTargets.push(target); + subscribeOptions.push(options); + return () => {}; + }, + }); + + service.setCallbacks({ + onError: (error) => errors.push(error), + }); + + const snapshot = await service.loadThread({ video }); + + assert.equal(fetchTargets.length, 1, "fetch should run even without an address"); + assert.deepStrictEqual(fetchTargets[0], { + videoEventId: "video123", + parentCommentId: "", + }); + assert.equal( + "videoDefinitionAddress" in fetchTargets[0], + false, + "videoDefinitionAddress should be omitted when unavailable", + ); + + assert.deepStrictEqual(fetchOptions[0], { + limit: service.defaultLimit, + relays: null, + }); + + assert.equal( + subscribeTargets.length, + 1, + "subscribe should run even without an address", + ); + assert.deepStrictEqual(subscribeTargets[0], fetchTargets[0]); + assert.equal( + typeof subscribeOptions[0]?.onEvent, + "function", + "subscription handler should be provided", + ); + + assert.equal(errors.length, 0, "missing address should not emit errors"); + assert.equal(snapshot.videoEventId, "video123"); + }, +); + test( "CommentThreadService teardown cancels hydration timers", async () => { diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index ce666ffd..6135a492 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setupModal } from "./video-modal-accessibility.test.mjs"; +import VideoModalCommentController from "../js/ui/videoModalCommentController.js"; const FOCUSABLE_SELECTOR = [ "a[href]", @@ -174,6 +175,101 @@ test("VideoModal comment composer updates messaging and dispatches events", asyn assert.strictEqual(submitDetail.triggerElement, modal.commentsSubmitButton); }); +test( + "VideoModalCommentController publishes comment using event id fallback", + async () => { + const publishCalls = []; + const statusMessages = []; + let resetCalled = false; + const composerStates = []; + const appendedEvents = []; + + const videoModal = { + setCommentComposerState: (state) => { + composerStates.push({ ...state }); + }, + resetCommentComposer: () => { + resetCalled = true; + }, + setCommentStatus: (message) => { + statusMessages.push(message); + }, + appendComment: (event) => { + appendedEvents.push(event); + }, + }; + + const processedEvents = []; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: () => {}, + processIncomingEvent: (event) => { + processedEvents.push(event); + }, + }, + videoModal, + auth: { + isLoggedIn: () => true, + }, + services: { + publishComment: async (payload, eventData) => { + publishCalls.push([payload, eventData]); + return { + ok: true, + event: { + id: "legacy-comment", + pubkey: "pk1", + content: eventData.content, + tags: [], + }, + }; + }, + }, + utils: { + normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, + }, + }); + + controller.currentVideo = { + id: "legacy-video", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + tags: [], + }; + controller.modalCommentState.videoEventId = "legacy-video"; + + await controller.handleVideoModalCommentSubmit({ text: "Legacy support" }); + + assert.equal(publishCalls.length, 1, "publishComment should be invoked"); + const [payload, eventData] = publishCalls[0]; + assert.deepStrictEqual(payload, { + videoEventId: "legacy-video", + parentCommentId: null, + }); + assert.equal( + "videoDefinitionAddress" in payload, + false, + "payload should omit videoDefinitionAddress when unavailable", + ); + assert.equal(eventData.content, "Legacy support"); + + assert.equal(processedEvents.length, 1, "event should be processed optimistically"); + assert.equal(resetCalled, true, "composer should reset after publishing"); + assert.ok( + statusMessages.includes("Comment posted."), + "success message should be surfaced", + ); + assert.equal( + composerStates[composerStates.length - 1]?.disabled, + false, + "composer should be re-enabled after publishing", + ); + assert.equal(appendedEvents.length, 0, "optimistic insert should avoid duplicate append"); + }, +); + test("VideoModal comment section exposes aria landmarks and participates in focus trap", async (t) => { const { window, document, modal, playerModal, trigger, cleanup } = await setupModal(); t.after(cleanup);