Files
bitvid/tests/video-modal-comments.test.mjs
T

1269 lines
38 KiB
JavaScript

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 = {
...modal,
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: [],
},
],
]),
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: [],
},
],
]),
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);
assert.equal(modal.commentComposerHint.textContent.trim(), defaultHint.trim());
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();
});