mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- Mock HTMLMediaElement.prototype.pause/load and window.scrollTo in test setup to prevent JSDOM "Not implemented" errors. - Fix VideoModal mock in comments tests to preserve prototype chain (using Object.create instead of spread). - Add missing childrenByParent map to comments test payloads to fix rendering assertions. Co-authored-by: PR0M3TH3AN <[email protected]>
1269 lines
38 KiB
JavaScript
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 = 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);
|
|
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();
|
|
});
|