Refactor VideoModal to delegate comments to CommentsController

Move comment-related logic and state from VideoModal.js to CommentsController.js.
Split CommentsController into CommentsController (orchestration), CommentsListRenderer (DOM management), and CommentNodeFactory (element creation) to maintain modularity and adhere to file size limits.
Update VideoModal to initialize and destroy CommentsController correctly.
Fix minor lint issues.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-26 17:53:34 +00:00
1 parent 9913e484ab
commit feb7ad765a
4 files changed
+1547 -2158

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,402 @@
import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js";
import { formatAbsoluteTimestamp, formatTimeAgo } from "../../../utils/formatters.js";
import { registerCommentAvatarFailure as registerCommentAvatarFailureUtil } from "./utils/commentAvatar.js";
export class CommentNodeFactory {
constructor({ document, window, logger, dispatch, DEFAULT_PROFILE_AVATAR }) {
this.document = document;
this.window = window;
this.logger = logger;
this.dispatch = dispatch;
this.DEFAULT_PROFILE_AVATAR = DEFAULT_PROFILE_AVATAR;
}
createCommentNode(event, { depth = 0 } = {}) {
if (!event || typeof event !== "object" || !this.document) {
return null;
}
const commentId =
typeof event.id === "string" && event.id.trim() ? event.id.trim() : "";
if (!commentId) {
return null;
}
const listItem = this.document.createElement("li");
listItem.classList.add("comment-thread__item");
listItem.dataset.commentId = commentId;
listItem.dataset.commentDepth = String(Math.max(0, depth));
listItem.dataset.timestamp = String(
Number.isFinite(event.created_at) ? event.created_at : 0
);
if (typeof event.pubkey === "string" && event.pubkey.trim()) {
listItem.dataset.commentAuthor = event.pubkey.trim();
}
const avatarWrapper = this.document.createElement("div");
avatarWrapper.classList.add("comment-thread__avatar");
const avatarImg = this.document.createElement("img");
avatarImg.hidden = true;
avatarImg.loading = "lazy";
avatarImg.decoding = "async";
avatarWrapper.appendChild(avatarImg);
const avatarLabel = this.document.createElement("span");
avatarWrapper.appendChild(avatarLabel);
const content = this.document.createElement("div");
content.classList.add("comment-thread__content");
const meta = this.document.createElement("div");
meta.classList.add("comment-thread__meta");
const authorEl = this.document.createElement("span");
authorEl.classList.add("comment-thread__author");
meta.appendChild(authorEl);
const timestamp = this.getCommentTimestampLabel(event.created_at);
if (timestamp.text) {
const timeEl = this.document.createElement("time");
timeEl.classList.add("comment-thread__timestamp");
if (timestamp.iso) {
timeEl.setAttribute("datetime", timestamp.iso);
}
if (timestamp.title) {
timeEl.title = timestamp.title;
}
timeEl.appendChild(this.document.createTextNode(timestamp.text));
meta.appendChild(timeEl);
}
content.appendChild(meta);
const identityMeta = this.document.createElement("div");
identityMeta.classList.add(
"flex",
"items-center",
"gap-2",
"text-2xs",
"text-muted-strong"
);
identityMeta.hidden = true;
const npubLabel = this.document.createElement("span");
npubLabel.classList.add("truncate");
identityMeta.appendChild(npubLabel);
content.appendChild(identityMeta);
const body = this.document.createElement("p");
body.classList.add("comment-thread__body");
body.appendChild(
this.document.createTextNode(
typeof event.content === "string" ? event.content : ""
)
);
content.appendChild(body);
const actions = this.document.createElement("div");
actions.classList.add("comment-thread__actions");
const copyButton = this.document.createElement("button");
copyButton.type = "button";
copyButton.classList.add("comment-thread__copy-npub");
copyButton.textContent = "Copy npub";
copyButton.dataset.originalLabel = "Copy npub";
copyButton.hidden = true;
this.bindCommentCopyHandler(copyButton);
actions.appendChild(copyButton);
const muteButton = this.document.createElement("button");
muteButton.type = "button";
muteButton.classList.add("comment-thread__mute");
muteButton.appendChild(this.document.createTextNode("Mute author"));
muteButton.addEventListener("click", (domEvent) => {
const triggerElement = domEvent?.currentTarget || muteButton;
this.dispatch("comment:mute-author", {
commentId,
pubkey:
typeof event.pubkey === "string" && event.pubkey
? event.pubkey
: "",
triggerElement,
});
});
actions.appendChild(muteButton);
content.appendChild(actions);
const replies = this.document.createElement("ul");
replies.classList.add("comment-thread__replies");
replies.setAttribute("data-comment-replies", "");
content.appendChild(replies);
listItem.appendChild(avatarWrapper);
listItem.appendChild(content);
return {
listItem,
replies,
elements: {
avatarImg,
avatarLabel,
authorEl,
npubContainer: identityMeta,
npubLabel,
copyButton,
}
};
}
getCommentTimestampLabel(createdAt) {
const numeric = Number(createdAt);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { text: "", iso: "", title: "" };
}
const date = new Date(numeric * 1000);
if (Number.isNaN(date.getTime())) {
return { text: "", iso: "", title: "" };
}
let text = "";
try {
text = formatTimeAgo(numeric);
} catch (error) {
this.logger.log("[VideoModal] Failed to format comment timestamp", error);
}
if (!text) {
text = formatAbsoluteTimestamp(numeric);
}
return {
text,
iso: date.toISOString(),
title: formatAbsoluteTimestamp(numeric),
};
}
bindCommentCopyHandler(button) {
if (!button) {
return;
}
button.addEventListener("click", async () => {
const npub =
typeof button.dataset.npub === "string" && button.dataset.npub
? button.dataset.npub
: "";
const originalLabel =
typeof button.dataset.originalLabel === "string" &&
button.dataset.originalLabel
? button.dataset.originalLabel
: "Copy npub";
if (!button.dataset.originalLabel) {
button.dataset.originalLabel = originalLabel;
}
if (!npub) {
this.showCommentCopyFeedback(button, "Copy failed", "error");
return;
}
try {
const clipboard = this.window?.navigator?.clipboard;
if (!clipboard || typeof clipboard.writeText !== "function") {
throw new Error("Clipboard unavailable");
}
await clipboard.writeText(npub);
this.showCommentCopyFeedback(button, "Copied!", "copied");
} catch (error) {
this.logger.log("[VideoModal] Failed to copy comment npub", error);
this.showCommentCopyFeedback(button, "Copy failed", "error");
}
});
}
showCommentCopyFeedback(button, message, state = "") {
if (!button) {
return;
}
const originalLabel =
typeof button.dataset.originalLabel === "string" &&
button.dataset.originalLabel
? button.dataset.originalLabel
: "Copy npub";
button.textContent = message;
if (state) {
button.dataset.state = state;
} else if (button.dataset.state) {
delete button.dataset.state;
}
const timeoutId = Number.parseInt(
button.dataset.feedbackTimeoutId || "",
10
);
if (
Number.isFinite(timeoutId) &&
this.window &&
typeof this.window.clearTimeout === "function"
) {
this.window.clearTimeout(timeoutId);
}
if (!this.window || typeof this.window.setTimeout !== "function") {
button.textContent = button.dataset.originalLabel || originalLabel;
if (button.dataset.feedbackTimeoutId) {
delete button.dataset.feedbackTimeoutId;
}
return;
}
const nextId = this.window.setTimeout(() => {
button.textContent = button.dataset.originalLabel || originalLabel;
if (button.dataset.state) {
delete button.dataset.state;
}
if (button.dataset.feedbackTimeoutId) {
delete button.dataset.feedbackTimeoutId;
}
}, UI_FEEDBACK_DELAY_MS);
button.dataset.feedbackTimeoutId = String(nextId);
}
updateCommentNodeProfile(refs, profile, cache, failures) {
if (!refs) {
return;
}
if (refs.authorEl) {
refs.authorEl.textContent = profile.displayName;
}
if (refs.avatarLabel) {
refs.avatarLabel.textContent = profile.initial;
refs.avatarLabel.hidden = Boolean(profile.avatarUrl);
}
if (refs.avatarImg) {
if (!refs.avatarImg.dataset.commentAvatarBound) {
const labelRef = refs.avatarLabel;
const imgRef = refs.avatarImg;
refs.avatarImg.addEventListener("error", () => {
const failedSource =
typeof imgRef?.dataset?.commentAvatarSource === "string"
? imgRef.dataset.commentAvatarSource
: "";
if (failedSource) {
registerCommentAvatarFailureUtil({
cache,
failures,
defaultAvatar: this.DEFAULT_PROFILE_AVATAR,
sourceUrl: failedSource,
});
}
if (
imgRef &&
imgRef.dataset.commentAvatarCurrent !== this.DEFAULT_PROFILE_AVATAR
) {
imgRef.dataset.commentAvatarCurrent = this.DEFAULT_PROFILE_AVATAR;
imgRef.src = this.DEFAULT_PROFILE_AVATAR;
return;
}
if (imgRef) {
imgRef.hidden = true;
}
if (labelRef) {
labelRef.hidden = false;
}
});
refs.avatarImg.dataset.commentAvatarBound = "true";
}
const nextSource = profile.avatarSource || "";
refs.avatarImg.dataset.commentAvatarSource = nextSource;
if (profile.avatarUrl) {
if (
refs.avatarImg.dataset.commentAvatarCurrent !== profile.avatarUrl
) {
refs.avatarImg.dataset.commentAvatarCurrent = profile.avatarUrl;
refs.avatarImg.src = profile.avatarUrl;
}
refs.avatarImg.alt = `${profile.displayName}'s avatar`;
refs.avatarImg.hidden = false;
if (refs.avatarLabel) {
refs.avatarLabel.hidden = true;
}
} else {
if (
refs.avatarImg.dataset.commentAvatarCurrent !==
this.DEFAULT_PROFILE_AVATAR
) {
refs.avatarImg.dataset.commentAvatarCurrent =
this.DEFAULT_PROFILE_AVATAR;
refs.avatarImg.src = this.DEFAULT_PROFILE_AVATAR;
}
refs.avatarImg.hidden = true;
if (refs.avatarLabel) {
refs.avatarLabel.hidden = false;
}
}
}
if (refs.npubContainer) {
if (profile.shortNpub) {
refs.npubContainer.hidden = false;
if (refs.npubLabel) {
refs.npubLabel.textContent = profile.shortNpub;
if (profile.npub) {
refs.npubLabel.title = profile.npub;
} else if (refs.npubLabel.title) {
refs.npubLabel.removeAttribute("title");
}
}
} else {
refs.npubContainer.hidden = true;
if (refs.npubLabel) {
refs.npubLabel.textContent = "";
if (refs.npubLabel.title) {
refs.npubLabel.removeAttribute("title");
}
}
}
}
if (refs.copyButton) {
const originalLabel =
typeof refs.copyButton.dataset.originalLabel === "string" &&
refs.copyButton.dataset.originalLabel
? refs.copyButton.dataset.originalLabel
: "Copy npub";
if (profile.npub) {
refs.copyButton.hidden = false;
refs.copyButton.disabled = false;
refs.copyButton.dataset.npub = profile.npub;
refs.copyButton.setAttribute(
"aria-label",
`Copy ${profile.displayName}'s npub`
);
refs.copyButton.textContent = originalLabel;
if (refs.copyButton.dataset.state) {
delete refs.copyButton.dataset.state;
}
} else {
refs.copyButton.hidden = true;
refs.copyButton.disabled = true;
if (refs.copyButton.dataset.state) {
delete refs.copyButton.dataset.state;
}
if (refs.copyButton.dataset.npub) {
delete refs.copyButton.dataset.npub;
}
refs.copyButton.removeAttribute("aria-label");
refs.copyButton.textContent = originalLabel;
}
}
}
}
+489 -109
View File
@@ -1,10 +1,67 @@
import logger from "../../../utils/logger.js";
import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js";
import { CommentsListRenderer } from "./commentsListRenderer.js";
export class CommentsController {
constructor({ modal } = {}) {
this.modal = modal;
this.root = null;
this.playerModal = null;
this.renderer = new CommentsListRenderer({ controller: this });
this.commentsRoot = null;
this.commentsContainer = null;
// Composer elements
this.commentsComposer = null;
this.commentsInput = null;
this.commentsSubmitButton = null;
this.commentsStatusMessage = null;
this.commentsCharCount = null;
this.commentComposerHint = null;
this.commentRetryButton = null;
this.commentsDisabledPlaceholder = null;
this.commentsDisabledPlaceholderDefaultText = "";
// Composer state
this.commentComposerDefaultHint = "";
this.commentComposerDefaultCountText = "";
this.commentStatusDefaultText = "";
this.commentComposerMaxLength = 0;
this.commentComposerState = {
disabled: true,
reason: "",
parentCommentId: null,
};
this.commentCallbacks = {
teardown: null,
};
this.boundCommentSubmitHandler =
this.handleCommentComposerSubmit.bind(this);
this.boundCommentInputHandler =
this.handleCommentComposerInput.bind(this);
this.boundCommentRetryHandler = this.handleCommentRetry.bind(this);
}
get document() {
return this.modal?.document || this.playerModal?.ownerDocument || (typeof document !== 'undefined' ? document : null);
}
get window() {
return this.modal?.window || this.document?.defaultView || (typeof window !== 'undefined' ? window : null);
}
get logger() {
return this.modal?.logger || logger;
}
get commentsList() {
return this.renderer?.listRoot;
}
dispatch(type, detail, options) {
return this.modal?.dispatch(type, detail, options);
}
initialize({ playerModal } = {}) {
@@ -20,15 +77,14 @@ export class CommentsController {
this.ensureCommentsDisabledPlaceholder({ root }) ||
null;
this.root = root;
this.modal.commentsRoot = root;
this.modal.commentsContainer = root;
this.modal.commentsDisabledPlaceholder = commentsDisabledPlaceholder;
this.commentsRoot = root;
this.commentsContainer = root;
this.commentsDisabledPlaceholder = commentsDisabledPlaceholder;
if (commentsDisabledPlaceholder) {
const disabledText = commentsDisabledPlaceholder.textContent || "";
if (disabledText) {
this.modal.commentsDisabledPlaceholderDefaultText = disabledText;
this.commentsDisabledPlaceholderDefaultText = disabledText;
}
}
@@ -38,52 +94,57 @@ export class CommentsController {
);
this.disableComments({
reason: "missing-root",
message: this.modal.commentsDisabledPlaceholderDefaultText ||
message: this.commentsDisabledPlaceholderDefaultText ||
"Comments are unavailable right now.",
});
return;
}
this.modal.commentsCountLabel =
root?.querySelector("[data-comments-count]") || null;
this.modal.commentsEmptyState =
root?.querySelector("[data-comments-empty]") || null;
this.modal.commentsList =
root?.querySelector("[data-comments-list]") || null;
this.modal.commentsLoadMoreButton =
root?.querySelector("[data-comments-load-more]") || null;
this.modal.commentsComposer =
// Initialize Renderer
const commentsList = root?.querySelector("[data-comments-list]") || null;
const loadMoreButton = root?.querySelector("[data-comments-load-more]") || null;
const countLabel = root?.querySelector("[data-comments-count]") || null;
const emptyState = root?.querySelector("[data-comments-empty]") || null;
this.renderer.initialize({
list: commentsList,
loadMoreButton,
countLabel,
emptyState
});
this.commentsComposer =
root?.querySelector("[data-comments-composer]") || null;
this.modal.commentsInput =
this.modal.commentsComposer?.querySelector("[data-comments-input]") || null;
this.modal.commentsSubmitButton =
this.modal.commentsComposer?.querySelector("[data-comments-submit]") || null;
this.modal.commentsStatusMessage =
this.modal.commentsComposer?.querySelector("[data-comments-status]") ||
this.commentsInput =
this.commentsComposer?.querySelector("[data-comments-input]") || null;
this.commentsSubmitButton =
this.commentsComposer?.querySelector("[data-comments-submit]") || null;
this.commentsStatusMessage =
this.commentsComposer?.querySelector("[data-comments-status]") ||
root?.querySelector("[data-comments-status]") ||
null;
this.modal.commentsCharCount =
this.modal.commentsComposer?.querySelector("[data-comments-char-count]") ||
this.commentsCharCount =
this.commentsComposer?.querySelector("[data-comments-char-count]") ||
null;
this.modal.commentComposerHint =
this.modal.commentsComposer?.querySelector("#commentComposerHelp") ||
this.modal.commentsComposer?.querySelector(".comment-composer__hint") ||
this.modal.commentComposerHint ||
this.commentComposerHint =
this.commentsComposer?.querySelector("#commentComposerHelp") ||
this.commentsComposer?.querySelector(".comment-composer__hint") ||
this.commentComposerHint ||
null;
const missingSelectors = [
{ selector: "[data-comments-list]", element: this.modal.commentsList },
{ selector: "[data-comments-list]", element: commentsList },
{
selector: "[data-comments-composer]",
element: this.modal.commentsComposer,
element: this.commentsComposer,
},
{
selector: "[data-comments-input]",
element: this.modal.commentsInput,
element: this.commentsInput,
},
{
selector: "[data-comments-submit]",
element: this.modal.commentsSubmitButton,
element: this.commentsSubmitButton,
},
]
.filter(({ element }) => !element)
@@ -95,7 +156,7 @@ export class CommentsController {
);
this.disableComments({
reason: "missing-selectors",
message: this.modal.commentsDisabledPlaceholderDefaultText ||
message: this.commentsDisabledPlaceholderDefaultText ||
"Comments are unavailable right now.",
});
return;
@@ -106,56 +167,384 @@ export class CommentsController {
this.playerModal.classList?.remove("comments-disabled");
}
if (this.modal.commentComposerHint) {
const hintText = this.modal.commentComposerHint.textContent || "";
if (this.commentComposerHint) {
const hintText = this.commentComposerHint.textContent || "";
if (hintText) {
this.modal.commentComposerDefaultHint = hintText;
this.commentComposerDefaultHint = hintText;
}
}
if (this.modal.commentsCharCount) {
const countText = this.modal.commentsCharCount.textContent || "";
if (this.commentsCharCount) {
const countText = this.commentsCharCount.textContent || "";
if (countText) {
this.modal.commentComposerDefaultCountText = countText;
this.commentComposerDefaultCountText = countText;
}
}
if (this.modal.commentsStatusMessage) {
const statusText = this.modal.commentsStatusMessage.textContent || "";
if (this.commentsStatusMessage) {
const statusText = this.commentsStatusMessage.textContent || "";
if (statusText) {
this.modal.commentStatusDefaultText = statusText;
this.commentStatusDefaultText = statusText;
}
}
if (this.modal.commentsInput) {
const parsedMax = Number(this.modal.commentsInput.maxLength);
if (this.commentsInput) {
const parsedMax = Number(this.commentsInput.maxLength);
if (Number.isFinite(parsedMax) && parsedMax > 0) {
this.modal.commentComposerMaxLength = parsedMax;
this.commentComposerMaxLength = parsedMax;
}
}
if (this.modal.commentsDisabledPlaceholder) {
this.modal.commentsDisabledPlaceholder.setAttribute("hidden", "");
if (this.commentsDisabledPlaceholder) {
this.commentsDisabledPlaceholder.setAttribute("hidden", "");
}
if (this.modal.commentsComposer) {
this.modal.commentsComposer.removeAttribute("hidden");
if (this.commentsComposer) {
this.commentsComposer.removeAttribute("hidden");
}
this.modal.clearComments();
this.modal.resetCommentComposer();
this.modal.attachCommentEventHandlers();
this.clearComments();
this.resetCommentComposer();
this.attachCommentEventHandlers();
}
attachCommentEventHandlers() {
if (this.commentsComposer && this.boundCommentSubmitHandler) {
this.commentsComposer.addEventListener(
"submit",
this.boundCommentSubmitHandler
);
}
if (this.commentsInput && this.boundCommentInputHandler) {
this.commentsInput.addEventListener(
"input",
this.boundCommentInputHandler
);
}
if (this.commentRetryButton && this.boundCommentRetryHandler) {
this.commentRetryButton.addEventListener(
"click",
this.boundCommentRetryHandler
);
}
}
detachCommentEventHandlers() {
if (this.commentsComposer && this.boundCommentSubmitHandler) {
this.commentsComposer.removeEventListener(
"submit",
this.boundCommentSubmitHandler
);
}
if (this.commentsInput && this.boundCommentInputHandler) {
this.commentsInput.removeEventListener(
"input",
this.boundCommentInputHandler
);
}
if (this.commentRetryButton && this.boundCommentRetryHandler) {
this.commentRetryButton.removeEventListener(
"click",
this.boundCommentRetryHandler
);
}
}
handleCommentComposerSubmit(event) {
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
const triggerElement =
(event && event.submitter) || this.commentsSubmitButton || null;
if (this.commentComposerState.reason === "login-required") {
this.modal.dispatch("comment:login-required", { triggerElement });
return;
}
if (this.commentComposerState.disabled) {
return;
}
const text = this.getCommentInputValue();
if (!text) {
return;
}
this.modal.dispatch("comment:submit", {
text,
parentId: this.commentComposerState.parentCommentId || null,
triggerElement,
});
}
handleCommentComposerInput() {
this.updateCommentCharCount();
this.updateCommentSubmitState();
}
handleCommentRetry(event) {
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
const triggerElement =
(event && event.currentTarget) || this.commentRetryButton || null;
this.modal.dispatch("comment:retry", {
text: this.getCommentInputValue(),
parentId: this.commentComposerState.parentCommentId || null,
triggerElement,
});
}
getCommentInputValue({ trimmed = true } = {}) {
const value = this.commentsInput ? this.commentsInput.value || "" : "";
return trimmed ? value.trim() : value;
}
updateCommentSubmitState() {
if (!this.commentsSubmitButton) {
return;
}
const hasText = !!this.getCommentInputValue();
const shouldDisable = this.commentComposerState.disabled || !hasText;
this.commentsSubmitButton.disabled = shouldDisable;
}
updateCommentCharCount() {
if (!this.commentsCharCount) {
return;
}
const length = this.commentsInput ? this.commentsInput.value.length : 0;
const safeLength = Number.isFinite(length) ? Math.max(0, length) : 0;
const max = Number.isFinite(this.commentComposerMaxLength)
? Math.max(0, this.commentComposerMaxLength)
: 0;
const label = max > 0 ? `${safeLength} / ${max}` : `${safeLength}`;
this.commentsCharCount.textContent = label;
}
setCommentHintText(text) {
if (!this.commentComposerHint) {
return;
}
const message =
typeof text === "string" && text
? text
: this.commentComposerDefaultHint || "";
this.commentComposerHint.textContent = message;
}
removeCommentRetryButton() {
if (!this.commentRetryButton) {
return;
}
this.commentRetryButton.removeEventListener(
"click",
this.boundCommentRetryHandler
);
if (this.commentRetryButton.parentNode) {
this.commentRetryButton.parentNode.removeChild(this.commentRetryButton);
}
this.commentRetryButton = null;
}
setCommentStatus(message, { showRetry = false } = {}) {
if (!this.commentsStatusMessage) {
return;
}
this.removeCommentRetryButton();
while (this.commentsStatusMessage.firstChild) {
this.commentsStatusMessage.removeChild(
this.commentsStatusMessage.firstChild
);
}
const text =
typeof message === "string" && message
? message
: this.commentStatusDefaultText || "";
if (text) {
this.commentsStatusMessage.appendChild(
this.document.createTextNode(text)
);
}
if (showRetry) {
const spacer = this.document.createTextNode(" ");
const retryButton = this.document.createElement("button");
retryButton.type = "button";
retryButton.classList.add("comment-thread__retry", "focus-ring");
retryButton.appendChild(this.document.createTextNode("Retry"));
retryButton.addEventListener("click", this.boundCommentRetryHandler);
this.commentsStatusMessage.appendChild(spacer);
this.commentsStatusMessage.appendChild(retryButton);
this.commentRetryButton = retryButton;
}
}
setCommentsVisibility(isVisible) {
const root = this.commentsContainer || this.commentsRoot;
if (!root) {
return;
}
const shouldShow = Boolean(isVisible);
if (shouldShow) {
root.removeAttribute("hidden");
root.classList?.remove("hidden");
} else {
root.setAttribute("hidden", "");
root.classList?.add("hidden");
}
}
showCommentsDisabledMessage(message) {
const placeholder = this.commentsDisabledPlaceholder;
if (placeholder) {
const fallbackText = this.commentsDisabledPlaceholderDefaultText || "";
const nextText =
typeof message === "string" && message.trim()
? message.trim()
: fallbackText;
if (nextText) {
placeholder.textContent = nextText;
} else if (fallbackText) {
placeholder.textContent = fallbackText;
}
placeholder.removeAttribute("hidden");
placeholder.classList?.remove("hidden");
}
this.setCommentsVisibility(false);
}
hideCommentsDisabledMessage() {
const placeholder = this.commentsDisabledPlaceholder;
if (placeholder) {
if (this.commentsDisabledPlaceholderDefaultText) {
placeholder.textContent = this.commentsDisabledPlaceholderDefaultText;
}
placeholder.setAttribute("hidden", "");
placeholder.classList?.add("hidden");
}
}
setCommentComposerState({ disabled = false, reason = "" } = {}) {
const normalizedReason = typeof reason === "string" ? reason : "";
let shouldDisable = !!disabled;
switch (normalizedReason) {
case "login-required":
case "submitting":
case "disabled":
shouldDisable = true;
break;
case "error":
shouldDisable = false;
break;
default:
break;
}
this.commentComposerState.disabled = shouldDisable;
this.commentComposerState.reason = normalizedReason;
if (this.commentsInput) {
this.commentsInput.disabled = shouldDisable;
}
if (normalizedReason !== "disabled") {
this.hideCommentsDisabledMessage();
}
if (this.commentsComposer) {
if (normalizedReason === "disabled") {
this.commentsComposer.setAttribute("hidden", "");
} else {
this.commentsComposer.removeAttribute("hidden");
}
}
if (normalizedReason === "login-required") {
this.setCommentHintText("Log in to add a comment.");
this.setCommentStatus("");
this.setCommentsVisibility(true);
} else if (normalizedReason === "submitting") {
this.setCommentHintText(this.commentComposerDefaultHint);
this.setCommentStatus("Posting comment…");
} else if (normalizedReason === "error") {
this.setCommentHintText(this.commentComposerDefaultHint);
this.setCommentStatus("We couldn't post your comment. Try again?", {
showRetry: true,
});
} else if (normalizedReason === "disabled") {
this.setCommentHintText(this.commentComposerDefaultHint);
this.setCommentStatus("");
} else {
this.setCommentHintText(this.commentComposerDefaultHint);
this.setCommentStatus("");
}
this.updateCommentSubmitState();
}
resetCommentComposer() {
if (this.commentsInput) {
this.commentsInput.disabled = false;
this.commentsInput.value = "";
}
this.hideCommentsDisabledMessage();
this.commentComposerState = {
disabled: false,
reason: "",
parentCommentId: null,
};
this.setCommentHintText(this.commentComposerDefaultHint);
this.setCommentStatus("");
this.updateCommentCharCount();
this.updateCommentSubmitState();
}
setCommentSectionCallbacks({ teardown = null } = {}) {
this.commentCallbacks.teardown =
typeof teardown === "function" ? teardown : null;
}
invokeCommentTeardown() {
const teardown = this.commentCallbacks.teardown;
if (typeof teardown !== "function") {
return;
}
this.commentCallbacks.teardown = null;
try {
teardown();
} catch (error) {
this.logger.log("[VideoModal] Failed to teardown comment services", error);
}
}
renderComments(snapshot) {
this.renderer.render(snapshot);
}
appendComment(event) {
this.renderer.append(event);
}
clearComments() {
this.renderer.clear();
}
disableComments({ message = "", reason = "" } = {}) {
const placeholder =
this.modal.commentsDisabledPlaceholder ||
this.ensureCommentsDisabledPlaceholder({ root: this.root }) ||
this.commentsDisabledPlaceholder ||
this.ensureCommentsDisabledPlaceholder({ root: this.commentsRoot }) ||
null;
if (!this.modal.commentsDisabledPlaceholder && placeholder) {
this.modal.commentsDisabledPlaceholder = placeholder;
if (!this.commentsDisabledPlaceholder && placeholder) {
this.commentsDisabledPlaceholder = placeholder;
if (placeholder.textContent) {
this.modal.commentsDisabledPlaceholderDefaultText = placeholder.textContent;
this.commentsDisabledPlaceholderDefaultText = placeholder.textContent;
}
}
@@ -164,23 +553,27 @@ export class CommentsController {
this.playerModal.classList?.add("comments-disabled");
}
if (this.modal.commentsList) {
this.modal.commentsList.setAttribute("hidden", "");
this.modal.commentsList.classList?.add("hidden");
// Hide list via renderer?
// The renderer manages list content, but visibility of the container might be controller concern?
// In old code: this.commentsList.setAttribute("hidden", "");
// Renderer has listRoot.
if (this.renderer.listRoot) {
this.renderer.listRoot.setAttribute("hidden", "");
this.renderer.listRoot.classList?.add("hidden");
}
if (this.modal.commentsComposer) {
this.modal.commentsComposer.setAttribute("hidden", "");
this.modal.commentsComposer.classList?.add("hidden");
if (this.commentsComposer) {
this.commentsComposer.setAttribute("hidden", "");
this.commentsComposer.classList?.add("hidden");
}
const nextMessage =
typeof message === "string" && message.trim()
? message.trim()
: this.modal.commentsDisabledPlaceholderDefaultText ||
: this.commentsDisabledPlaceholderDefaultText ||
"Comments are unavailable right now.";
this.modal.showCommentsDisabledMessage(nextMessage);
this.showCommentsDisabledMessage(nextMessage);
logger.dev?.info?.(
"[VideoModal:comments] Disabled comments handling",
{ reason: reason || "unknown" }
@@ -188,80 +581,68 @@ export class CommentsController {
}
update(action = {}) {
if (!this.modal) {
return;
}
const { type } = action;
switch (type) {
case "set-visibility":
this.modal.setCommentsVisibility(action.visible);
this.setCommentsVisibility(action.visible);
break;
case "render":
this.modal.renderComments(action.snapshot);
this.renderComments(action.snapshot);
break;
case "set-composer-state":
this.modal.setCommentComposerState(action.state);
this.setCommentComposerState(action.state);
break;
case "set-thread-context":
this.modal.setCommentThreadContext(action.context);
this.setCommentThreadContext(action.context);
break;
case "clear":
this.modal.clearComments();
this.clearComments();
break;
default:
break;
}
}
destroy() {
if (!this.modal) {
return;
setCommentThreadContext(context) {
if (context) {
this.commentThreadContext = { ...context };
}
this.modal.detachCommentEventHandlers();
this.modal.removeCommentRetryButton();
this.modal.commentsRoot = null;
this.modal.commentsContainer = null;
this.modal.commentsCountLabel = null;
this.modal.commentsEmptyState = null;
this.modal.commentsList = null;
this.modal.commentsLoadMoreButton = null;
this.modal.commentsDisabledPlaceholder = null;
this.modal.commentsComposer = null;
this.modal.commentsInput = null;
this.modal.commentsSubmitButton = null;
this.modal.commentsStatusMessage = null;
this.modal.commentsCharCount = null;
this.modal.commentComposerHint = null;
}
destroy() {
this.detachCommentEventHandlers();
this.removeCommentRetryButton();
this.invokeCommentTeardown();
this.renderer.destroy();
this.commentsRoot = null;
this.commentsContainer = null;
this.commentsDisabledPlaceholder = null;
this.commentsComposer = null;
this.commentsInput = null;
this.commentsSubmitButton = null;
this.commentsStatusMessage = null;
this.commentsCharCount = null;
this.commentComposerHint = null;
if (this.playerModal) {
this.playerModal.removeAttribute("data-comments-disabled");
this.playerModal.classList?.remove("comments-disabled");
}
this.root = null;
}
getDocument() {
if (this.modal?.document) {
return this.modal.document;
}
if (this.playerModal?.ownerDocument) {
return this.playerModal.ownerDocument;
}
if (typeof document !== "undefined") {
return document;
}
return null;
this.playerModal = null;
}
ensureCommentsDisabledPlaceholder({ root } = {}) {
const existing =
this.modal?.commentsDisabledPlaceholder ||
this.commentsDisabledPlaceholder ||
root?.querySelector?.("[data-comments-disabled-placeholder]") ||
this.playerModal?.querySelector?.("[data-comments-disabled-placeholder]");
if (existing) {
return existing;
}
const doc = this.getDocument();
const doc = this.document;
const container = root?.parentElement || this.playerModal || null;
if (!doc || !container) {
return null;
@@ -275,7 +656,7 @@ export class CommentsController {
placeholder.classList.add("comment-thread__disabled");
const defaultText =
this.modal?.commentsDisabledPlaceholderDefaultText ||
this.commentsDisabledPlaceholderDefaultText ||
"Comments are unavailable right now.";
placeholder.textContent = defaultText;
@@ -288,4 +669,3 @@ export class CommentsController {
return placeholder;
}
}
@@ -0,0 +1,652 @@
import {
normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil,
resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil,
} from "./utils/commentAvatar.js";
import { HEX64_REGEX } from "../../../utils/hex.js";
import { sanitizeProfileMediaUrl } from "../../../utils/profileMedia.js";
import logger from "../../../utils/logger.js";
import { CommentNodeFactory } from "./commentNodeFactory.js";
export class CommentsListRenderer {
constructor({ controller } = {}) {
this.controller = controller;
this.document = controller.document;
this.window = controller.window;
this.logger = controller.logger || logger;
this.helpers = controller.modal?.helpers || {}; // Need helpers for npub format
this.listRoot = null;
this.loadMoreButton = null;
this.countLabel = null;
this.emptyState = null;
this.commentProfiles = new Map();
this.commentNodes = new Map();
this.commentChildLists = new Map();
this.commentNodeElements = new Map();
this.commentNpubEncodingFailures = new Set();
this.commentAvatarCache = new Map();
this.commentAvatarFailures = new Set();
this.commentThreadContext = {
videoEventId: "",
parentCommentId: null,
};
this.DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
this.boundCommentLoadMoreHandler = this.handleCommentLoadMore.bind(this);
this.nodeFactory = new CommentNodeFactory({
document: this.document,
window: this.window,
logger: this.logger,
dispatch: (type, detail) => this.controller.dispatch(type, detail),
DEFAULT_PROFILE_AVATAR: this.DEFAULT_PROFILE_AVATAR
});
}
initialize({ list, loadMoreButton, countLabel, emptyState }) {
this.listRoot = list;
this.loadMoreButton = loadMoreButton;
this.countLabel = countLabel;
this.emptyState = emptyState;
if (this.loadMoreButton) {
this.loadMoreButton.addEventListener("click", this.boundCommentLoadMoreHandler);
}
}
destroy() {
if (this.loadMoreButton) {
this.loadMoreButton.removeEventListener("click", this.boundCommentLoadMoreHandler);
}
this.listRoot = null;
this.loadMoreButton = null;
this.countLabel = null;
this.emptyState = null;
this.commentProfiles.clear();
this.commentNodes.clear();
this.commentChildLists.clear();
this.commentNodeElements.clear();
this.commentAvatarCache.clear();
this.commentAvatarFailures.clear();
}
handleCommentLoadMore(event) {
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
const triggerElement =
(event && event.currentTarget) || this.loadMoreButton || null;
this.controller.dispatch("comment:load-more", {
parentId: this.commentThreadContext.parentCommentId || null,
triggerElement,
});
}
render(snapshot) {
if (!this.listRoot || !this.document) {
return;
}
const commentsMap = this.normalizeCommentMap(snapshot?.commentsById);
const childrenMap = this.normalizeChildrenMap(snapshot?.childrenByParent);
this.commentProfiles = this.normalizeProfileMap(snapshot?.profiles);
this.commentThreadContext.videoEventId =
typeof snapshot?.videoEventId === "string"
? snapshot.videoEventId.trim()
: "";
this.commentThreadContext.parentCommentId =
typeof snapshot?.parentCommentId === "string" &&
snapshot.parentCommentId.trim()
? snapshot.parentCommentId.trim()
: null;
this.commentNodes.clear();
this.commentChildLists = new Map();
this.commentChildLists.set(null, this.listRoot);
this.listRoot.textContent = "";
const fragment = this.document.createDocumentFragment();
const topLevelIds = this.getChildCommentIds(childrenMap, null);
topLevelIds.forEach((commentId) => {
const node = this.buildCommentTree(
commentId,
commentsMap,
childrenMap,
0
);
if (node) {
fragment.appendChild(node);
}
});
this.listRoot.appendChild(fragment);
this.updateCommentCount(this.commentNodes.size);
this.toggleCommentEmptyState(this.commentNodes.size === 0);
}
clear() {
this.commentNodes.clear();
this.commentChildLists.clear();
this.commentNodeElements.clear();
this.commentNpubEncodingFailures.clear();
if (this.listRoot) {
this.listRoot.textContent = "";
this.commentChildLists.set(null, this.listRoot);
}
this.updateCommentCount(0);
this.toggleCommentEmptyState(true);
}
append(event) {
if (!event || typeof event !== "object" || !this.listRoot) {
return;
}
const commentId =
typeof event.id === "string" && event.id.trim() ? event.id.trim() : "";
if (!commentId || this.commentNodes.has(commentId)) {
return;
}
if (event.profile && typeof event.pubkey === "string") {
this.mergeCommentProfile(event.pubkey, event.profile);
}
const parentId = this.extractParentCommentId(event);
const parentNode =
parentId && this.commentNodes.has(parentId)
? this.commentNodes.get(parentId)
: null;
const depth = parentNode
? Math.max(0, Number(parentNode.dataset?.commentDepth) || 0) + 1
: 0;
const node = this.createCommentNode(event, { depth });
if (!node) {
return;
}
const container = this.getCommentChildrenContainer(parentId);
if (!container) {
this.logger.log("[VideoModal] Missing container for appended comment", parentId);
return;
}
this.insertCommentNodeSorted(container, node);
this.updateCommentCount(this.commentNodes.size);
this.toggleCommentEmptyState(this.commentNodes.size === 0);
}
insertCommentNodeSorted(container, node) {
if (!container || !node) {
return;
}
const timestamp = Number(node.dataset.timestamp || 0);
let inserted = false;
const children = container.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const childTimestamp = Number(child.dataset.timestamp || 0);
if (childTimestamp > timestamp) {
container.insertBefore(node, child);
inserted = true;
break;
}
}
if (!inserted) {
container.appendChild(node);
}
}
buildCommentTree(commentId, commentsMap, childrenMap, depth = 0) {
const normalizedId =
typeof commentId === "string" && commentId.trim()
? commentId.trim()
: "";
if (!normalizedId) {
return null;
}
const event = commentsMap.get(normalizedId);
if (!event) {
return null;
}
const node = this.createCommentNode(event, { depth });
if (!node) {
return null;
}
const repliesContainer = this.commentChildLists.get(normalizedId);
if (repliesContainer) {
const childIds = this.getChildCommentIds(childrenMap, normalizedId);
if (childIds.length) {
const fragment = this.document.createDocumentFragment();
childIds.forEach((childId) => {
const childNode = this.buildCommentTree(
childId,
commentsMap,
childrenMap,
depth + 1
);
if (childNode) {
fragment.appendChild(childNode);
}
});
if (fragment.childNodes.length) {
repliesContainer.appendChild(fragment);
}
}
}
return node;
}
createCommentNode(event, { depth = 0 } = {}) {
if (!event || typeof event !== "object") {
return null;
}
const commentId =
typeof event.id === "string" && event.id.trim() ? event.id.trim() : "";
if (!commentId) {
return null;
}
if (this.commentNodes.has(commentId)) {
return this.commentNodes.get(commentId);
}
const { listItem, replies, elements } = this.nodeFactory.createCommentNode(event, { depth });
this.commentNodes.set(commentId, listItem);
this.commentChildLists.set(commentId, replies);
this.commentNodeElements.set(commentId, elements);
this.updateCommentNodeProfile(commentId, event.pubkey);
return listItem;
}
updateCommentNodeProfile(commentId, pubkey) {
if (!(this.commentNodeElements instanceof Map)) {
this.commentNodeElements = new Map();
}
if (!commentId || !this.commentNodeElements.has(commentId)) {
return;
}
const refs = this.commentNodeElements.get(commentId);
const profile = this.getCommentAuthorProfile(pubkey);
this.nodeFactory.updateCommentNodeProfile(
refs,
profile,
this.commentAvatarCache,
this.commentAvatarFailures
);
}
updateCommentNodesForAuthor(pubkey) {
if (typeof pubkey !== "string" || !pubkey.trim()) {
return;
}
const normalized = pubkey.trim().toLowerCase();
this.commentNodes.forEach((node, id) => {
const author =
typeof node?.dataset?.commentAuthor === "string"
? node.dataset.commentAuthor.trim().toLowerCase()
: "";
if (author && author === normalized) {
this.updateCommentNodeProfile(id, pubkey);
}
});
}
getCommentAuthorName(pubkey) {
return this.getCommentAuthorProfile(pubkey).displayName;
}
getCommentAvatarInitial(pubkey) {
return this.getCommentAuthorProfile(pubkey).initial;
}
getCommentAuthorProfile(pubkey) {
const normalized =
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
const profileEntry = normalized
? this.commentProfiles.get(normalized.toLowerCase()) || null
: null;
const rawPicture =
profileEntry && typeof profileEntry.picture === "string"
? profileEntry.picture
: "";
const sanitizedPicture = sanitizeProfileMediaUrl(rawPicture);
const avatar = this.resolveCommentAvatarAsset(normalized, sanitizedPicture);
let npub = "";
if (profileEntry && typeof profileEntry.npub === "string") {
const trimmed = profileEntry.npub.trim();
if (trimmed) {
if (trimmed.startsWith("npub")) {
npub = trimmed;
} else if (HEX64_REGEX.test(trimmed)) {
npub = this.encodeCommentPubkeyToNpub(trimmed);
}
}
}
if (!npub && profileEntry && typeof profileEntry.pubkey === "string") {
const trimmed = profileEntry.pubkey.trim();
if (trimmed) {
npub = this.encodeCommentPubkeyToNpub(trimmed);
}
}
if (!npub && normalized) {
npub = this.encodeCommentPubkeyToNpub(normalized);
}
let displayName = "";
if (profileEntry && typeof profileEntry === "object") {
const candidates = [
profileEntry.display_name,
profileEntry.name,
profileEntry.username,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
displayName = trimmed;
break;
}
}
}
if (displayName && HEX64_REGEX.test(displayName)) {
displayName = "";
}
if (!displayName && npub && this.helpers.formatShortNpub) {
displayName = this.helpers.formatShortNpub(npub);
}
if (!displayName) {
displayName = "Anonymous";
}
const shortNpub = npub && this.helpers.formatShortNpub ? this.helpers.formatShortNpub(npub) : "";
const initialCandidate = displayName.trim().charAt(0) || "";
const initial = initialCandidate ? initialCandidate.toUpperCase() : "?";
return {
displayName,
avatarUrl: avatar.url,
avatarSource: avatar.source,
npub,
shortNpub,
initial,
};
}
resolveCommentAvatarAsset(pubkey, sanitizedPicture) {
return resolveCommentAvatarAssetUtil({
cache: this.commentAvatarCache,
failures: this.commentAvatarFailures,
defaultAvatar: this.DEFAULT_PROFILE_AVATAR,
pubkey,
sanitizedPicture,
});
}
normalizeCommentAvatarKey(value) {
return normalizeCommentAvatarKeyUtil(value);
}
encodeCommentPubkeyToNpub(pubkey) {
if (typeof pubkey !== "string") {
return "";
}
const trimmed = pubkey.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
if (!HEX64_REGEX.test(trimmed)) {
return "";
}
if (this.commentNpubEncodingFailures.has(trimmed)) {
return "";
}
try {
const encoded = this.window?.NostrTools?.nip19?.npubEncode?.(trimmed);
if (typeof encoded === "string" && encoded) {
return encoded;
}
} catch (error) {
if (!this.commentNpubEncodingFailures.has(trimmed)) {
this.logger.log("[VideoModal] Failed to encode comment author pubkey", error);
}
}
this.commentNpubEncodingFailures.add(trimmed);
return "";
}
normalizeCommentMap(candidate) {
if (candidate instanceof Map) {
return new Map(candidate);
}
if (Array.isArray(candidate)) {
const map = new Map();
candidate.forEach(([key, value]) => {
if (typeof key === "string" && key.trim()) {
map.set(key.trim(), value);
}
});
return map;
}
if (candidate && typeof candidate === "object") {
const map = new Map();
Object.entries(candidate).forEach(([key, value]) => {
if (typeof key === "string" && key.trim()) {
map.set(key.trim(), value);
}
});
return map;
}
return new Map();
}
normalizeChildrenMap(candidate) {
const map = new Map();
if (candidate instanceof Map) {
candidate.forEach((value, key) => {
map.set(this.normalizeParentKey(key), this.normalizeIdList(value));
});
return map;
}
if (Array.isArray(candidate)) {
candidate.forEach(([key, value]) => {
map.set(this.normalizeParentKey(key), this.normalizeIdList(value));
});
return map;
}
if (candidate && typeof candidate === "object") {
Object.entries(candidate).forEach(([key, value]) => {
map.set(this.normalizeParentKey(key), this.normalizeIdList(value));
});
}
return map;
}
normalizeProfileMap(candidate) {
const map = new Map();
if (candidate instanceof Map) {
candidate.forEach((value, key) => {
const normalizedKey =
typeof key === "string" && key.trim() ? key.trim().toLowerCase() : "";
if (normalizedKey) {
map.set(normalizedKey, value);
}
});
return map;
}
if (Array.isArray(candidate)) {
candidate.forEach(([key, value]) => {
const normalizedKey =
typeof key === "string" && key.trim() ? key.trim().toLowerCase() : "";
if (normalizedKey) {
map.set(normalizedKey, value);
}
});
return map;
}
if (candidate && typeof candidate === "object") {
Object.entries(candidate).forEach(([key, value]) => {
const normalizedKey =
typeof key === "string" && key.trim() ? key.trim().toLowerCase() : "";
if (normalizedKey) {
map.set(normalizedKey, value);
}
});
}
return map;
}
normalizeIdList(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) =>
typeof entry === "string" && entry.trim() ? entry.trim() : ""
)
.filter(Boolean);
}
normalizeParentKey(value) {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (
!trimmed ||
trimmed === "null" ||
trimmed === "undefined" ||
trimmed === "__root__"
) {
return null;
}
return trimmed;
}
return this.normalizeParentKey(String(value));
}
getChildCommentIds(childrenMap, parentId) {
const key = this.normalizeParentKey(parentId);
const list = childrenMap.get(key);
return Array.isArray(list) ? [...list] : [];
}
getCommentChildrenContainer(parentId) {
const key = this.normalizeParentKey(parentId);
if (key === null) {
return this.commentChildLists.get(null) || this.listRoot;
}
if (this.commentChildLists.has(key)) {
return this.commentChildLists.get(key);
}
const parentNode = this.commentNodes.get(key);
if (parentNode) {
const existing = parentNode.querySelector("[data-comment-replies]");
if (existing) {
this.commentChildLists.set(key, existing);
return existing;
}
}
return this.commentChildLists.get(null) || this.listRoot;
}
extractParentCommentId(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const values = [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const [name, value] = tag;
if (name !== "e") {
continue;
}
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized || normalized === this.commentThreadContext.videoEventId) {
continue;
}
values.push(normalized);
}
if (!values.length) {
return null;
}
return values[values.length - 1];
}
mergeCommentProfile(pubkey, profile) {
if (typeof pubkey !== "string" || !pubkey.trim()) {
return;
}
const normalized = pubkey.trim().toLowerCase();
this.commentProfiles.set(normalized, profile);
this.updateCommentNodesForAuthor(pubkey);
}
updateCommentCount(count) {
if (!this.countLabel) {
return;
}
const numeric = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
const label = numeric === 1 ? "1 comment" : `${numeric} comments`;
this.countLabel.textContent = label;
}
toggleCommentEmptyState(isEmpty) {
const shouldShow = Boolean(isEmpty);
if (this.emptyState) {
if (shouldShow) {
this.emptyState.removeAttribute("hidden");
} else {
this.emptyState.setAttribute("hidden", "");
}
}
if (this.listRoot) {
if (shouldShow) {
this.listRoot.setAttribute("data-empty", "true");
} else {
this.listRoot.removeAttribute("data-empty");
}
}
}
}