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"); + } + } + } +}