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"; import { COMMENT_EVENT_KIND } from "../js/nostr/commentEvents.js"; const FOCUSABLE_SELECTOR = [ "a[href]", "area[href]", "button:not([disabled])", 'input:not([type="hidden"]):not([disabled])', "select:not([disabled])", "textarea:not([disabled])", "iframe", "object", "embed", '[contenteditable="true"]', "[tabindex]", "audio[controls]", "video[controls]", ].join(","); function createCommentEvent({ id, pubkey, content, createdAt, parentId = null }) { const tags = [["e", "video123"], ["a", "30078:authorpk:video-root"]]; if (parentId) { tags.push(["e", parentId]); } assert.ok( tags.some((tag) => Array.isArray(tag) && tag[0] === "e" && tag[1] === "video123"), "comment fixture should include root #e tag", ); assert.ok( tags.some( (tag) => Array.isArray(tag) && tag[0] === "a" && tag[1] === "30078:authorpk:video-root", ), "comment fixture should include root #a tag", ); return { id, pubkey, content, kind: 1, created_at: createdAt, tags, }; } test("VideoModal comment section toggles visibility and renders hydrated comments", async (t) => { const { document, modal, cleanup } = await setupModal(); t.after(cleanup); const commentsRoot = document.querySelector("[data-comments-root]"); assert.ok(commentsRoot, "comment root should exist"); modal.setCommentsVisibility(false); assert.equal(commentsRoot.hasAttribute("hidden"), true); assert.equal(commentsRoot.classList.contains("hidden"), true); modal.setCommentsVisibility(true); assert.equal(commentsRoot.hasAttribute("hidden"), false); assert.equal(commentsRoot.classList.contains("hidden"), false); const snapshot = { videoEventId: "video123", parentCommentId: null, commentsById: new Map([ [ "comment-1", createCommentEvent({ id: "comment-1", pubkey: "pk1", content: "First!", createdAt: 1700000000, }), ], [ "reply-1", createCommentEvent({ id: "reply-1", pubkey: "pk2", content: "Thanks for sharing", createdAt: 1700000005, parentId: "comment-1", }), ], ]), childrenByParent: new Map([ [null, ["comment-1"]], ["comment-1", ["reply-1"]], ]), profiles: new Map([ ["pk1", { name: "Alice" }], ["pk2", { name: "Bob" }], ]), }; modal.renderComments(snapshot); const renderedItems = Array.from( modal.commentsList.querySelectorAll("[data-comment-id]"), ); assert.equal(renderedItems.length, 2, "should render top-level comment and reply"); const topLevel = modal.commentsList.querySelector('[data-comment-id="comment-1"]'); assert.ok(topLevel, "top-level comment node should render"); assert.equal(topLevel.dataset.commentDepth, "0"); const reply = modal.commentsList.querySelector('[data-comment-id="reply-1"]'); assert.ok(reply, "reply node should render"); assert.equal(reply.dataset.commentDepth, "1"); assert.equal(reply.closest("ol").getAttribute("role"), "list"); const countLabel = document.querySelector("[data-comments-count]"); assert.equal(countLabel.textContent.trim(), "2 comments"); const emptyState = document.querySelector("[data-comments-empty]"); assert.equal(emptyState.hasAttribute("hidden"), true); assert.equal(modal.commentsList.getAttribute("data-empty"), null); }); test( "Unauthenticated users can read comments while the composer stays disabled", async (t) => { const { document, modal, cleanup } = await setupModal(); t.after(cleanup); const controller = new VideoModalCommentController({ commentThreadService: { defaultLimit: 2, setCallbacks: () => {}, teardown: () => {}, loadThread: () => Promise.resolve(), processIncomingEvent: () => {}, }, videoModal: modal, auth: { isLoggedIn: () => false, }, }); controller.load({ id: "video-login-required", enableComments: true }); await Promise.resolve(); controller.handleCommentThreadReady({ videoEventId: "video-login-required", parentCommentId: null, commentsById: new Map([ [ "guest-comment", createCommentEvent({ id: "guest-comment", pubkey: "pk-guest", content: "Looking forward to more uploads", createdAt: 1700000010, }), ], ]), childrenByParent: new Map([[null, ["guest-comment"]]]), profiles: new Map([["pk-guest", { name: "Guest" }]]), }); const commentsRoot = document.querySelector("[data-comments-root]"); assert.equal(commentsRoot.hasAttribute("hidden"), false); assert.equal(commentsRoot.classList.contains("hidden"), false); const renderedComments = Array.from( modal.commentsList.querySelectorAll("[data-comment-id]") || [], ); assert.equal(renderedComments.length, 1); assert.equal(renderedComments[0].dataset.commentId, "guest-comment"); assert.equal(modal.commentComposerState.reason, "login-required"); assert.equal(modal.commentsInput?.disabled, true); assert.equal(modal.commentsSubmitButton?.disabled, true); const disabledPlaceholder = document.querySelector( "[data-comments-disabled-placeholder]", ); assert.equal(disabledPlaceholder.hasAttribute("hidden"), true); assert.equal( disabledPlaceholder.textContent.trim(), "Comments have been turned off for this video.", ); }, ); test( "Guest users render loaded thread snapshots before composer auth enforcement", async (t) => { const { modal, cleanup } = await setupModal(); t.after(cleanup); const snapshot = { videoEventId: "video-loaded-snapshot", parentCommentId: null, commentsById: new Map([ [ "loaded-comment", createCommentEvent({ id: "loaded-comment", pubkey: "pk-loaded", content: "Snapshot content shows up", createdAt: 1700000100, }), ], ]), childrenByParent: new Map([[null, ["loaded-comment"]]]), profiles: new Map([["pk-loaded", { name: "Loaded" }]]), }; const controller = new VideoModalCommentController({ commentThreadService: { defaultLimit: 1, setCallbacks: () => {}, teardown: () => {}, loadThread: () => Promise.resolve(snapshot), }, videoModal: modal, auth: { isLoggedIn: () => false, }, }); controller.load({ id: "video-loaded-snapshot", enableComments: true }); await controller.modalCommentLoadPromise; const renderedComments = Array.from( modal.commentsList.querySelectorAll("[data-comment-id]") || [], ); assert.equal(renderedComments.length, 1); assert.equal(renderedComments[0].dataset.commentId, "loaded-comment"); const statusText = modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || ""; assert.equal(statusText, ""); assert.equal(modal.commentComposerState.reason, "login-required"); }, ); test( "Thread ready snapshots force comments visible before composer gating", async (t) => { const { modal, cleanup } = await setupModal(); t.after(cleanup); const visibilityCalls = []; const instrumentedModal = Object.create(modal); instrumentedModal.setCommentsVisibility = (isVisible) => { visibilityCalls.push(isVisible); modal.setCommentsVisibility(isVisible); }; const snapshot = { videoEventId: "video-visibility-snapshot", parentCommentId: null, commentsById: new Map([ [ "visible-comment", createCommentEvent({ id: "visible-comment", pubkey: "pk-visible", content: "Rendered as soon as thread is ready", createdAt: 1700000300, }), ], ]), childrenByParent: new Map([[null, ["visible-comment"]]]), profiles: new Map([["pk-visible", { name: "Visible" }]]), }; const controller = new VideoModalCommentController({ commentThreadService: { defaultLimit: 1, setCallbacks: () => {}, teardown: () => {}, loadThread: () => Promise.resolve(snapshot), }, videoModal: instrumentedModal, auth: { isLoggedIn: () => false, }, }); controller.load({ id: "video-visibility-snapshot", enableComments: true }); await controller.modalCommentLoadPromise; assert.ok(visibilityCalls.length >= 2); assert.equal(visibilityCalls.pop(), true); const renderedComments = Array.from( modal.commentsList.querySelectorAll("[data-comment-id]") || [], ); assert.equal(renderedComments.length, 1); assert.equal(renderedComments[0].dataset.commentId, "visible-comment"); }, ); test( "Controller renders synchronous loadThread snapshots immediately", async (t) => { const { modal, cleanup } = await setupModal(); t.after(cleanup); const snapshot = { videoEventId: "video-sync-snapshot", parentCommentId: null, commentsById: new Map([ [ "sync-comment", createCommentEvent({ id: "sync-comment", pubkey: "pk-sync", content: "Snapshot delivered instantly", createdAt: 1700000200, }), ], ]), childrenByParent: new Map([[null, ["sync-comment"]]]), profiles: new Map([["pk-sync", { name: "Synchronous" }]]), }; const controller = new VideoModalCommentController({ commentThreadService: { defaultLimit: 1, setCallbacks: () => {}, teardown: () => {}, loadThread: () => snapshot, }, videoModal: modal, auth: { isLoggedIn: () => false, }, }); controller.load({ id: "video-sync-snapshot", enableComments: true }); await Promise.resolve(); const renderedComments = Array.from( modal.commentsList.querySelectorAll("[data-comment-id]") || [], ); assert.equal(renderedComments.length, 1); assert.equal(renderedComments[0].dataset.commentId, "sync-comment"); const statusText = modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || ""; assert.equal(statusText, ""); assert.equal(modal.commentComposerState.reason, "login-required"); }, ); test( "VideoModalCommentController attaches profiles using normalized pubkeys", async () => { const appended = []; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, teardown: () => {}, loadThread: () => Promise.resolve(), }, videoModal: { setCommentSectionCallbacks: () => {}, hideCommentsDisabledMessage: () => {}, showCommentsDisabledMessage: () => {}, setCommentsVisibility: () => {}, clearComments: () => {}, resetCommentComposer: () => {}, setCommentStatus: () => {}, setCommentComposerState: () => {}, appendComment: (comment) => appended.push(comment), }, auth: { isLoggedIn: () => true, }, }); controller.load({ id: "VideoCase", enableComments: true }); await Promise.resolve(); controller.handleCommentThreadAppend({ videoEventId: "VideoCase", commentsById: new Map([ [ "c1", { id: "c1", pubkey: "AbCd", kind: COMMENT_EVENT_KIND, content: "Hi", tags: [], }, ], ]), childrenByParent: new Map([[null, ["c1"]]]), commentIds: ["c1"], profiles: new Map([["ABCD", { name: "Mixed Case" }]]), }); assert.equal(appended.length, 1); assert.deepEqual(appended[0].profile, { name: "Mixed Case" }); }, ); test( "VideoModalCommentController accepts mixed-case videoEventIds for snapshots", async () => { const renderedSnapshots = []; const appended = []; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, teardown: () => {}, loadThread: () => Promise.resolve(), processIncomingEvent: () => {}, }, videoModal: { setCommentSectionCallbacks: () => {}, hideCommentsDisabledMessage: () => {}, showCommentsDisabledMessage: () => {}, setCommentsVisibility: () => {}, clearComments: () => {}, resetCommentComposer: () => {}, setCommentStatus: () => {}, setCommentComposerState: () => {}, renderComments: (snapshot) => renderedSnapshots.push(snapshot), appendComment: (comment) => appended.push(comment), }, auth: { isLoggedIn: () => true, }, utils: { normalizeHexPubkey: (value) => value, }, }); controller.modalCommentState.videoEventId = "VideoCaseMixed"; const snapshot = { videoEventId: "videocasemixed", parentCommentId: null, commentsById: new Map([ [ "comment-1", { id: "comment-1", pubkey: "pk1", kind: COMMENT_EVENT_KIND, content: "First!", tags: [], }, ], ]), childrenByParent: new Map([[null, ["comment-1"]]]), profiles: new Map(), }; controller.handleCommentThreadReady(snapshot); const payload = { videoEventId: " VIDEOCASEMIXED ", commentsById: new Map([ [ "comment-2", { id: "comment-2", pubkey: "pk2", kind: COMMENT_EVENT_KIND, content: "Second!", tags: [], }, ], ]), childrenByParent: new Map([[null, ["comment-2"]]]), commentIds: ["comment-2"], profiles: new Map(), }; controller.handleCommentThreadAppend(payload); assert.equal(renderedSnapshots.length, 1, "snapshot should render despite casing"); assert.equal(appended.length, 1, "append should accept trimmed, mixed-case IDs"); }, ); test("VideoModal comment composer updates messaging and dispatches events", async (t) => { const { window, document, modal, cleanup } = await setupModal(); t.after(cleanup); const defaultHint = modal.commentComposerDefaultHint; assert.ok(defaultHint); modal.setCommentComposerState({ disabled: true, reason: "login-required" }); assert.equal(modal.commentsInput.disabled, true); assert.equal(modal.commentsComposer.hasAttribute("hidden"), false); assert.equal(modal.commentsDisabledPlaceholder.hasAttribute("hidden"), true); assert.equal(modal.commentComposerHint.textContent.trim(), "Log in to add a comment."); assert.equal(modal.commentsStatusMessage.textContent.trim(), ""); modal.setCommentComposerState({ disabled: false, reason: "error" }); assert.equal(modal.commentsInput.disabled, false); assert.equal( modal.commentsStatusMessage.textContent.includes( "We couldn't post your comment. Try again?", ), true, ); assert.ok(modal.commentRetryButton, "retry button should be rendered for error state"); modal.showCommentsDisabledMessage("Comments have been turned off for this video."); modal.setCommentComposerState({ disabled: true, reason: "disabled" }); assert.equal(modal.commentsComposer.hasAttribute("hidden"), true); assert.equal(modal.commentsDisabledPlaceholder.hasAttribute("hidden"), false); modal.setCommentComposerState({ disabled: false, reason: "" }); assert.equal(modal.commentsComposer.hasAttribute("hidden"), false); assert.equal(modal.commentsDisabledPlaceholder.hasAttribute("hidden"), true); // Ensure the hint text isn't empty after reset assert.ok(modal.commentComposerHint.textContent.trim().length > 0); const loginRequests = []; const submitEvents = []; modal.addEventListener("comment:login-required", (event) => { loginRequests.push(event.detail); }); modal.addEventListener("comment:submit", (event) => { submitEvents.push(event.detail); }); modal.setCommentComposerState({ disabled: true, reason: "login-required" }); modal.commentsComposer.dispatchEvent( new window.Event("submit", { bubbles: true, cancelable: true }), ); assert.equal(loginRequests.length, 1); assert.equal(submitEvents.length, 0); modal.setCommentComposerState({ disabled: false, reason: "" }); modal.commentsInput.value = " New comment "; modal.updateCommentCharCount(); modal.updateCommentSubmitState(); assert.equal(modal.commentsSubmitButton.disabled, false); modal.commentsComposer.dispatchEvent( new window.Event("submit", { bubbles: true, cancelable: true }), ); assert.equal(submitEvents.length, 1); const submitDetail = submitEvents[0]; assert.deepStrictEqual(submitDetail.parentId, null); assert.equal(submitDetail.text, "New comment"); assert.strictEqual(submitDetail.triggerElement, modal.commentsSubmitButton); }); test( "VideoModalCommentController load preserves current video for submissions", async () => { const publishCalls = []; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, teardown: () => {}, defaultLimit: 10, loadThread: () => Promise.resolve(), processIncomingEvent: () => {}, }, videoModal: { setCommentSectionCallbacks: () => {}, hideCommentsDisabledMessage: () => {}, showCommentsDisabledMessage: () => {}, setCommentsVisibility: () => {}, clearComments: () => {}, resetCommentComposer: () => {}, setCommentStatus: () => {}, setCommentComposerState: () => {}, }, auth: { isLoggedIn: () => true, }, services: { publishComment: async (payload) => { publishCalls.push(payload); return { ok: true, event: { id: "comment", tags: [] } }; }, }, }); const video = { id: "video123", pubkey: "AUTHORPK", enableComments: true, kind: 30078, tags: [["d", "video-root"]], videoRootId: "video-root", videoRootRelay: "wss://root.example", }; controller.load(video); controller.submit({ text: "Nice" }); await controller.modalCommentPublishPromise; assert.equal(publishCalls.length, 1, "publishComment should be called once"); const payload = publishCalls[0]; 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"); }, ); test( "VideoModalCommentController falls back to thread pointer when video tags are absent", async () => { const publishCalls = []; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, teardown: () => {}, defaultLimit: 10, loadThread: () => Promise.resolve(), processIncomingEvent: () => {}, }, videoModal: { setCommentSectionCallbacks: () => {}, hideCommentsDisabledMessage: () => {}, showCommentsDisabledMessage: () => {}, setCommentsVisibility: () => {}, clearComments: () => {}, resetCommentComposer: () => {}, setCommentStatus: () => {}, setCommentComposerState: () => {}, }, auth: { isLoggedIn: () => true, }, services: { publishComment: async (payload) => { publishCalls.push(payload); return { ok: true, event: { id: "comment", tags: [] } }; }, }, }); const video = { id: "video456", 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" }); await controller.modalCommentPublishPromise; assert.equal(publishCalls.length, 1); const payload = publishCalls[0]; assert.equal( payload.videoDefinitionAddress, "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 = []; let threadReady = null; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: ({ onThreadReady }) => { threadReady = 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: [] } }; }, }, }); controller.commentThreadServiceReady = threadReady; 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]?.video?.videoRootId, "root-only"); assert.equal(loadTargets[0]?.video?.videoRootRelay, "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"); }, ); test( "VideoModalCommentController uses pointerIdentifiers root id fallback", async () => { const publishCalls = []; const loadTargets = []; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, 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: [] } }; }, }, }); const video = { id: "pointer-video", pubkey: "AUTHORPK", enableComments: true, kind: 30078, pointerIdentifiers: { videoRootId: "pointer-root", }, }; controller.load(video); await Promise.resolve(); assert.equal(loadTargets.length, 1, "loadThread should capture pointer target"); assert.equal( loadTargets[0]?.video?.pointerIdentifiers?.videoRootId, "pointer-root", "pointerIdentifiers should carry the normalized root id", ); controller.submit({ text: "Pointer fallback" }); await controller.modalCommentPublishPromise; assert.equal(publishCalls.length, 1, "publishComment should run once"); const payload = publishCalls[0]; assert.equal(payload.rootIdentifier, "pointer-root"); }, ); 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: [], }, }; }, }, }); 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, videoKind: "30078", videoAuthorPubkey: "authorpk", rootKind: "30078", rootAuthorPubkey: "authorpk", }); 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( "VideoModalCommentController disposes safely during in-flight comment loads", async () => { const renderCalls = []; const appendCalls = []; const teardownCalls = []; const setCallbacksCalls = []; let onThreadReady = null; let onCommentsAppended = null; let resolveLoadThread; const commentThreadService = { defaultLimit: 5, setCallbacks: ({ onThreadReady: ready, onCommentsAppended: appended }) => { onThreadReady = ready; onCommentsAppended = appended; }, loadThread: () => new Promise((resolve) => { resolveLoadThread = resolve; }), teardown: () => { teardownCalls.push("teardown"); }, processIncomingEvent: () => {}, }; const videoModal = { setCommentSectionCallbacks: (callbacks) => { setCallbacksCalls.push(callbacks); }, setCommentsVisibility: () => {}, hideCommentsDisabledMessage: () => {}, showCommentsDisabledMessage: () => {}, clearComments: () => {}, resetCommentComposer: () => {}, setCommentStatus: () => {}, setCommentComposerState: () => {}, renderComments: (payload) => renderCalls.push(payload), appendComment: (payload) => appendCalls.push(payload), }; const controller = new VideoModalCommentController({ commentThreadService, videoModal, auth: { isLoggedIn: () => true }, callbacks: { showError: () => {}, showStatus: () => {}, muteAuthor: () => Promise.resolve(), shouldHideAuthor: () => false, }, services: { publishComment: async () => ({ ok: true, event: { id: "event", tags: [] } }), }, }); controller.load({ id: "video-inflight", pubkey: "AUTHORPK", enableComments: true, kind: 30078, tags: [], }); await Promise.resolve(); teardownCalls.length = 0; // ignore the initial dispose inside load controller.dispose(); assert.equal(teardownCalls.length, 1, "dispose should teardown the active thread"); assert.equal( setCallbacksCalls.length > 0, true, "comment callbacks should be registered even before load resolves", ); resolveLoadThread?.(); await Promise.resolve(); onThreadReady?.({ videoEventId: "video-inflight", parentCommentId: null, commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), childrenByParent: new Map([[null, ["event-1"]]]), profiles: new Map(), }); assert.equal(renderCalls.length, 0, "stale thread payloads should be ignored after dispose"); onCommentsAppended?.({ videoEventId: "video-inflight", parentCommentId: null, commentIds: ["event-1"], commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), childrenByParent: new Map([[null, ["event-1"]]]), profiles: new Map(), }); assert.equal( appendCalls.length, 0, "stale appends should not render duplicate comments after dispose", ); }, ); test( "VideoModalCommentController prompts login when publish requires authentication", async () => { const composerStates = []; const loginLifecycle = []; const errorMessages = []; const loginModal = { openModal: ({ triggerElement } = {}) => { loginLifecycle.push({ type: "openModal", triggerElement }); return true; }, }; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, processIncomingEvent: () => {}, }, videoModal: { setCommentComposerState: (state) => { composerStates.push({ ...state }); }, resetCommentComposer: () => { throw new Error("composer should not reset when auth is required"); }, setCommentStatus: () => {}, appendComment: () => {}, }, auth: { isLoggedIn: () => true, initializeLoginModalController: ({ logIfMissing }) => { loginLifecycle.push({ type: "initialize", logIfMissing }); }, getLoginModalController: () => loginModal, requestLogin: ({ allowAccountSelection }) => { loginLifecycle.push({ type: "request", allowAccountSelection }); return Promise.resolve(true); }, }, callbacks: { showError: (message) => { errorMessages.push(message); }, showStatus: () => {}, muteAuthor: () => Promise.resolve(), shouldHideAuthor: () => false, }, services: { publishComment: async () => ({ ok: false, error: "auth-required" }), }, utils: { normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, }, }); controller.currentVideo = { id: "video-auth", pubkey: "AUTHORPK", enableComments: true, kind: 30078, tags: [], }; controller.modalCommentState.videoEventId = "video-auth"; controller.modalCommentState.videoKind = "30078"; controller.modalCommentState.videoAuthorPubkey = "AUTHORPK"; const triggerElement = { id: "comment-trigger" }; await controller.handleVideoModalCommentSubmit({ text: "Needs auth", triggerElement, }); const lastComposerState = composerStates[composerStates.length - 1]; assert.deepStrictEqual( lastComposerState, { disabled: true, reason: "login-required" }, "composer should remain disabled while prompting for login", ); assert.equal(errorMessages.length, 0, "auth-required should not surface as an error"); assert.equal( loginLifecycle.some((step) => step.type === "initialize"), true, "login modal controller should be initialized", ); assert.equal( loginLifecycle.some((step) => step.type === "openModal"), true, "login modal should attempt to open", ); const modalOpen = loginLifecycle.find((step) => step.type === "openModal"); assert.equal( modalOpen?.triggerElement, triggerElement, "trigger element should propagate to login modal", ); }, ); test( "VideoModalCommentController includes parent metadata when replying", async () => { const publishCalls = []; const parentEvent = { id: "parent-1", kind: COMMENT_EVENT_KIND, pubkey: "parentpk", tags: [], }; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: () => {}, getCommentEvent: (id) => (id === "parent-1" ? parentEvent : null), processIncomingEvent: () => {}, }, videoModal: { setCommentComposerState: () => {}, setCommentStatus: () => {}, resetCommentComposer: () => {}, appendComment: () => {}, }, auth: { isLoggedIn: () => true, }, services: { publishComment: async (payload) => { publishCalls.push(payload); return { ok: true, event: { id: "reply", tags: [] } }; }, }, }); controller.currentVideo = { id: "video123", pubkey: "AUTHORPK", enableComments: true, kind: 30078, }; controller.modalCommentState = { videoEventId: "video123", videoDefinitionAddress: null, videoKind: "30078", videoAuthorPubkey: "AUTHORPK", videoRootId: null, videoRootRelay: null, parentCommentId: "parent-1", parentCommentKind: String(COMMENT_EVENT_KIND), parentCommentPubkey: null, }; await controller.handleVideoModalCommentSubmit({ text: "Replying", parentId: "parent-1", }); assert.equal(publishCalls.length, 1, "reply should trigger publish call"); const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); assert.equal(payload.videoKind, "30078"); assert.equal(payload.videoAuthorPubkey, "authorpk"); assert.equal(payload.parentCommentId, "parent-1"); assert.equal(payload.parentCommentKind, String(COMMENT_EVENT_KIND)); assert.equal(payload.parentCommentPubkey, "parentpk"); assert.equal(payload.parentAuthorPubkey, "parentpk"); assert.equal(payload.rootAuthorPubkey, "authorpk"); }, ); 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); modal.open(null, { triggerElement: trigger }); await Promise.resolve(); const commentsRoot = document.querySelector("[data-comments-root]"); const commentsHeading = document.getElementById("videoModalCommentsHeading"); assert.ok(commentsRoot, "comment root should exist when modal opens"); assert.ok(commentsHeading, "comment heading should exist"); assert.equal(commentsRoot.getAttribute("role"), "region"); assert.equal(commentsRoot.getAttribute("aria-labelledby"), commentsHeading.id); assert.equal(modal.commentsComposer.getAttribute("aria-labelledby"), commentsHeading.id); const commentInput = document.querySelector("[data-comments-input]"); assert.ok(commentInput, "comment textarea should exist"); assert.equal( commentInput.getAttribute("aria-describedby"), "commentComposerHelp commentComposerCount", ); const focusableElements = Array.from( playerModal.querySelectorAll(FOCUSABLE_SELECTOR), ); focusableElements.forEach((element) => { if (element === commentInput) { element.removeAttribute("tabindex"); element.disabled = false; return; } if (typeof element.disabled === "boolean") { element.disabled = true; } element.setAttribute("tabindex", "-1"); }); commentInput.focus(); const tabEvent = new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, }); commentInput.dispatchEvent(tabEvent); assert.equal(tabEvent.defaultPrevented, true, "focus trap should intercept Tab"); const activeAfter = document.activeElement; assert.ok( activeAfter === commentInput || activeAfter === modal.modalPanel, "focus trap should keep focus within the modal", ); modal.close(); });