From feb7ad765ac1344ae0ac683612fb3b8333c28d80 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:53:34 +0000 Subject: [PATCH] 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 <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/ui/components/VideoModal.js | 2053 +---------------- .../video-modal/commentNodeFactory.js | 402 ++++ .../video-modal/commentsController.js | 598 ++++- .../video-modal/commentsListRenderer.js | 652 ++++++ 4 files changed, 1547 insertions(+), 2158 deletions(-) create mode 100644 js/ui/components/video-modal/commentNodeFactory.js create mode 100644 js/ui/components/video-modal/commentsListRenderer.js diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index dfcb9066..dd32fbaa 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -13,11 +13,6 @@ import { SimilarContentController } from "./video-modal/similarContentController import { ModerationController } from "./video-modal/moderationController.js"; import { ZapController } from "./video-modal/zapController.js"; import { LinkPreviewController } from "./video-modal/linkPreviewController.js"; -import { - normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, - resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, - registerCommentAvatarFailure as registerCommentAvatarFailureUtil, -} from "./video-modal/utils/commentAvatar.js"; import { renderTagPillStrip, applyTagPreferenceState, @@ -31,9 +26,7 @@ import { derivePointerKeyFromInput, } from "./VideoModalSimilarHelpers.js"; import { - subscribeToVideoViewCount, unsubscribeFromVideoViewCount, - formatViewCount, } from "../../viewCounter.js"; import { getModerationOverrideActionLabels, @@ -41,14 +34,9 @@ import { } from "../moderationUiHelpers.js"; import { buildModerationBadgeText } from "../moderationCopy.js"; import { - formatAbsoluteTimestamp, - formatTimeAgo, formatShortNpub as defaultFormatShortNpub, } from "../../utils/formatters.js"; -import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; import { getBreakpointLg } from "../../designSystem/metrics.js"; -import { HEX64_REGEX } from "../../utils/hex.js"; -import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; const SIMILAR_CONTENT_LIMIT = 10; @@ -228,55 +216,10 @@ export class VideoModal { this.zapController = new ZapController({ modal: this }); this.linkPreviewController = new LinkPreviewController({ modal: this }); - this.commentsRoot = null; - this.commentsContainer = null; - this.commentsCountLabel = null; - this.commentsEmptyState = null; - this.commentsList = null; - this.commentsLoadMoreButton = null; - this.commentsDisabledPlaceholder = null; - this.commentsDisabledPlaceholderDefaultText = ""; - this.commentsComposer = null; - this.commentsInput = null; - this.commentsSubmitButton = null; - this.commentsStatusMessage = null; - this.commentsCharCount = null; - this.commentComposerHint = null; - this.commentComposerDefaultHint = ""; - this.commentComposerDefaultCountText = ""; - this.commentStatusDefaultText = ""; - this.commentComposerMaxLength = 0; - this.commentComposerState = { - disabled: true, - reason: "", - parentCommentId: null, - }; this.shareNostrAuthState = { isLoggedIn: false, hasSigner: false, }; - this.commentThreadContext = { - videoEventId: "", - parentCommentId: 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.commentRetryButton = null; - this.commentCallbacks = { - teardown: null, - }; - this.boundCommentSubmitHandler = - this.handleCommentComposerSubmit.bind(this); - this.boundCommentInputHandler = - this.handleCommentComposerInput.bind(this); - this.boundCommentLoadMoreHandler = - this.handleCommentLoadMore.bind(this); - this.boundCommentRetryHandler = this.handleCommentRetry.bind(this); this.modalPosterCleanup = null; this.videoEventCleanup = null; @@ -317,7 +260,6 @@ export class VideoModal { this.handleGlobalModerationBlock(event); this.MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; - this.DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; this.setMediaLoader(mediaLoader); @@ -795,1993 +737,6 @@ export class VideoModal { } } - 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.commentsLoadMoreButton && this.boundCommentLoadMoreHandler) { - this.commentsLoadMoreButton.addEventListener( - "click", - this.boundCommentLoadMoreHandler - ); - } - 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.commentsLoadMoreButton && this.boundCommentLoadMoreHandler) { - this.commentsLoadMoreButton.removeEventListener( - "click", - this.boundCommentLoadMoreHandler - ); - } - 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.dispatch("comment:login-required", { triggerElement }); - return; - } - - if (this.commentComposerState.disabled) { - return; - } - - const text = this.getCommentInputValue(); - if (!text) { - return; - } - - this.dispatch("comment:submit", { - text, - parentId: this.commentComposerState.parentCommentId || null, - triggerElement, - }); - } - - handleCommentComposerInput() { - this.updateCommentCharCount(); - this.updateCommentSubmitState(); - } - - handleCommentLoadMore(event) { - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - const triggerElement = - (event && event.currentTarget) || this.commentsLoadMoreButton || null; - this.dispatch("comment:load-more", { - parentId: this.commentThreadContext.parentCommentId || null, - triggerElement, - }); - } - - handleCommentRetry(event) { - if (event && typeof event.preventDefault === "function") { - event.preventDefault(); - } - const triggerElement = - (event && event.currentTarget) || this.commentRetryButton || null; - this.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.log("[VideoModal] Failed to teardown comment services", error); - } - } - - renderComments(snapshot) { - if (!this.commentsList || !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.commentsList); - - this.commentsList.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.commentsList.appendChild(fragment); - - this.updateCommentCount(this.commentNodes.size); - this.toggleCommentEmptyState(this.commentNodes.size === 0); - } - - appendComment(event) { - if (!event || typeof event !== "object" || !this.commentsList) { - 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.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" || !this.document) { - 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 = 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); - - this.commentNodes.set(commentId, listItem); - this.commentChildLists.set(commentId, replies); - this.commentNodeElements.set(commentId, { - avatarImg, - avatarLabel, - authorEl, - npubContainer: identityMeta, - npubLabel, - copyButton, - }); - - this.updateCommentNodeProfile(commentId, event.pubkey); - - return listItem; - } - - 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.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(commentId, pubkey) { - if (!(this.commentNodeElements instanceof Map)) { - this.commentNodeElements = new Map(); - } - - if (!commentId || !this.commentNodeElements.has(commentId)) { - return; - } - - const node = this.commentNodes.get(commentId); - if (!node) { - return; - } - - const refs = this.commentNodeElements.get(commentId); - const profile = this.getCommentAuthorProfile(pubkey); - - 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) { - this.registerCommentAvatarFailure(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; - } - } - } - - 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) { - displayName = this.helpers.formatShortNpub(npub); - } - - if (!displayName) { - displayName = "Anonymous"; - } - - const shortNpub = npub ? 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); - } - - registerCommentAvatarFailure(sourceUrl) { - registerCommentAvatarFailureUtil({ - cache: this.commentAvatarCache, - failures: this.commentAvatarFailures, - defaultAvatar: this.DEFAULT_PROFILE_AVATAR, - sourceUrl, - }); - } - - 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.log("[VideoModal] Failed to encode comment author pubkey", error); - } - } - - this.commentNpubEncodingFailures.add(trimmed); - return ""; - } - - 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.log("[VideoModal] Failed to format comment timestamp", error); - } - if (!text) { - text = formatAbsoluteTimestamp(numeric); - } - - return { - text, - iso: date.toISOString(), - title: formatAbsoluteTimestamp(numeric), - }; - } - - 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.commentsList; - } - 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.commentsList; - } - - 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); - } - - clearComments() { - if (!(this.commentNodes instanceof Map)) { - this.commentNodes = new Map(); - } else { - this.commentNodes.clear(); - } - if (!(this.commentChildLists instanceof Map)) { - this.commentChildLists = new Map(); - } else { - this.commentChildLists.clear(); - } - if (!(this.commentNodeElements instanceof Map)) { - this.commentNodeElements = new Map(); - } else { - this.commentNodeElements.clear(); - } - if (this.commentNpubEncodingFailures instanceof Set) { - this.commentNpubEncodingFailures.clear(); - } else { - this.commentNpubEncodingFailures = new Set(); - } - if (this.commentsList) { - this.commentsList.textContent = ""; - this.commentChildLists.set(null, this.commentsList); - } - this.updateCommentCount(0); - this.toggleCommentEmptyState(true); - } - - updateCommentCount(count) { - if (!this.commentsCountLabel) { - return; - } - const numeric = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; - const label = numeric === 1 ? "1 comment" : `${numeric} comments`; - this.commentsCountLabel.textContent = label; - } - - toggleCommentEmptyState(isEmpty) { - const shouldShow = Boolean(isEmpty); - if (this.commentsEmptyState) { - if (shouldShow) { - this.commentsEmptyState.removeAttribute("hidden"); - } else { - this.commentsEmptyState.setAttribute("hidden", ""); - } - } - if (this.commentsList) { - if (shouldShow) { - this.commentsList.setAttribute("data-empty", "true"); - } else { - this.commentsList.removeAttribute("data-empty"); - } - } - } - - destroy() { - this.close(); - this.teardownVideoTagsResizeObserver(); - this.commentsController.destroy(); - this.reactionsController.destroy(); - this.similarContentController.destroy(); - this.moderationController.destroy(); - this.zapController.destroy(); - this.linkPreviewController.destroy(); - this.invokeCommentTeardown(); - - if (this.document) { - this.document.removeEventListener( - "video:moderation-override", - this.handleGlobalModerationOverride, - ); - this.document.removeEventListener( - "video:moderation-block", - this.handleGlobalModerationBlock, - ); - this.document.removeEventListener( - "video:moderation-hide", - this.handleGlobalModerationHide, - ); - } - - this.removeModerationPrimaryButton(); - this.removeModerationBlockButton(); - this.moderationOverlay = null; - this.moderationBadge = null; - this.moderationBadgeText = null; - this.moderationActionsContainer = null; - this.moderationPrimaryButton = null; - this.moderationPrimaryMode = ""; - this.moderationBlockButton = null; - this.moderationBadgeId = ""; - this.activeModerationContext = null; - - this.commentProfiles = new Map(); - this.commentNodes = new Map(); - this.commentChildLists = new Map(); - this.commentNodeElements = new Map(); - this.commentComposerState = { - disabled: true, - reason: "", - parentCommentId: null, - }; - this.commentThreadContext = { - videoEventId: "", - parentCommentId: null, - }; - this.commentComposerDefaultHint = ""; - this.commentComposerDefaultCountText = ""; - this.commentStatusDefaultText = ""; - this.commentComposerMaxLength = 0; - - this.commentsRoot = null; - this.commentsContainer = null; - this.commentsCountLabel = null; - this.commentsEmptyState = null; - this.commentsList = null; - this.commentsLoadMoreButton = null; - this.commentsDisabledPlaceholder = null; - this.commentsDisabledPlaceholderDefaultText = ""; - this.commentsComposer = null; - this.commentsInput = null; - this.commentsSubmitButton = null; - this.commentsStatusMessage = null; - this.commentsCharCount = null; - this.commentComposerHint = null; - this.commentRetryButton = null; - } - - bindVideoEvents() { - if ( - !this.modalVideo || - !this.window || - typeof this.window.HTMLVideoElement === "undefined" || - !(this.modalVideo instanceof this.window.HTMLVideoElement) - ) { - return; - } - - const loadedHandler = () => { - this.dispatch("playback:loadeddata", { - video: this.modalVideo, - active: this.activeVideo - }); - }; - const playingHandler = () => { - this.dispatch("playback:playing", { - video: this.modalVideo, - active: this.activeVideo - }); - }; - - this.modalVideo.addEventListener("loadeddata", loadedHandler); - this.modalVideo.addEventListener("playing", playingHandler); - - this.videoEventCleanup = () => { - if (!this.modalVideo) { - return; - } - this.modalVideo.removeEventListener("loadeddata", loadedHandler); - this.modalVideo.removeEventListener("playing", playingHandler); - this.videoEventCleanup = null; - }; - } - - detachVideoEvents() { - if (typeof this.videoEventCleanup === "function") { - this.videoEventCleanup(); - } - this.videoEventCleanup = null; - } - - getAmbientFallbackColor() { - const canvas = this.ambientCanvas; - const doc = - canvas?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - const win = doc?.defaultView || (typeof window !== "undefined" ? window : null); - - if (doc && win && typeof win.getComputedStyle === "function") { - const styles = win.getComputedStyle(doc.documentElement); - const tokens = [ - "--color-overlay-strong", - "--color-overlay", - "--color-surface", - "--color-bg", - ]; - - for (const token of tokens) { - const value = styles.getPropertyValue(token); - if (value && value.trim()) { - return value.trim(); - } - } - } - - return "rgb(0, 0, 0)"; - } - - clearAmbientCanvas() { - const canvas = this.ambientCanvas; - if (!canvas || typeof canvas.getContext !== "function") { - return; - } - const context = canvas.getContext("2d"); - if (!context) { - return; - } - - const width = canvas.width || canvas.clientWidth || 0; - const height = canvas.height || canvas.clientHeight || 0; - if (!width || !height) { - return; - } - - context.fillStyle = this.getAmbientFallbackColor(); - context.fillRect(0, 0, width, height); - } - - teardownAmbientGlow({ clear = true } = {}) { - const detach = this.detachAmbientBackground; - this.detachAmbientBackground = null; - - if (typeof detach === "function") { - try { - detach({ clear }); - return; - } catch (error) { - this.log("[VideoModal] Failed to teardown ambient background", error); - } - } - - if (clear) { - this.clearAmbientCanvas(); - } - } - - attachAmbientGlow() { - if (!this.modalVideo || !this.ambientCanvas) { - this.clearAmbientCanvas(); - return; - } - - try { - this.detachAmbientBackground = attachAmbientBackground( - this.modalVideo, - this.ambientCanvas, - ); - } catch (error) { - this.detachAmbientBackground = null; - this.log("[VideoModal] Failed to attach ambient background", error); - this.clearAmbientCanvas(); - } - } - - bindActionButtons() { - if (this.copyMagnetBtn) { - this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest); - } - if (this.shareBtn && this.shareBtn.dataset.modalMenuHandler !== "true") { - this.shareBtn.dataset.modalMenuHandler = "true"; - this.shareBtn.addEventListener("click", this.handleShareRequest); - } - if (this.embedBtn) { - this.embedBtn.addEventListener("click", this.handleEmbedRequest); - } - - if (this.modalMoreBtn && this.modalMoreBtn.dataset.modalMenuHandler !== "true") { - this.modalMoreBtn.dataset.modalMenuHandler = "true"; - this.modalMoreBtn.addEventListener("click", this.handleModalMoreButtonClick); - } - - if (this.sourceToggleButtons.length > 0) { - this.sourceToggleButtons.forEach((btn) => { - btn.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - const mode = btn.dataset.sourceToggle; - if (mode) { - this.handleSourceToggle(mode); - } - }); - }); - } - - if (this.creatorAvatar) { - this.creatorAvatar.addEventListener( - "click", - this.handleCreatorNavigation - ); - } - if (this.creatorName) { - this.creatorName.addEventListener("click", this.handleCreatorNavigation); - } - } - - - setupModalMorePopover() { - if (!this.modalMoreBtn) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - return; - } - - const documentRef = - this.modalMoreBtn.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - const render = ({ document: doc, close }) => { - const panel = this.buildModalMoreMenuPanel({ document: doc, close }); - this.modalMoreMenuPanel = panel; - return panel; - }; - - const popover = createPopover(this.modalMoreBtn, render, { - document: documentRef, - placement: "bottom-end", - restoreFocusOnClose: true, - }); - - if (!popover) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - return; - } - - const originalOpen = popover.open?.bind(popover); - if (originalOpen) { - popover.open = async (...args) => { - this.refreshModalMoreMenuPanel(); - return originalOpen(...args); - }; - } - - const originalDestroy = popover.destroy?.bind(popover); - if (originalDestroy) { - popover.destroy = (...args) => { - originalDestroy(...args); - if (this.modalMorePopover === popover) { - this.modalMorePopover = null; - this.modalMoreMenuPanel = null; - } - }; - } - - this.modalMoreBtn.dataset.moreMenuToggleBound = "true"; - this.modalMorePopover = popover; - } - - setupModalSharePopover() { - if (!this.shareBtn) { - this.modalSharePopover = null; - return; - } - - const documentRef = - this.shareBtn.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - const render = ({ document: doc, close }) => { - const panel = this.buildModalShareMenuPanel({ document: doc, close }); - return panel; - }; - - const popover = createPopover(this.shareBtn, render, { - document: documentRef, - placement: "bottom-end", - restoreFocusOnClose: true, - }); - - if (!popover) { - this.modalSharePopover = null; - return; - } - - const originalOpen = popover.open?.bind(popover); - if (originalOpen) { - popover.open = async (...args) => { - this.refreshModalShareMenuPanel(); - return originalOpen(...args); - }; - } - - const originalDestroy = popover.destroy?.bind(popover); - if (originalDestroy) { - popover.destroy = (...args) => { - originalDestroy(...args); - if (this.modalSharePopover === popover) { - this.modalSharePopover = null; - } - }; - } - - this.shareBtn.dataset.shareMenuToggleBound = "true"; - this.modalSharePopover = popover; - } - - buildModalShareMenuPanel({ document: doc, close }) { - const isLoggedIn = this.shareNostrAuthState.isLoggedIn; - const hasSigner = this.shareNostrAuthState.hasSigner; - - const hasMagnet = Boolean( - (typeof this.activeVideo?.magnet === "string" && - this.activeVideo.magnet.trim()) || - this.activeVideo?.torrentSupported - ); - - const hasCdn = Boolean( - typeof this.activeVideo?.url === "string" && this.activeVideo.url.trim() - ); - - const panel = createVideoShareMenuPanel({ - document: doc, - video: this.activeVideo, - isLoggedIn, - hasSigner, - hasMagnet, - hasCdn, - context: "modal", - }); - - if (!panel) { - return null; - } - - const buttons = panel.querySelectorAll("button[data-action]"); - buttons.forEach((button) => { - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - const dataset = {}; - Object.entries(button.dataset || {}).forEach(([key, value]) => { - dataset[key] = value; - }); - if (!dataset.context) { - dataset.context = "modal"; - } - - const action = dataset.action || ""; - - // Map menu actions to events - if (action === "share") { - this.dispatch("video:share", { video: this.activeVideo }); - } else if (action === "share-nostr") { - this.dispatch("video:share-nostr", { video: this.activeVideo }); - } else if (action === "copy-magnet") { - this.dispatch("video:copy-magnet", { video: this.activeVideo }); - } else if (action === "copy-cdn") { - this.dispatch("video:copy-cdn", { video: this.activeVideo }); - } - - if (typeof close === "function") { - close({ reason: "action" }); - } - }); - }); - - return panel; - } - - refreshModalShareMenuPanel() { - if (!this.modalSharePopover) { - return; - } - - const existingPanel = this.modalSharePopover.getPanel?.(); - const documentRef = - existingPanel?.ownerDocument || - this.shareBtn?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - if (!documentRef) { - return; - } - - const nextPanel = this.buildModalShareMenuPanel({ - document: documentRef, - close: (options) => this.modalSharePopover?.close(options), - }); - - if (!nextPanel) { - return; - } - - if (existingPanel && existingPanel.parentNode) { - existingPanel.parentNode.replaceChild(nextPanel, existingPanel); - } - } - - setShareNostrAuthState({ isLoggedIn, hasSigner } = {}) { - const nextIsLoggedIn = Boolean(isLoggedIn); - const nextHasSigner = Boolean(hasSigner); - const previous = this.shareNostrAuthState; - - if ( - previous && - previous.isLoggedIn === nextIsLoggedIn && - previous.hasSigner === nextHasSigner - ) { - return; - } - - this.shareNostrAuthState = { - isLoggedIn: nextIsLoggedIn, - hasSigner: nextHasSigner, - }; - - this.refreshModalShareMenuPanel(); - } - - buildModalMoreMenuPanel({ document: doc, close }) { - const panel = createVideoMoreMenuPanel({ - document: doc, - video: this.modalMoreMenuContext.video, - pointerInfo: this.modalMoreMenuContext.pointerInfo, - playbackUrl: this.modalMoreMenuContext.playbackUrl, - playbackMagnet: this.modalMoreMenuContext.playbackMagnet, - canManageBlacklist: this.modalMoreMenuContext.canManageBlacklist, - context: "modal", - }); - - if (!panel) { - return null; - } - - const buttons = panel.querySelectorAll("button[data-action]"); - buttons.forEach((button) => { - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - const dataset = {}; - Object.entries(button.dataset || {}).forEach(([key, value]) => { - dataset[key] = value; - }); - if (!dataset.context) { - dataset.context = "modal"; - } - - const action = dataset.action || ""; - this.dispatch("video:context-action", { - action, - dataset, - }); - - if (typeof close === "function") { - close({ reason: "action" }); - } - }); - }); - - return panel; - } - - refreshModalMoreMenuPanel() { - if (!this.modalMorePopover) { - return; - } - - const existingPanel = - this.modalMorePopover.getPanel?.() || this.modalMoreMenuPanel; - - const documentRef = - existingPanel?.ownerDocument || - this.modalMoreBtn?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - - if (!documentRef) { - return; - } - - const nextPanel = this.buildModalMoreMenuPanel({ - document: documentRef, - close: (options) => this.modalMorePopover?.close(options), - }); - - if (!nextPanel) { - return; - } - - const currentPanel = this.modalMorePopover.getPanel?.(); - if (currentPanel && currentPanel.parentNode) { - currentPanel.parentNode.replaceChild(nextPanel, currentPanel); - } else if (existingPanel?.parentNode) { - existingPanel.parentNode.replaceChild(nextPanel, existingPanel); - } - - this.modalMoreMenuPanel = nextPanel; - } - - handleCopyRequest(event) { - event?.preventDefault?.(); - if (this.copyMagnetBtn?.disabled) { - return; - } - this.dispatch("video:copy-magnet", { video: this.activeVideo }); - } - - handleSourceToggle(mode) { - if (!this.activeVideo || (mode !== "url" && mode !== "torrent")) { - return; - } - - this.dispatch("playback:switch-source", { - video: this.activeVideo, - source: mode, - }); - } - - updateSourceAvailability(video) { - const hasUrl = Boolean( - typeof video?.url === "string" && video.url.trim() - ); - const hasMagnet = Boolean( - (typeof video?.magnet === "string" && video.magnet.trim()) || - video?.torrentSupported - ); - const hasPointer = this.hasResolvablePointer(video); - - this.setShareEnabled(hasUrl || hasMagnet); - this.setEmbedEnabled(hasPointer); - - if (!this.sourceToggleContainer) { - return; - } - - if (hasUrl || hasMagnet) { - this.sourceToggleContainer.removeAttribute("hidden"); - this.sourceToggleContainer.classList.remove("hidden"); - } else { - this.sourceToggleContainer.setAttribute("hidden", ""); - this.sourceToggleContainer.classList.add("hidden"); - } - - this.sourceToggleButtons.forEach((btn) => { - const mode = btn.dataset.sourceToggle; - if (mode === "url") { - btn.disabled = !hasUrl; - } else if (mode === "torrent") { - btn.disabled = !hasMagnet; - } - }); - - if (this.activeServersLabel) { - const count = hasUrl ? 1 : 0; - this.activeServersLabel.textContent = - count === 1 ? "1 server" : `${count} servers`; - } - } - - updateSourceToggleState(activeSource) { - if (!this.sourceToggleButtons.length) { - return; - } - - const current = activeSource === "torrent" ? "torrent" : "url"; - this.sourceToggleButtons.forEach((btn) => { - const mode = btn.dataset.sourceToggle; - const isActive = mode === current; - btn.setAttribute("aria-pressed", isActive ? "true" : "false"); - }); - } - - handleShareRequest(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.shareBtn?.disabled) { - return; - } - if (this.modalSharePopover?.toggle) { - this.modalSharePopover.toggle(); - return; - } - if (this.modalSharePopover?.isOpen?.()) { - this.modalSharePopover.close?.(); - } else { - this.modalSharePopover?.open?.(); - } - } - - handleEmbedRequest(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.embedBtn?.disabled) { - return; - } - this.dispatch("video:embed", { video: this.activeVideo }); - } - - handleCreatorNavigation(event) { - event?.preventDefault?.(); - this.dispatch("creator:navigate", { video: this.activeVideo }); - } - - handleModalMoreButtonClick(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - if (this.modalMoreBtn?.disabled) { - return; - } - if (this.modalMorePopover?.toggle) { - this.modalMorePopover.toggle(); - return; - } - if (this.modalMorePopover?.isOpen?.()) { - this.modalMorePopover.close?.(); - } else { - this.modalMorePopover?.open?.(); - } - } - - handleReactionClick(event) { - event?.preventDefault?.(); - event?.stopPropagation?.(); - - const button = event?.currentTarget; - if (!button || typeof button !== "object") { - return; - } - - if (button.disabled || button.getAttribute("aria-disabled") === "true") { - return; - } - - const reactionValue = - typeof button.dataset?.reaction === "string" - ? button.dataset.reaction.trim() - : ""; - const normalized = - reactionValue === "+" - ? "+" - : reactionValue === "-" - ? "-" - : ""; - - if (!normalized) { - return; - } - - const currentReaction = this.reactionState?.userReaction || ""; - if (normalized === currentReaction) { - return; - } - - this.dispatch("video:reaction", { - video: this.activeVideo, - reaction: normalized, - previousReaction: currentReaction, - }); - } - /** * Resets the modal state, populates it with the provided video data, and makes it visible. * Also initializes moderation, comments, and accessibility traps. @@ -2805,7 +760,7 @@ export class VideoModal { // Ensure comments controller is initialized if elements are missing if ( this.commentsController && - (!this.commentsList || !this.commentsList.isConnected) + (!this.commentsController.commentsList || !this.commentsController.commentsList.isConnected) ) { this.commentsController.destroy(); this.commentsController.initialize({ playerModal: this.playerModal }); @@ -2857,9 +812,9 @@ export class VideoModal { if (this.modalMorePopover?.close) { this.modalMorePopover.close({ restoreFocus: false }); } - this.invokeCommentTeardown(); - this.clearComments(); - this.resetCommentComposer(); + this.commentsController.invokeCommentTeardown(); + this.commentsController.clearComments(); + this.commentsController.resetCommentComposer(); this.renderVideoTags([]); this.clearSimilarContent(); this.clearLinkPreviews(); diff --git a/js/ui/components/video-modal/commentNodeFactory.js b/js/ui/components/video-modal/commentNodeFactory.js new file mode 100644 index 00000000..9eadd4f7 --- /dev/null +++ b/js/ui/components/video-modal/commentNodeFactory.js @@ -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; + } + } + } +} diff --git a/js/ui/components/video-modal/commentsController.js b/js/ui/components/video-modal/commentsController.js index a89644f0..7d6cd3db 100644 --- a/js/ui/components/video-modal/commentsController.js +++ b/js/ui/components/video-modal/commentsController.js @@ -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; } } - diff --git a/js/ui/components/video-modal/commentsListRenderer.js b/js/ui/components/video-modal/commentsListRenderer.js new file mode 100644 index 00000000..37bd8ab8 --- /dev/null +++ b/js/ui/components/video-modal/commentsListRenderer.js @@ -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"); + } + } + } +}