import { createModalAccessibility } from "./modalAccessibility.js"; import createPopover from "../overlay/popoverEngine.js"; import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js"; import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; import { CommentsController } from "./video-modal/commentsController.js"; import { ReactionsController } from "./video-modal/reactionsController.js"; import { SimilarContentController } from "./video-modal/similarContentController.js"; import { ModerationController } from "./video-modal/moderationController.js"; import { normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, registerCommentAvatarFailure as registerCommentAvatarFailureUtil, } from "./video-modal/utils/commentAvatar.js"; import { renderTagPillStrip, applyTagPreferenceState, trimTagPillStripToFit, } from "./tagPillList.js"; import { SimilarContentCard } from "./SimilarContentCard.js"; import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount, formatViewCount, } from "../../viewCounter.js"; import { getModerationOverrideActionLabels, normalizeVideoModerationContext, } from "../moderationUiHelpers.js"; import { buildModerationBadgeText } from "../moderationCopy.js"; import { formatAbsoluteTimestamp, formatTimeAgo, formatShortNpub as defaultFormatShortNpub, } from "../../utils/formatters.js"; import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const SIMILAR_CONTENT_LIMIT = 10; export class VideoModal { constructor({ removeTrackingScripts, setGlobalModalState, document: doc, logger, mediaLoader, assets = {}, state = {}, helpers = {}, } = {}) { if (!doc) { throw new Error("VideoModal requires a document reference."); } if (typeof setGlobalModalState !== "function") { throw new Error("VideoModal requires setGlobalModalState helper."); } this.document = doc; this.window = doc.defaultView || globalThis; this.removeTrackingScripts = typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {}; this.setGlobalModalState = setGlobalModalState; const providedLogger = logger ?? devLogger; if (typeof providedLogger === "function") { this.logger = { log: providedLogger }; } else if (providedLogger && typeof providedLogger.log === "function") { this.logger = providedLogger; } else { this.logger = devLogger; } this.eventTarget = new EventTarget(); this.mediaLoader = null; const assetsConfig = assets && typeof assets === "object" ? assets : {}; this.fallbackThumbnailSrc = typeof assetsConfig.fallbackThumbnailSrc === "string" ? assetsConfig.fallbackThumbnailSrc : ""; const stateConfig = state && typeof state === "object" ? state : {}; this.thumbnailCache = stateConfig.loadedThumbnails instanceof Map ? stateConfig.loadedThumbnails : null; const helperConfig = helpers && typeof helpers === "object" ? helpers : {}; const fallbackFormatShort = (value) => { try { return defaultFormatShortNpub(value); } catch { return typeof value === "string" ? value : ""; } }; this.helpers = { safeEncodeNpub: typeof helperConfig.safeEncodeNpub === "function" ? helperConfig.safeEncodeNpub : () => "", formatShortNpub: typeof helperConfig.formatShortNpub === "function" ? helperConfig.formatShortNpub : fallbackFormatShort, }; this.loaded = false; this.playerModal = null; this.modalPanel = null; this.modalBackdrop = null; this.scrollRegion = null; this.modalVideo = null; this.videoStage = null; this.modalStatus = null; this.modalProgress = null; this.modalProgressStatus = null; this.modalStatsContainer = null; this.modalStats = null; this.modalPeers = null; this.modalSpeed = null; this.modalDownloaded = null; this.videoTitle = null; this.videoDescription = null; this.videoTimestamp = null; this.videoEditedTimestamp = null; this.videoViewCountEl = null; this.videoTagsRoot = null; this.videoTagsData = Object.freeze([]); this.videoTagsSourceData = this.videoTagsData; this.videoTagsResizeObserver = null; this.videoTagsLastObservedWidth = 0; this.handleVideoTagsResize = this.handleVideoTagsResize.bind(this); this.creatorAvatar = null; this.creatorName = null; this.creatorNpub = null; this.copyMagnetBtn = null; this.shareBtn = null; this.modalZapBtn = null; this.modalMoreBtn = null; this.reactionButtons = { "+": null, "-": null, }; this.reactionMeter = null; this.reactionMeterFill = null; this.reactionMeterLabel = null; this.reactionMeterAssistive = null; this.reactionCountLabels = { "+": null, "-": null, }; this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, userReaction: "", }; this.reactionNumberFormatter = null; this.similarContentContainer = null; this.similarContentHeading = null; this.similarContentList = null; this.similarContentCards = []; this.similarContentViewCountSubscriptions = []; this.pendingSimilarContent = null; this.similarContentVisible = false; this.similarContentMediaQuery = null; this.handleSimilarContentMediaChange = this.handleSimilarContentMediaChange.bind(this); this.modalAccessibility = null; this.modalNavScrollHandler = null; this.modalZapDialog = null; this.modalZapForm = null; this.modalZapAmountInput = null; this.modalZapCommentInput = null; this.modalZapSplitSummary = null; this.modalZapStatusEl = null; this.modalZapReceipts = null; this.modalZapSendBtn = null; this.modalZapCloseBtn = null; this.modalZapWalletPrompt = null; this.modalZapWalletLink = null; this.modalZapDialogOpen = false; this.modalZapPending = false; this.modalZapRequiresLogin = false; this.modalZapPopover = null; this.modalZapOpenPromise = null; this.modalZapPendingToggle = null; this.modalMorePopover = null; this.modalMoreMenuPanel = null; this.modalMoreMenuContext = { video: null, pointerInfo: null, playbackUrl: "", playbackMagnet: "", canManageBlacklist: false, }; this.commentsController = new CommentsController({ modal: this }); this.reactionsController = new ReactionsController({ modal: this }); this.similarContentController = new SimilarContentController({ modal: this }); this.moderationController = new ModerationController({ 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.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; this.ambientCanvas = null; this.detachAmbientBackground = null; this.activeVideo = null; this.activeModerationContext = null; this.tagPreferenceStateResolver = null; this.moderationOverlay = null; this.moderationBadge = null; this.moderationBadgeText = null; this.moderationActionsContainer = null; this.moderationPrimaryButton = null; this.moderationBlockButton = null; this.moderationPrimaryMode = ""; this.moderationBadgeId = ""; this.handleCopyRequest = this.handleCopyRequest.bind(this); this.handleShareRequest = this.handleShareRequest.bind(this); this.handleCreatorNavigation = this.handleCreatorNavigation.bind(this); this.handleModalMoreButtonClick = this.handleModalMoreButtonClick.bind(this); this.handleReactionClick = this.handleReactionClick.bind(this); this.handleVideoTagActivate = this.handleVideoTagActivate.bind(this); this.handleModerationOverrideClick = this.handleModerationOverrideClick.bind(this); this.handleModerationHideClick = this.handleModerationHideClick.bind(this); this.handleModerationBlockClick = this.handleModerationBlockClick.bind(this); this.handleGlobalModerationOverride = this.handleGlobalModerationOverride.bind(this); this.handleGlobalModerationBlock = this.handleGlobalModerationBlock.bind(this); this.handleGlobalModerationHide = (event) => 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); if (this.document) { this.document.addEventListener( "video:moderation-override", this.handleGlobalModerationOverride, ); this.document.addEventListener( "video:moderation-block", this.handleGlobalModerationBlock, ); this.document.addEventListener( "video:moderation-hide", this.handleGlobalModerationHide, ); } } log(message, ...args) { if (!message) { return; } if (this.logger && typeof this.logger.log === "function") { this.logger.log(message, ...args); return; } if (typeof this.logger === "function") { this.logger(message, ...args); return; } devLogger.log(message, ...args); } addEventListener(type, listener, options) { this.eventTarget.addEventListener(type, listener, options); } removeEventListener(type, listener, options) { this.eventTarget.removeEventListener(type, listener, options); } dispatch(type, detail, options = {}) { const config = options && typeof options === "object" ? { ...options } : Object.create(null); const event = new CustomEvent(type, { detail, cancelable: Boolean(config.cancelable), }); return this.eventTarget.dispatchEvent(event); } getRoot() { return this.playerModal; } getVideoElement() { return this.modalVideo; } setVideoElement(videoElement) { this.teardownAmbientGlow({ clear: false }); this.detachVideoEvents(); this.clearPosterCleanup(); if ( this.window && typeof this.window.HTMLVideoElement !== "undefined" && videoElement instanceof this.window.HTMLVideoElement ) { this.modalVideo = videoElement; this.bindVideoEvents(); } else { this.modalVideo = null; } this.attachAmbientGlow(); return this.modalVideo; } async load() { if (this.loaded) { const root = this.playerModal; const rootConnected = root && root.isConnected; if (rootConnected) { const video = this.modalVideo; const videoConnected = video && video.isConnected; if (!videoConnected) { const existingVideo = root.querySelector("#modalVideo"); if (existingVideo) { this.hydrate(root); if (this.modalVideo && this.modalVideo.isConnected) { return this.playerModal; } } this.loaded = false; } else { return this.playerModal; } } else { this.loaded = false; this.cleanupVideoTags(); this.teardownVideoTagsResizeObserver(); this.videoTagsRoot = null; this.playerModal = null; } } const existing = this.document.getElementById("playerModal"); if (existing) { if (this.playerModal === existing && this.loaded) { return this.playerModal; } this.hydrate(existing); this.loaded = true; return this.playerModal; } const response = await fetch("components/video-modal.html"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const html = await response.text(); const container = this.document.getElementById("modalContainer"); if (!container) { throw new Error("Modal container element not found!"); } const wrapper = this.document.createElement("div"); wrapper.innerHTML = html; this.removeTrackingScripts(wrapper); const fragment = this.document.createDocumentFragment(); while (wrapper.firstChild) { fragment.appendChild(wrapper.firstChild); } container.appendChild(fragment); const playerModal = container.querySelector("#playerModal"); if (!playerModal) { throw new Error("Player modal root not found in markup."); } applyDesignSystemAttributes(playerModal); this.hydrate(playerModal); this.loaded = true; return this.playerModal; } hydrate(playerModal) { if (this.modalAccessibility?.destroy) { this.modalAccessibility.destroy(); } this.modalAccessibility = null; if (this.modalZapPopover?.destroy) { this.modalZapPopover.destroy(); } if (this.modalMorePopover?.destroy) { this.modalMorePopover.destroy(); } this.modalZapPopover = null; this.modalZapOpenPromise = null; this.modalZapPendingToggle = null; this.modalMorePopover = null; this.modalMoreMenuPanel = null; this.teardownAmbientGlow({ clear: false }); if (this.moderationPrimaryButton) { if (this.moderationPrimaryMode === "override") { this.moderationPrimaryButton.removeEventListener( "click", this.handleModerationOverrideClick, ); } else if (this.moderationPrimaryMode === "hide") { this.moderationPrimaryButton.removeEventListener( "click", this.handleModerationHideClick, ); } } if (this.moderationBlockButton) { this.moderationBlockButton.removeEventListener( "click", this.handleModerationBlockClick, ); } this.moderationOverlay = null; this.moderationBadge = null; this.moderationBadgeText = null; this.moderationActionsContainer = null; this.moderationPrimaryButton = null; this.moderationPrimaryMode = ""; this.moderationBlockButton = null; const previousScrollRegion = this.scrollRegion; if (previousScrollRegion && this.modalNavScrollHandler) { previousScrollRegion.removeEventListener( "scroll", this.modalNavScrollHandler ); } this.commentsController.destroy(); this.reactionsController.destroy(); this.similarContentController.destroy(); this.moderationController.destroy(); this.clearSimilarContent({ preservePending: true }); this.playerModal = playerModal; this.modalPanel = playerModal.querySelector(".bv-modal__panel") || null; this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; this.scrollRegion = this.modalPanel || playerModal; const nextVideoTagsRoot = playerModal.querySelector("#videoTags") || null; if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); } this.teardownVideoTagsResizeObserver(); this.videoTagsRoot = nextVideoTagsRoot; if (this.videoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); } const { container: similarContainer } = this.similarContentController.initialize({ playerModal }) || {}; const statsContainerCandidate = playerModal.querySelector("[data-video-stats-container]") || playerModal .querySelector("[data-video-stats]") ?.closest("[data-video-stats-container]") || playerModal .querySelector("[data-video-stats]") ?.closest(".watch-container") || playerModal.querySelector("[data-video-stats]") || playerModal .querySelector(".video-modal__stats") ?.closest(".watch-container") || playerModal.querySelector(".video-modal__stats") || null; this.normalizeModalLayoutStructure(playerModal, { layout: playerModal.querySelector(".video-modal__layout") || null, primary: playerModal.querySelector(".video-modal__primary") || null, secondary: playerModal.querySelector(".video-modal__secondary") || null, similarContainer, statsContainer: statsContainerCandidate, }); const previousTags = Array.isArray(this.videoTagsSourceData) ? [...this.videoTagsSourceData] : []; this.videoTagsData = null; this.videoTagsSourceData = Object.freeze([]); this.moderationController.initialize({ playerModal }); this.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; this.modalProgress = playerModal.querySelector("#modalProgress") || null; this.modalProgressStatus = playerModal.querySelector("#modalProgressStatus") || null; this.modalStatsContainer = playerModal.querySelector("[data-video-stats-container]") || statsContainerCandidate || null; this.modalStats = playerModal.querySelector("[data-video-stats]") || this.modalStatsContainer?.querySelector("[data-video-stats]") || this.modalStatsContainer?.querySelector(".video-modal__stats") || playerModal.querySelector(".video-modal__stats") || null; this.modalPeers = playerModal.querySelector("#modalPeers") || null; this.modalSpeed = playerModal.querySelector("#modalSpeed") || null; this.modalDownloaded = playerModal.querySelector("#modalDownloaded") || null; this.setTorrentStatsVisibility(false); this.videoTitle = playerModal.querySelector("#videoTitle") || null; this.videoDescription = playerModal.querySelector("#videoDescription") || null; this.videoTimestamp = playerModal.querySelector("#videoTimestamp") || null; this.videoEditedTimestamp = playerModal.querySelector("#videoEditedTimestamp") || null; this.videoViewCountEl = playerModal.querySelector("#videoViewCount") || null; this.creatorAvatar = playerModal.querySelector("#creatorAvatar") || null; this.creatorName = playerModal.querySelector("#creatorName") || null; this.creatorNpub = playerModal.querySelector("#creatorNpub") || null; this.copyMagnetBtn = playerModal.querySelector("#copyMagnetBtn") || null; this.shareBtn = playerModal.querySelector("#shareBtn") || null; this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null; this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null; this.reactionsController.initialize({ playerModal }); this.renderVideoTags(previousTags); if (Array.isArray(this.pendingSimilarContent)) { const pendingSimilar = this.pendingSimilarContent; this.pendingSimilarContent = null; this.setSimilarContent(pendingSimilar); } this.ambientCanvas = playerModal.querySelector("#ambientCanvas") || null; this.modalZapDialog = playerModal.querySelector("#modalZapDialog") || null; this.modalZapForm = playerModal.querySelector("#modalZapForm") || null; this.modalZapAmountInput = playerModal.querySelector("#modalZapAmountInput") || null; this.modalZapCommentInput = playerModal.querySelector("#modalZapCommentInput") || null; this.modalZapSplitSummary = playerModal.querySelector("#modalZapSplitSummary") || null; this.modalZapStatusEl = playerModal.querySelector("#modalZapStatus") || null; this.modalZapReceipts = playerModal.querySelector("#modalZapReceipts") || null; this.modalZapSendBtn = playerModal.querySelector("#modalZapSendBtn") || null; this.modalZapCloseBtn = playerModal.querySelector("#modalZapCloseBtn") || null; this.modalZapWalletPrompt = playerModal.querySelector("#modalZapWalletPrompt") || null; this.modalZapWalletLink = playerModal.querySelector("#modalZapWalletLink") || null; this.modalZapDialogOpen = false; this.setupModalZapPopover(); this.setupModalMorePopover(); this.refreshActiveVideoModeration({ video: this.activeVideo }); this.commentsController.initialize({ playerModal }); const closeButton = playerModal.querySelector("#closeModal"); if (closeButton) { closeButton.addEventListener("click", () => { this.dispatch("modal:close", { video: this.activeVideo }); }); } const modalNav = playerModal.querySelector("#modalNav"); if (modalNav && this.scrollRegion) { let lastScrollY = 0; const updateNavVisibility = (shouldShowNav) => { modalNav.classList.toggle("modal-nav--hidden", !shouldShowNav); modalNav.classList.toggle("modal-nav--visible", shouldShowNav); }; this.modalNavScrollHandler = () => { const currentScrollY = this.scrollRegion.scrollTop; const shouldShowNav = currentScrollY <= lastScrollY || currentScrollY < 50; updateNavVisibility(shouldShowNav); lastScrollY = currentScrollY; }; updateNavVisibility(true); this.scrollRegion.addEventListener("scroll", this.modalNavScrollHandler); } else { this.modalNavScrollHandler = null; } this.modalAccessibility = createModalAccessibility({ root: this.playerModal, panel: this.modalPanel, backdrop: this.modalBackdrop, document: this.document, onRequestClose: () => { this.dispatch("modal:close", { video: this.activeVideo }); } }); this.bindVideoEvents(); this.bindActionButtons(); this.setZapVisibility(false); this.setCopyEnabled(false); this.setShareEnabled(false); this.resetStats(); this.attachAmbientGlow(); if (!this.activeVideo && this.playerModal) { this.playerModal.classList.add("hidden"); this.playerModal.setAttribute("hidden", ""); this.document?.body?.classList?.remove("modal-open"); this.document?.documentElement?.classList?.remove("modal-open"); } } 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; } }, 2000); 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.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 "#000000"; } 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.addEventListener("click", this.handleShareRequest); } if (this.modalZapBtn) { this.modalZapBtn.addEventListener("click", (event) => { event?.preventDefault?.(); event?.stopPropagation?.(); if (this.modalZapBtn?.disabled) { return; } if (this.modalZapRequiresLogin) { this.dispatch( "zap:open", { video: this.activeVideo, requiresLogin: true, }, { cancelable: false }, ); return; } const popoverIsOpen = this.isZapDialogOpen(); if (this.modalZapOpenPromise) { this.modalZapPendingToggle = "close"; this.closeZapDialog({ silent: true, restoreFocus: false }); return; } if (popoverIsOpen) { if (this.modalZapPending) { return; } this.closeZapDialog(); return; } const allowed = this.dispatch( "zap:open", { video: this.activeVideo }, { cancelable: true }, ); if (allowed === false) { return; } this.modalZapPendingToggle = null; this.openZapDialog(); }); } if (this.modalMoreBtn && this.modalMoreBtn.dataset.modalMenuHandler !== "true") { this.modalMoreBtn.dataset.modalMenuHandler = "true"; this.modalMoreBtn.addEventListener("click", this.handleModalMoreButtonClick); } if (this.modalZapCloseBtn) { this.modalZapCloseBtn.addEventListener("click", (event) => { event?.preventDefault?.(); this.closeZapDialog(); }); } if (this.modalZapWalletLink) { this.modalZapWalletLink.addEventListener("click", (event) => { event?.preventDefault?.(); this.dispatch("zap:wallet-link", { video: this.activeVideo }); }); } if (this.modalZapForm) { this.modalZapForm.addEventListener("submit", (event) => { event?.preventDefault?.(); if (this.modalZapSendBtn?.dataset.completed === "true") { this.closeZapDialog(); return; } if (this.modalZapSendBtn?.disabled) { return; } this.dispatch("video:zap", { video: this.activeVideo, amount: this.getZapAmountValue(), comment: this.getZapCommentValue() }); }); } if (this.modalZapSendBtn) { this.modalZapSendBtn.addEventListener("click", (event) => { if (this.modalZapSendBtn?.dataset.completed === "true") { event?.preventDefault?.(); this.closeZapDialog(); } }); } if (this.modalZapAmountInput) { const amountHandler = () => { this.dispatch("zap:amount-change", { video: this.activeVideo, amount: this.getZapAmountValue() }); }; this.modalZapAmountInput.addEventListener("input", amountHandler); this.modalZapAmountInput.addEventListener("change", amountHandler); } if (this.modalZapCommentInput) { const commentHandler = () => { this.dispatch("zap:comment-change", { video: this.activeVideo, comment: this.getZapCommentValue() }); }; this.modalZapCommentInput.addEventListener("input", commentHandler); } if (this.creatorAvatar) { this.creatorAvatar.addEventListener( "click", this.handleCreatorNavigation ); } if (this.creatorName) { this.creatorName.addEventListener("click", this.handleCreatorNavigation); } } setupModalZapPopover() { if (!this.modalZapDialog) { this.modalZapPopover = null; return; } if (!this.modalZapDialog.dataset.state) { const isHidden = this.modalZapDialog.hasAttribute("hidden") || this.modalZapDialog.getAttribute("aria-hidden") === "true"; this.modalZapDialog.dataset.state = isHidden ? "closed" : "open"; } if (this.modalZapDialog.dataset.state !== "open") { this.modalZapDialog.hidden = true; this.modalZapDialog.setAttribute("aria-hidden", "true"); this.modalZapDialogOpen = false; } if (!this.modalZapBtn) { this.modalZapPopover = null; return; } const documentRef = this.modalZapDialog.ownerDocument || this.modalZapBtn.ownerDocument || this.document || (typeof document !== "undefined" ? document : null); const popover = createPopover( this.modalZapBtn, () => this.modalZapDialog, { document: documentRef, placement: "bottom-end", restoreFocusOnClose: true, }, ); if (!popover) { this.modalZapPopover = null; return; } const originalOpen = popover.open?.bind(popover); if (originalOpen) { popover.open = async (...args) => { const result = await originalOpen(...args); if (result) { this.modalZapDialog.dataset.state = "open"; this.modalZapDialog.hidden = false; this.modalZapDialog.setAttribute("aria-hidden", "false"); this.modalZapDialogOpen = true; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "true"); } this.focusZapAmount(); } return result; }; } const originalClose = popover.close?.bind(popover); if (originalClose) { popover.close = (options = {}) => { const { silent = false, ...rest } = options; const wasOpen = popover.isOpen?.() === true; const result = originalClose(rest); const wasDialogMarkedOpen = this.modalZapDialogOpen === true || this.modalZapDialog?.dataset?.state === "open"; if (wasOpen || wasDialogMarkedOpen) { this.modalZapPendingToggle = null; this.modalZapOpenPromise = null; this.modalZapDialog.dataset.state = "closed"; this.modalZapDialog.setAttribute("aria-hidden", "true"); this.modalZapDialog.hidden = true; this.modalZapDialogOpen = false; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "false"); } if (!silent && (wasOpen || wasDialogMarkedOpen)) { this.dispatch("zap:close", { video: this.activeVideo }); } } return result; }; } const originalDestroy = popover.destroy?.bind(popover); if (originalDestroy) { popover.destroy = (...args) => { originalDestroy(...args); if (this.modalZapPopover === popover) { this.modalZapPopover = null; } }; } this.modalZapPopover = popover; } 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; } 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 }); } handleShareRequest(event) { event?.preventDefault?.(); if (this.shareBtn?.disabled) { return; } this.dispatch("video:share", { 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, }); } open(video, options = {}) { this.activeVideo = video || null; this.moderationBadgeId = ""; if (!this.playerModal) { return; } // Ensure title element reference is available if (!this.videoTitle) { this.videoTitle = this.playerModal.querySelector("#videoTitle") || null; } // Ensure comments controller is initialized if elements are missing if ( this.commentsController && (!this.commentsList || !this.commentsList.isConnected) ) { this.commentsController.destroy(); this.commentsController.initialize({ playerModal: this.playerModal }); } // Set title immediately when opening if video has a title if (this.videoTitle && video && typeof video.title === "string" && video.title.trim()) { this.videoTitle.textContent = video.title.trim(); this.videoTitle.hidden = false; } else if (this.videoTitle) { this.videoTitle.textContent = "Untitled"; this.videoTitle.hidden = false; } this.resetReactions(); this.playerModal.classList.remove("hidden"); this.playerModal.removeAttribute("hidden"); this.document.body.classList.add("modal-open"); this.document.documentElement.classList.add("modal-open"); const triggerElement = options && typeof options === "object" ? options.triggerElement : null; this.modalAccessibility?.activate({ triggerElement }); if (this.scrollRegion) { this.scrollRegion.scrollTop = 0; } this.setGlobalModalState("player", true); this.applyLoadingPoster(); this.refreshActiveVideoModeration({ video: this.activeVideo }); } close() { this.activeVideo = null; this.applyModerationOverlay(null); if (this.playerModal) { this.playerModal.classList.add("hidden"); this.playerModal.setAttribute("hidden", ""); } this.document.body.classList.remove("modal-open"); this.document.documentElement.classList.remove("modal-open"); this.modalAccessibility?.deactivate(); this.setGlobalModalState("player", false); this.closeZapDialog({ silent: true, restoreFocus: false }); if (this.modalMorePopover?.close) { this.modalMorePopover.close({ restoreFocus: false }); } this.invokeCommentTeardown(); this.clearComments(); this.resetCommentComposer(); this.renderVideoTags([]); this.clearSimilarContent(); this.forceRemovePoster("close"); } resetReactions() { this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, userReaction: "", }; this.syncReactionButtons(); this.updateReactionMeterDisplay(); } setUserReaction(reaction) { const normalized = reaction === "+" ? "+" : reaction === "-" ? "-" : ""; if (!this.reactionState) { this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, userReaction: normalized, }; } else { this.reactionState.userReaction = normalized; } this.syncReactionButtons(); } updateReactionSummary({ total, counts, userReaction } = {}) { if (!this.reactionState) { this.reactionState = { counts: { "+": 0, "-": 0 }, total: 0, userReaction: "", }; } if (counts && typeof counts === "object") { const nextCounts = { ...this.reactionState.counts }; for (const [key, value] of Object.entries(counts)) { nextCounts[key] = this.normalizeReactionCount(value); } this.reactionState.counts = nextCounts; } if (Number.isFinite(total)) { this.reactionState.total = Math.max(0, Number(total)); } else if (counts && typeof counts === "object") { let computedTotal = 0; for (const value of Object.values(this.reactionState.counts)) { computedTotal += this.normalizeReactionCount(value); } this.reactionState.total = computedTotal; } if (userReaction !== undefined) { this.setUserReaction(userReaction); } else { this.syncReactionButtons(); } this.updateReactionMeterDisplay(); } syncReactionButtons() { const current = this.reactionState?.userReaction || ""; const likeButton = this.reactionButtons?.["+"] || null; const dislikeButton = this.reactionButtons?.["-"] || null; if (likeButton) { likeButton.setAttribute("aria-pressed", current === "+" ? "true" : "false"); } if (dislikeButton) { dislikeButton.setAttribute("aria-pressed", current === "-" ? "true" : "false"); } } cleanupVideoTags(root = this.videoTagsRoot) { if (!root) { return; } const buttons = root.querySelectorAll("button"); buttons.forEach((button) => { const handler = button.__tagPillClickHandler; if (handler) { button.removeEventListener("click", handler); delete button.__tagPillClickHandler; } }); root.textContent = ""; } toggleVideoTagsVisibility(isVisible) { const root = this.videoTagsRoot; if (!root) { return; } if (isVisible) { root.classList.remove("hidden"); root.removeAttribute("hidden"); root.setAttribute("aria-hidden", "false"); return; } root.classList.add("hidden"); root.setAttribute("hidden", ""); root.setAttribute("aria-hidden", "true"); } normalizeVideoTags(tags) { if (!tags) { return []; } let iterable; if (Array.isArray(tags)) { iterable = tags; } else if (typeof tags === "string") { iterable = [tags]; } else if (typeof tags?.[Symbol.iterator] === "function") { iterable = Array.from(tags); } else { iterable = []; } const seen = new Set(); const normalized = []; for (const entry of iterable) { if (entry === null || entry === undefined) { continue; } const raw = typeof entry === "string" ? entry : String(entry ?? ""); const trimmed = raw.trim().replace(/^#+/, ""); if (!trimmed) { continue; } const key = trimmed.toLocaleLowerCase(); if (seen.has(key)) { continue; } seen.add(key); normalized.push(trimmed); } return normalized.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }), ); } areVideoTagsEqual(nextTags, previousTags) { if (!Array.isArray(nextTags) || !Array.isArray(previousTags)) { return false; } if (nextTags.length !== previousTags.length) { return false; } for (let index = 0; index < nextTags.length; index += 1) { if (nextTags[index] !== previousTags[index]) { return false; } } return true; } renderVideoTags(tags = [], options = {}) { const { force = false } = options || {}; const normalized = this.normalizeVideoTags(tags); const root = this.videoTagsRoot; if (root) { if (!force && this.areVideoTagsEqual(normalized, this.videoTagsData)) { const hasVisibleButtons = root.querySelector("button[data-tag]") !== null; this.toggleVideoTagsVisibility(hasVisibleButtons); if (hasVisibleButtons) { this.ensureVideoTagsResizeObserver(); this.refreshTagPreferenceStates(); } else { this.teardownVideoTagsResizeObserver(); } return; } this.cleanupVideoTags(root); let hasVisibleButtons = false; if (normalized.length > 0) { const { root: strip } = renderTagPillStrip({ document: root.ownerDocument || this.document, tags: normalized, onTagActivate: this.handleVideoTagActivate, getTagState: (tag) => this.resolveTagPreferenceState(tag), }); if (strip) { root.appendChild(strip); trimTagPillStripToFit({ strip, container: root }); if (strip.querySelector("button[data-tag]")) { hasVisibleButtons = true; } else if (strip.parentNode === root) { root.removeChild(strip); } } } this.toggleVideoTagsVisibility(hasVisibleButtons); if (hasVisibleButtons) { this.ensureVideoTagsResizeObserver(); this.refreshTagPreferenceStates(); } else { this.teardownVideoTagsResizeObserver(); } } else { this.teardownVideoTagsResizeObserver(); } const stored = Object.freeze([...normalized]); this.videoTagsData = stored; this.videoTagsSourceData = stored; } reflowVideoTags() { if (!Array.isArray(this.videoTagsSourceData)) { return; } if (this.videoTagsSourceData.length === 0) { return; } this.renderVideoTags([...this.videoTagsSourceData], { force: true }); } ensureVideoTagsResizeObserver() { const root = this.videoTagsRoot; if (!root) { return; } const ResizeObserverCtor = (this.window && this.window.ResizeObserver) || globalThis.ResizeObserver || null; if (typeof ResizeObserverCtor !== "function") { return; } if (!this.videoTagsResizeObserver) { this.videoTagsResizeObserver = new ResizeObserverCtor( this.handleVideoTagsResize, ); } else if (typeof this.videoTagsResizeObserver.disconnect === "function") { this.videoTagsResizeObserver.disconnect(); } try { this.videoTagsResizeObserver.observe(root); } catch (error) { // Ignore observer errors (e.g., observing a detached node). } } teardownVideoTagsResizeObserver() { if (this.videoTagsResizeObserver) { try { this.videoTagsResizeObserver.disconnect(); } catch (error) { // Ignore teardown errors. } } this.videoTagsResizeObserver = null; this.videoTagsLastObservedWidth = 0; } handleVideoTagsResize(entries = []) { if (!this.videoTagsRoot) { return; } const entry = Array.isArray(entries) && entries.length > 0 ? entries.find((item) => item?.target === this.videoTagsRoot) || entries[0] : null; const measuredWidthRaw = entry ? entry.contentRect && typeof entry.contentRect.width === "number" ? entry.contentRect.width : typeof entry.contentBoxSize?.[0]?.inlineSize === "number" ? entry.contentBoxSize[0].inlineSize : this.videoTagsRoot.clientWidth || 0 : this.videoTagsRoot.clientWidth || 0; const measuredWidth = Number.isFinite(measuredWidthRaw) ? Math.max(0, Math.round(measuredWidthRaw)) : 0; if (measuredWidth <= 0) { this.videoTagsLastObservedWidth = 0; return; } if (this.videoTagsLastObservedWidth === measuredWidth) { return; } this.videoTagsLastObservedWidth = measuredWidth; this.reflowVideoTags(); } handleVideoTagActivate(tag, { event, button } = {}) { const normalized = typeof tag === "string" ? tag : String(tag ?? ""); if (!normalized) { return; } this.dispatch("tag:activate", { tag: normalized, video: this.activeVideo, trigger: button || null, nativeEvent: event || null, }); } setTagPreferenceStateResolver(resolver) { this.tagPreferenceStateResolver = typeof resolver === "function" ? resolver : null; } resolveTagPreferenceState(tag) { if (typeof this.tagPreferenceStateResolver !== "function") { return "neutral"; } try { return this.tagPreferenceStateResolver(tag); } catch (error) { devLogger.warn( "[VideoModal] Failed to resolve tag preference state:", error, ); return "neutral"; } } refreshTagPreferenceStates() { const root = this.videoTagsRoot; if (!root) { return; } const buttons = root.querySelectorAll("button[data-tag]"); buttons.forEach((button) => { const tag = button.dataset.tag || ""; const state = this.resolveTagPreferenceState(tag); applyTagPreferenceState(button, state); }); } updateReactionMeterDisplay() { if (!this.reactionState) { return; } const counts = this.reactionState.counts || {}; const likeCount = this.normalizeReactionCount(counts["+"]); const dislikeCount = this.normalizeReactionCount(counts["-"]); const totalReactions = likeCount + dislikeCount; const likeRatio = totalReactions > 0 ? Math.round((likeCount / totalReactions) * 100) : 0; if (this.reactionCountLabels?.["+"]) { this.reactionCountLabels["+"].textContent = this.formatReactionCount( likeCount ); } if (this.reactionCountLabels?.["-"]) { this.reactionCountLabels["-"].textContent = this.formatReactionCount( dislikeCount ); } const meterLabel = totalReactions > 0 ? `${likeRatio}% positive` : "No reactions yet"; const assistiveLabel = totalReactions > 0 ? `${likeRatio}% positive (${this.formatReactionCount( likeCount )} likes and ${this.formatReactionCount(dislikeCount)} dislikes)` : "No reactions yet"; if (this.reactionMeter) { this.reactionMeter.setAttribute("aria-valuenow", String(likeRatio)); this.reactionMeter.setAttribute("aria-valuetext", assistiveLabel); } if (this.reactionMeterLabel) { this.reactionMeterLabel.textContent = meterLabel; } if (this.reactionMeterAssistive) { this.reactionMeterAssistive.textContent = assistiveLabel; } if (this.reactionMeterFill) { this.reactionMeterFill.style.inlineSize = `${likeRatio}%`; this.reactionMeterFill.style.width = `${likeRatio}%`; } } normalizeReactionCount(value) { if (Number.isFinite(value)) { return Math.max(0, Math.round(Number(value))); } const parsed = Number(value); return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : 0; } getReactionNumberFormatter() { if (this.reactionNumberFormatter === false) { return null; } if (this.reactionNumberFormatter) { return this.reactionNumberFormatter; } try { if (this.window?.Intl?.NumberFormat) { this.reactionNumberFormatter = new this.window.Intl.NumberFormat(); return this.reactionNumberFormatter; } } catch (error) { this.reactionNumberFormatter = false; return null; } this.reactionNumberFormatter = false; return null; } formatReactionCount(value) { const numeric = Number.isFinite(value) ? Number(value) : Number(value || 0); const safeValue = Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0; const formatter = this.getReactionNumberFormatter(); if (formatter) { try { return formatter.format(safeValue); } catch (error) { // fallback below } } return String(safeValue); } applyLoadingPoster() { if (!this.modalVideo) { return; } this.clearPosterCleanup(); const clearPoster = () => { this.forceRemovePoster("playback-event"); }; this.modalVideo.addEventListener("loadeddata", clearPoster); this.modalVideo.addEventListener("playing", clearPoster); this.modalVideo.poster = this.MODAL_LOADING_POSTER; this.modalPosterCleanup = () => { if (!this.modalVideo) { return; } this.modalVideo.removeEventListener("loadeddata", clearPoster); this.modalVideo.removeEventListener("playing", clearPoster); this.modalPosterCleanup = null; }; } clearPosterCleanup() { if (typeof this.modalPosterCleanup === "function") { this.modalPosterCleanup(); } this.modalPosterCleanup = null; } forceRemovePoster(reason = "manual-clear") { if (!this.modalVideo) { return false; } this.clearPosterCleanup(); const videoEl = this.modalVideo; const hadPoster = videoEl.hasAttribute("poster") || (typeof videoEl.poster === "string" && videoEl.poster !== ""); if (!hadPoster) { return false; } videoEl.poster = ""; if (videoEl.hasAttribute("poster")) { videoEl.removeAttribute("poster"); } this.log(`[VideoModal] Cleared loading poster (${reason}).`); return true; } resetStats() { this.updatePeers(""); this.updateSpeed(""); this.updateDownloaded(""); this.updateProgress("0%"); this.setTorrentStatsVisibility(false); } updateStatus(message) { if (this.modalStatus) { this.modalStatus.textContent = message || ""; } } setTorrentStatsVisibility(shouldShow) { const visible = Boolean(shouldShow); const container = this.modalStatsContainer; const stats = this.modalStats; const toggle = (element, show) => { if (!element) { return; } if (show) { element.removeAttribute("hidden"); element.classList?.remove("hidden"); } else { element.setAttribute("hidden", ""); element.classList?.add("hidden"); } }; if (container) { toggle(container, visible); if (stats) { if (visible) { stats.removeAttribute("hidden"); stats.classList?.remove("hidden"); } else { stats.setAttribute("hidden", ""); stats.classList?.add("hidden"); } } return; } if (stats) { toggle(stats, visible); } } updatePeers(text) { if (this.modalPeers) { this.modalPeers.textContent = text || ""; } } updateSpeed(text) { if (this.modalSpeed) { this.modalSpeed.textContent = text || ""; } } updateDownloaded(text) { if (this.modalDownloaded) { this.modalDownloaded.textContent = text || ""; } } updateProgress(value) { if (!this.modalProgress) { return; } let nextValue = null; if (typeof value === "number" && Number.isFinite(value)) { nextValue = value; } else if (typeof value === "string") { const trimmed = value.trim(); if (trimmed) { const parsed = Number.parseFloat(trimmed); if (Number.isFinite(parsed)) { nextValue = parsed; } } } if (!Number.isFinite(nextValue)) { this.modalProgress.value = 0; delete this.modalProgress.dataset.progress; this.modalProgress.dataset.state = "idle"; this.modalProgress.setAttribute( "aria-valuetext", "Download progress unavailable", ); if (this.modalProgressStatus) { this.modalProgressStatus.textContent = ""; } return; } const clamped = Math.max(0, Math.min(100, nextValue)); this.modalProgress.max = 100; this.modalProgress.value = clamped; this.modalProgress.dataset.progress = String(clamped); const state = clamped >= 100 ? "complete" : "active"; this.modalProgress.dataset.state = state; const valueText = `Download ${clamped}% complete`; this.modalProgress.setAttribute("aria-valuetext", valueText); if (this.modalProgressStatus) { this.modalProgressStatus.textContent = valueText; } } setCopyEnabled(enabled) { if (!this.copyMagnetBtn) { return; } this.copyMagnetBtn.disabled = !enabled; this.copyMagnetBtn.setAttribute("aria-disabled", (!enabled).toString()); this.copyMagnetBtn.classList.toggle("opacity-50", !enabled); this.copyMagnetBtn.classList.toggle("cursor-not-allowed", !enabled); } setShareEnabled(enabled) { if (!this.shareBtn) { return; } this.shareBtn.disabled = !enabled; this.shareBtn.setAttribute("aria-disabled", (!enabled).toString()); this.shareBtn.classList.toggle("opacity-50", !enabled); this.shareBtn.classList.toggle("cursor-not-allowed", !enabled); } setZapVisibility(visible, options = {}) { let config; if (typeof visible === "object" && visible !== null) { config = { ...visible }; } else { config = { visible, ...(options && typeof options === "object" ? options : {}), }; } const shouldShow = !!config.visible; const requiresLogin = shouldShow && !!config.requiresLogin; this.modalZapRequiresLogin = requiresLogin; if (this.modalZapBtn) { this.modalZapBtn.toggleAttribute("hidden", !shouldShow); const disableButton = !shouldShow || (this.modalZapPending && !requiresLogin); this.modalZapBtn.disabled = disableButton; const ariaDisabledValue = requiresLogin || !shouldShow ? "true" : "false"; this.modalZapBtn.setAttribute("aria-disabled", ariaDisabledValue); this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString()); this.modalZapBtn.setAttribute("aria-expanded", "false"); if (shouldShow) { this.modalZapBtn.removeAttribute("tabindex"); } else { this.modalZapBtn.setAttribute("tabindex", "-1"); } if (requiresLogin) { this.modalZapBtn.dataset.requiresLogin = "true"; this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); } else { delete this.modalZapBtn.dataset.requiresLogin; if (this.modalZapPending) { this.modalZapBtn.setAttribute("aria-busy", "true"); this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); } else { this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove( "opacity-50", "pointer-events-none", ); } } } if (!shouldShow || requiresLogin) { this.closeZapDialog({ silent: true, restoreFocus: false }); } } setWalletPromptVisible(visible) { if (!this.modalZapWalletPrompt) { return; } const shouldShow = !!visible; this.modalZapWalletPrompt.toggleAttribute("hidden", !shouldShow); this.modalZapWalletPrompt.setAttribute( "aria-hidden", (!shouldShow).toString() ); } async openZapDialog() { if (this.modalZapRequiresLogin) { return Promise.resolve(false); } if (this.modalZapOpenPromise) { return this.modalZapOpenPromise; } const runOpen = async () => { if (this.modalZapPopover?.open) { const opened = await this.modalZapPopover.open(); if (opened) { this.modalZapDialogOpen = true; if (this.modalZapDialog) { this.modalZapDialog.dataset.state = "open"; this.modalZapDialog.hidden = false; this.modalZapDialog.setAttribute("aria-hidden", "false"); } this.focusZapAmount(); return true; } if (this.modalZapDialog) { this.modalZapDialog.hidden = false; this.modalZapDialog.dataset.state = "open"; this.modalZapDialog.setAttribute("aria-hidden", "false"); this.modalZapDialogOpen = true; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "true"); } this.focusZapAmount(); return true; } return opened; } if (!this.modalZapDialog) { return false; } this.modalZapDialog.hidden = false; this.modalZapDialog.dataset.state = "open"; this.modalZapDialog.setAttribute("aria-hidden", "false"); this.modalZapDialogOpen = true; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "true"); } this.focusZapAmount(); return true; }; const promise = runOpen().catch((error) => { this.log("[VideoModal] Failed to open zap popover", error); return false; }); this.modalZapOpenPromise = promise.finally(() => { const shouldClose = this.modalZapPendingToggle === "close"; this.modalZapOpenPromise = null; this.modalZapPendingToggle = null; if (shouldClose && !this.modalZapPending) { this.closeZapDialog(); } }); return this.modalZapOpenPromise; } closeZapDialog({ silent = false, restoreFocus } = {}) { const dialogAppearsOpen = this.isZapDialogOpen(); if (this.modalZapOpenPromise && !dialogAppearsOpen) { this.modalZapPendingToggle = "close"; return; } this.modalZapPendingToggle = null; if (this.modalZapPopover?.close) { const options = { silent }; if (restoreFocus !== undefined) { options.restoreFocus = restoreFocus; } const closeResult = this.modalZapPopover.close(options); if ( closeResult !== true && this.modalZapDialog && this.modalZapDialogOpen ) { this.modalZapDialog.dataset.state = "closed"; this.modalZapDialog.setAttribute("aria-hidden", "true"); this.modalZapDialog.hidden = true; this.modalZapDialogOpen = false; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "false"); } if (!silent) { this.dispatch("zap:close", { video: this.activeVideo }); } } return; } if (!this.modalZapDialog) { return; } if (this.modalZapDialogOpen) { this.modalZapDialog.dataset.state = "closed"; this.modalZapDialog.setAttribute("aria-hidden", "true"); this.modalZapDialog.hidden = true; this.modalZapDialogOpen = false; if (this.modalZapBtn) { this.modalZapBtn.setAttribute("aria-expanded", "false"); } if (!silent) { this.dispatch("zap:close", { video: this.activeVideo }); } } } isZapDialogOpen() { const popoverIsOpen = typeof this.modalZapPopover?.isOpen === "function" ? this.modalZapPopover.isOpen() : null; if (popoverIsOpen === true) { return true; } if (popoverIsOpen === false && this.modalZapDialogOpen) { return true; } if (this.modalZapDialog?.dataset?.state === "open") { return true; } if (this.modalZapDialog && this.modalZapDialog.hidden === false) { return true; } return !!this.modalZapDialogOpen; } focusZapAmount() { if ( this.modalZapAmountInput && typeof this.modalZapAmountInput.focus === "function" ) { this.modalZapAmountInput.focus(); } } getZapAmountValue() { if (!this.modalZapAmountInput) { return 0; } const numeric = Number(this.modalZapAmountInput.value); if (!Number.isFinite(numeric)) { return 0; } return Math.max(0, Math.round(numeric)); } setZapAmount(value) { if (!this.modalZapAmountInput) { return; } if (value === null || value === undefined || value === "") { this.modalZapAmountInput.value = ""; return; } const numeric = Number(value); if (Number.isFinite(numeric)) { this.modalZapAmountInput.value = Math.max(0, Math.round(numeric)); return; } this.modalZapAmountInput.value = value; } getZapCommentValue() { if (!this.modalZapCommentInput) { return ""; } return (this.modalZapCommentInput.value || "").trim(); } setZapComment(value) { if (!this.modalZapCommentInput) { return; } this.modalZapCommentInput.value = typeof value === "string" ? value : ""; } resetZapForm({ amount = "", comment = "" } = {}) { this.setZapAmount(amount); this.setZapComment(comment); this.setZapStatus("", "neutral"); this.clearZapReceipts(); this.setZapRetryPending(false); this.setZapCompleted(false); } setZapSplitSummary(text) { if (!this.modalZapSplitSummary) { return; } const message = typeof text === "string" ? text : ""; this.modalZapSplitSummary.textContent = message || "Enter an amount to view the split."; } setZapStatus(message, tone = "neutral") { if (!this.modalZapStatusEl) { return; } const normalizedTone = typeof tone === "string" ? tone : "neutral"; const text = typeof message === "string" ? message : ""; this.modalZapStatusEl.textContent = text; this.modalZapStatusEl.classList.remove( "text-text", "text-muted", "text-info", "text-critical", "text-warning-strong" ); if (!text) { this.modalZapStatusEl.classList.add("text-muted"); return; } if (normalizedTone === "success") { this.modalZapStatusEl.classList.add("text-info"); } else if (normalizedTone === "error") { this.modalZapStatusEl.classList.add("text-critical"); } else if (normalizedTone === "warning") { this.modalZapStatusEl.classList.add("text-warning-strong"); } else { this.modalZapStatusEl.classList.add("text-text"); } } clearZapReceipts() { if (!this.modalZapReceipts) { return; } while (this.modalZapReceipts.firstChild) { this.modalZapReceipts.removeChild(this.modalZapReceipts.firstChild); } } renderZapReceipts(receipts = [], { partial = false } = {}) { if (!this.modalZapReceipts || !this.document) { return; } this.clearZapReceipts(); if (!Array.isArray(receipts) || receipts.length === 0) { if (partial) { const empty = this.document.createElement("li"); empty.className = "text-sm text-text"; empty.textContent = "No zap receipts available."; this.modalZapReceipts.appendChild(empty); } return; } const normalized = receipts.filter( (receipt) => receipt && typeof receipt === "object" ); const validatedReceipts = normalized.filter( (receipt) => receipt?.validation?.status === "passed" && receipt.validation.event ); const paymentFailures = normalized.filter((receipt) => { if (!receipt) { return false; } const status = typeof receipt.status === "string" ? receipt.status.toLowerCase() : ""; return status && status !== "success"; }); const unvalidatedReceipts = normalized.filter((receipt) => { if (!receipt) { return false; } const status = typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success"; if (status !== "success" && status !== "") { return false; } const validationStatus = typeof receipt.validation?.status === "string" ? receipt.validation.status.toLowerCase() : ""; if (!validationStatus || validationStatus === "skipped") { return false; } return validationStatus !== "passed"; }); const renderReceiptItem = ({ receipt, tone }) => { const li = this.document.createElement("li"); li.className = "rounded border border-border p-3 bg-overlay-panel-soft"; const header = this.document.createElement("div"); header.className = "flex items-center justify-between gap-2 text-xs text-text"; const shareType = receipt.recipientType || receipt.type || "creator"; const shareLabel = this.document.createElement("span"); const isPlatformShare = shareType === "platform"; const label = isPlatformShare ? "Platform fee" : shareType === "creator" ? "Creator" : "Lightning"; shareLabel.textContent = `${label} • ${Math.max( 0, Math.round(Number(receipt.amount || 0)) )} sats`; const status = this.document.createElement("span"); if (tone === "validated") { status.textContent = "Validated"; status.className = "text-info"; } else if (tone === "failed") { status.textContent = "Failed"; status.className = "text-critical"; } else { status.textContent = "Pending"; status.className = "text-muted"; } header.appendChild(shareLabel); header.appendChild(status); li.appendChild(header); const address = this.document.createElement("p"); address.className = "mt-1 text-xs text-text break-all"; if (receipt.address && !isPlatformShare) { address.textContent = receipt.address; li.appendChild(address); } const detail = this.document.createElement("p"); detail.className = "mt-2 text-xs text-muted"; if (tone === "failed") { const errorMessage = (receipt.error && receipt.error.message) || (typeof receipt.error === "string" ? receipt.error : "Payment failed."); detail.textContent = errorMessage; } else { let detailMessage = "Invoice settled."; const preimage = receipt.payment?.result?.preimage; if (typeof preimage === "string" && preimage) { detailMessage = `Preimage: ${preimage.slice(0, 18)}${ preimage.length > 18 ? "…" : "" }`; } detail.textContent = detailMessage; } li.appendChild(detail); this.modalZapReceipts.appendChild(li); }; validatedReceipts.forEach((receipt) => { renderReceiptItem({ receipt, tone: "validated" }); }); paymentFailures.forEach((receipt) => { renderReceiptItem({ receipt, tone: "failed" }); }); if (!validatedReceipts.length && !paymentFailures.length && !unvalidatedReceipts.length) { const empty = this.document.createElement("li"); empty.className = "text-sm text-text"; empty.textContent = partial ? "No zap receipts available." : "No zap receipts published yet."; this.modalZapReceipts.appendChild(empty); return; } if (unvalidatedReceipts.length) { const warning = this.document.createElement("li"); warning.className = "rounded border border-border p-3 bg-overlay-panel-soft text-xs text-warning-strong"; const summaries = unvalidatedReceipts.map((receipt) => { const shareType = receipt.recipientType || receipt.type || "creator"; const label = shareType === "platform" ? "Platform" : shareType === "creator" ? "Creator" : "Lightning"; const address = typeof receipt.address === "string" && receipt.address ? ` (${receipt.address})` : ""; const reason = typeof receipt.validation?.reason === "string" && receipt.validation.reason ? ` — ${receipt.validation.reason}` : " — Awaiting compliant receipt."; return `${label}${address}${reason}`; }); const intro = validatedReceipts.length ? "Awaiting validated zap receipt for remaining share(s)." : "No validated zap receipts yet."; warning.textContent = `${intro} ${summaries.join(" ")}`.trim(); this.modalZapReceipts.appendChild(warning); } } setZapPending(pending) { const isPending = !!pending; this.modalZapPending = isPending; if (this.modalZapSendBtn) { this.modalZapSendBtn.disabled = isPending; this.modalZapSendBtn.setAttribute( "aria-busy", isPending ? "true" : "false" ); this.modalZapSendBtn.classList.toggle("opacity-50", isPending); this.modalZapSendBtn.classList.toggle("pointer-events-none", isPending); } if (this.modalZapAmountInput) { this.modalZapAmountInput.disabled = isPending; } if (this.modalZapCommentInput) { this.modalZapCommentInput.disabled = isPending; } if (this.modalZapCloseBtn) { this.modalZapCloseBtn.disabled = isPending; this.modalZapCloseBtn.classList.toggle("opacity-50", isPending); this.modalZapCloseBtn.classList.toggle("pointer-events-none", isPending); } if (this.modalZapBtn) { const buttonHidden = this.modalZapBtn.hasAttribute("hidden"); if (isPending && !this.modalZapRequiresLogin) { this.modalZapBtn.disabled = true; this.modalZapBtn.setAttribute("aria-busy", "true"); this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); } else if (!buttonHidden) { this.modalZapBtn.disabled = false; this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); } else { this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); this.modalZapBtn.disabled = true; } } } setZapRetryPending(pending, { summary = "" } = {}) { if (!this.modalZapSendBtn) { return; } if (pending) { delete this.modalZapSendBtn.dataset.completed; this.modalZapSendBtn.dataset.retryPending = "true"; if (summary) { this.modalZapSendBtn.dataset.retrySummary = summary; } else { delete this.modalZapSendBtn.dataset.retrySummary; } } else { delete this.modalZapSendBtn.dataset.retryPending; delete this.modalZapSendBtn.dataset.retrySummary; } this.applyZapSendButtonState(); } setZapCompleted(completed) { if (!this.modalZapSendBtn) { return; } if (completed) { delete this.modalZapSendBtn.dataset.retryPending; delete this.modalZapSendBtn.dataset.retrySummary; this.modalZapSendBtn.dataset.completed = "true"; } else { delete this.modalZapSendBtn.dataset.completed; } this.applyZapSendButtonState(); } applyZapSendButtonState() { if (!this.modalZapSendBtn) { return; } if (this.modalZapSendBtn.dataset.completed === "true") { this.modalZapSendBtn.textContent = "Done"; this.modalZapSendBtn.setAttribute("aria-label", "Close zap dialog"); this.modalZapSendBtn.title = "Close zap dialog"; return; } if (this.modalZapSendBtn.dataset.retryPending === "true") { this.modalZapSendBtn.textContent = "Retry"; this.modalZapSendBtn.setAttribute( "aria-label", "Retry failed zap shares" ); const summary = this.modalZapSendBtn.dataset.retrySummary; if (summary) { this.modalZapSendBtn.title = summary; } else { this.modalZapSendBtn.removeAttribute("title"); } return; } this.modalZapSendBtn.textContent = "Send"; this.modalZapSendBtn.setAttribute("aria-label", "Send a zap"); this.modalZapSendBtn.removeAttribute("title"); } getViewCountElement() { return this.videoViewCountEl; } updateViewCountLabel(text) { if (this.videoViewCountEl) { this.videoViewCountEl.textContent = text || ""; } } setViewCountPointer(pointerKey) { if (!this.videoViewCountEl) { return; } if (pointerKey) { this.videoViewCountEl.dataset.viewPointer = pointerKey; } else if (this.videoViewCountEl.dataset?.viewPointer) { delete this.videoViewCountEl.dataset.viewPointer; } } updateMetadata({ title, description, timestamp, timestamps, viewCount, creator, tags, } = {}) { if (title !== undefined) { // Try to find the title element if not already cached if (!this.videoTitle) { if (this.playerModal) { this.videoTitle = this.playerModal.querySelector("#videoTitle") || null; } // Fallback: try document query if playerModal query failed if (!this.videoTitle) { this.videoTitle = this.document.getElementById("videoTitle") || null; } } if (this.videoTitle) { const titleText = typeof title === "string" && title.trim() ? title.trim() : "Untitled"; this.videoTitle.textContent = titleText; // Ensure element is visible this.videoTitle.hidden = false; if (this.videoTitle.style) { this.videoTitle.style.display = ""; } } else { // Log for debugging if element cannot be found this.logger?.log?.( "[VideoModal] Could not find #videoTitle element to set title:", title ); } } if (this.videoDescription && description !== undefined) { this.renderVideoDescription(description); } if (tags !== undefined) { this.renderVideoTags(tags ?? []); } if (timestamps) { this.updateTimestamps(timestamps); } else if (timestamp !== undefined) { this.updateTimestamps({ posted: timestamp }); } if (this.videoViewCountEl && viewCount !== undefined) { if (typeof viewCount === "string") { this.updateViewCountLabel(viewCount); } else { this.updateViewCountLabel(""); } } if (creator !== undefined) { this.updateCreator(creator); } this.refreshActiveVideoModeration({ video: this.activeVideo }); } getModerationBadgeId() { if (this.moderationBadgeId) { return this.moderationBadgeId; } const baseId = typeof this.activeVideo?.id === "string" && this.activeVideo.id ? this.activeVideo.id : ""; if (!baseId) { this.moderationBadgeId = ""; return this.moderationBadgeId; } const sanitized = baseId.replace(/[^a-zA-Z0-9_-]/g, ""); if (!sanitized) { this.moderationBadgeId = ""; return this.moderationBadgeId; } this.moderationBadgeId = `video-modal-${sanitized}-moderation`; return this.moderationBadgeId; } refreshActiveVideoModeration({ video = this.activeVideo } = {}) { if (!video) { this.activeModerationContext = null; this.applyModerationOverlay(null); return; } const context = normalizeVideoModerationContext(video.moderation); this.activeModerationContext = context; this.applyModerationOverlay(context); } applyModerationOverlay(context) { const stage = this.videoStage; const overlay = this.moderationOverlay; const badge = this.moderationBadge; const textEl = this.moderationBadgeText; const showModeration = Boolean(context?.shouldShow); const shouldBlur = Boolean(context?.activeBlur && !context?.overrideActive); const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive); if (!showModeration) { if (stage && stage.dataset.visualState === "blurred") { delete stage.dataset.visualState; } if (overlay) { overlay.setAttribute("hidden", ""); if (overlay.dataset.overlayState) { delete overlay.dataset.overlayState; } } if (badge) { badge.dataset.variant = context?.overrideActive ? "neutral" : "warning"; if (badge.dataset.moderationState) { delete badge.dataset.moderationState; } if (badge.dataset.moderationHideReason) { delete badge.dataset.moderationHideReason; } badge.removeAttribute("title"); if (badge.id && !this.activeVideo) { badge.removeAttribute("id"); } badge.removeAttribute("aria-label"); } this.removeModerationPrimaryButton(); this.removeModerationBlockButton(); return; } if (stage) { if (shouldBlur) { stage.dataset.visualState = "blurred"; } else if (stage.dataset.visualState === "blurred") { delete stage.dataset.visualState; } } if (overlay) { if (showModeration && !context?.overrideActive) { overlay.removeAttribute("hidden"); } else { overlay.setAttribute("hidden", ""); } overlay.dataset.overlayState = context?.overrideActive ? "override" : "active"; } if (shouldBlur && this.modalVideo && typeof this.modalVideo.pause === "function") { try { this.modalVideo.pause(); } catch (error) { // ignore pause failures (e.g., in unsupported environments) } } const badgeId = this.getModerationBadgeId(); const textContent = buildModerationBadgeText(context, { variant: "modal" }); if (badge) { const state = context?.overrideActive ? "override" : hiddenActive ? "hidden" : context?.trustedMuted ? "trusted-mute" : "blocked"; badge.dataset.variant = context?.overrideActive ? "neutral" : "warning"; badge.dataset.moderationState = state; if (hiddenActive && context?.effectiveHideReason) { badge.dataset.moderationHideReason = context.effectiveHideReason; } else if (badge.dataset.moderationHideReason) { delete badge.dataset.moderationHideReason; } if (badgeId) { badge.id = badgeId; } else if (badge.id && !this.activeVideo) { badge.removeAttribute("id"); } if (textEl) { textEl.textContent = textContent; } const muteNames = Array.isArray(context?.trustedMuteDisplayNames) ? context.trustedMuteDisplayNames .map((name) => (typeof name === "string" ? name.trim() : "")) .filter(Boolean) : []; const reporterNames = Array.isArray(context?.reporterDisplayNames) ? context.reporterDisplayNames .map((name) => (typeof name === "string" ? name.trim() : "")) .filter(Boolean) : []; const allNames = [...muteNames, ...reporterNames]; const uniqueNames = []; const seenNameKeys = new Set(); for (const name of allNames) { if (!name) { continue; } const key = name.toLowerCase(); if (seenNameKeys.has(key)) { continue; } seenNameKeys.add(key); uniqueNames.push(name); } if (uniqueNames.length) { const joined = uniqueNames.join(", "); const hasMuted = muteNames.length > 0; const hasReporters = reporterNames.length > 0; const prefix = hasMuted && hasReporters ? "Muted/Reported by" : hasMuted ? "Muted by" : "Reported by"; badge.title = `${prefix} ${joined}`; const baseLabel = textContent?.trim() || textEl?.textContent?.trim() || ""; if (baseLabel) { badge.setAttribute("aria-label", `${baseLabel}. ${prefix} ${joined}.`); } else { badge.setAttribute("aria-label", `${prefix} ${joined}.`); } } else { badge.removeAttribute("title"); const baseLabel = textContent?.trim() || textEl?.textContent?.trim(); if (baseLabel) { badge.setAttribute("aria-label", `${baseLabel}.`); } else { badge.removeAttribute("aria-label"); } } } const actions = this.ensureModerationActionsContainer(); let actionsAttached = false; const allowOverride = context?.overrideActive === true || context?.allowOverride === true || (shouldBlur && !context?.overrideActive && context?.blurReason !== "viewer-mute"); if (allowOverride) { const mode = context.overrideActive ? "hide" : "override"; const primaryButton = this.ensureModerationPrimaryButton(mode); if (primaryButton) { primaryButton.disabled = false; primaryButton.removeAttribute("aria-busy"); if (badgeId) { primaryButton.setAttribute("aria-describedby", badgeId); } else { primaryButton.removeAttribute("aria-describedby"); } if (actions && primaryButton.parentElement !== actions) { actions.appendChild(primaryButton); } actionsAttached = true; } } else { this.removeModerationPrimaryButton(); } if (this.shouldShowModerationBlockAction(context)) { const blockButton = this.ensureModerationBlockButton(); if (blockButton) { blockButton.disabled = false; blockButton.removeAttribute("aria-busy"); if (badgeId) { blockButton.setAttribute("aria-describedby", badgeId); } else { blockButton.removeAttribute("aria-describedby"); } if (actions && blockButton.parentElement !== actions) { actions.appendChild(blockButton); } actionsAttached = true; } } else { this.removeModerationBlockButton(); } if (actions) { if (actionsAttached) { if (badge && actions.parentElement !== badge) { badge.appendChild(actions); } } else if (actions.parentElement) { actions.parentElement.removeChild(actions); this.moderationActionsContainer = null; } else { this.moderationActionsContainer = null; } } } shouldShowModerationBlockAction(context = this.activeModerationContext) { if (!context || !context.trustedMuted) { return false; } if (context.activeHidden && !context.overrideActive) { return false; } return true; } ensureModerationActionsContainer() { const badge = this.moderationBadge; if (!badge) { this.moderationActionsContainer = null; return null; } let container = this.moderationActionsContainer; if (container && container.isConnected) { return container; } const existing = badge.querySelector("[data-moderation-actions]") || badge.querySelector(".moderation-badge__actions"); if (existing) { this.moderationActionsContainer = existing; return existing; } if (!this.document) { this.moderationActionsContainer = null; return null; } const created = this.document.createElement("div"); created.className = "moderation-badge__actions"; this.moderationActionsContainer = created; return created; } ensureModerationPrimaryButton(mode) { if (mode !== "override" && mode !== "hide") { return null; } if (!this.moderationPrimaryButton) { if (!this.document) { return null; } const button = this.document.createElement("button"); button.type = "button"; button.className = "moderation-badge__action flex-shrink-0"; this.moderationPrimaryButton = button; this.moderationPrimaryMode = ""; } const button = this.moderationPrimaryButton; if (this.moderationPrimaryMode !== mode) { if (this.moderationPrimaryMode === "override") { button.removeEventListener("click", this.handleModerationOverrideClick); } else if (this.moderationPrimaryMode === "hide") { button.removeEventListener("click", this.handleModerationHideClick); } if (mode === "override") { const { text, ariaLabel } = getModerationOverrideActionLabels({ overrideActive: false, }); button.textContent = text; button.dataset.moderationAction = "override"; button.setAttribute("aria-pressed", "false"); button.setAttribute("aria-label", ariaLabel); button.addEventListener("click", this.handleModerationOverrideClick); } else { const { text, ariaLabel } = getModerationOverrideActionLabels({ overrideActive: true, }); button.textContent = text; button.dataset.moderationAction = "hide"; button.removeAttribute("aria-pressed"); button.setAttribute("aria-label", ariaLabel); button.addEventListener("click", this.handleModerationHideClick); } this.moderationPrimaryMode = mode; } return button; } removeModerationPrimaryButton() { const button = this.moderationPrimaryButton; if (!button) { this.moderationPrimaryMode = ""; return; } if (this.moderationPrimaryMode === "override") { button.removeEventListener("click", this.handleModerationOverrideClick); } else if (this.moderationPrimaryMode === "hide") { button.removeEventListener("click", this.handleModerationHideClick); } if (button.parentElement) { button.parentElement.removeChild(button); } this.moderationPrimaryButton = null; this.moderationPrimaryMode = ""; } ensureModerationBlockButton() { if (!this.document) { return null; } if (!this.moderationBlockButton) { const button = this.document.createElement("button"); button.type = "button"; button.className = "moderation-badge__action flex-shrink-0"; button.dataset.moderationAction = "block"; button.textContent = "Block"; button.addEventListener("click", this.handleModerationBlockClick); this.moderationBlockButton = button; } return this.moderationBlockButton; } removeModerationBlockButton() { const button = this.moderationBlockButton; if (!button) { return; } button.removeEventListener("click", this.handleModerationBlockClick); if (button.parentElement) { button.parentElement.removeChild(button); } this.moderationBlockButton = null; } handleModerationOverrideClick(event) { event?.preventDefault?.(); event?.stopPropagation?.(); const button = event?.currentTarget || this.moderationPrimaryButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } if (!this.activeVideo) { if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } return; } const detail = { video: this.activeVideo, event, trigger: button || null, context: this.activeModerationContext || null, }; const handled = this.dispatch("video:moderation-override", detail); if (!handled && button) { button.disabled = false; button.removeAttribute("aria-busy"); } } handleModerationHideClick(event) { event?.preventDefault?.(); event?.stopPropagation?.(); const button = event?.currentTarget || this.moderationPrimaryButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } if (!this.activeVideo) { if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } return; } const detail = { video: this.activeVideo, event, trigger: button || null, context: this.activeModerationContext || null, }; const handled = this.dispatch("video:moderation-hide", detail); if (!handled && button) { button.disabled = false; button.removeAttribute("aria-busy"); } } handleModerationBlockClick(event) { event?.preventDefault?.(); event?.stopPropagation?.(); const button = event?.currentTarget || this.moderationBlockButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } if (!this.activeVideo) { if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } return; } const detail = { video: this.activeVideo, event, trigger: button || null, context: this.activeModerationContext || null, }; const handled = this.dispatch("video:moderation-block", detail); if (!handled && button) { button.disabled = false; button.removeAttribute("aria-busy"); } } handleGlobalModerationOverride(event) { const detail = event?.detail || {}; const video = detail && typeof detail === "object" ? detail.video : null; if (!video || !video.id || !this.activeVideo || this.activeVideo.id !== video.id) { return; } if (this.activeVideo !== video) { this.activeVideo = video; this.moderationBadgeId = ""; } this.refreshActiveVideoModeration({ video: this.activeVideo }); } handleGlobalModerationBlock(event) { const detail = event?.detail || {}; const video = detail && typeof detail === "object" ? detail.video : null; if (!video || !video.id || !this.activeVideo || this.activeVideo.id !== video.id) { return; } if (this.activeVideo !== video) { this.activeVideo = video; this.moderationBadgeId = ""; } this.refreshActiveVideoModeration({ video: this.activeVideo }); } renderVideoDescription(description) { const target = this.videoDescription; if (!target) { return; } while (target.firstChild) { target.removeChild(target.firstChild); } if (description === null || description === undefined) { return; } const normalized = typeof description === "string" ? description : String(description ?? ""); if (!normalized) { return; } const fragment = this.document.createDocumentFragment(); const lines = normalized.split(/\r?\n/); lines.forEach((line, index) => { if (index > 0) { fragment.appendChild(this.document.createElement("br")); } this.appendDescriptionLine(fragment, line); }); target.appendChild(fragment); } appendDescriptionLine(target, line) { if (!target) { return; } const text = typeof line === "string" ? line : String(line ?? ""); if (!text) { return; } const urlPattern = /\bhttps?:\/\/[^\s<>"']+/gi; let lastIndex = 0; let match; while ((match = urlPattern.exec(text)) !== null) { const preceding = text.slice(lastIndex, match.index); if (preceding) { target.appendChild(this.document.createTextNode(preceding)); } const { anchor, trailing } = this.createDescriptionLink(match[0]); if (anchor) { target.appendChild(anchor); } else if (match[0]) { target.appendChild(this.document.createTextNode(match[0])); } if (trailing) { target.appendChild(this.document.createTextNode(trailing)); } lastIndex = match.index + match[0].length; } const remaining = text.slice(lastIndex); if (remaining) { target.appendChild(this.document.createTextNode(remaining)); } } createDescriptionLink(rawMatch) { const normalized = typeof rawMatch === "string" ? rawMatch : String(rawMatch ?? ""); if (!normalized) { return { anchor: null, trailing: "" }; } let href = normalized; let trailing = ""; const trailingPattern = /[)\]\}>"',.;!?]+$/; while (href && trailingPattern.test(href)) { trailing = href.slice(-1) + trailing; href = href.slice(0, -1); } if (!href) { return { anchor: null, trailing: normalized }; } const anchor = this.document.createElement("a"); anchor.classList.add("video-modal__description-link", "focus-ring"); anchor.textContent = href; anchor.href = href; anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; return { anchor, trailing }; } updateTimestamps({ posted, edited } = {}) { if (this.videoTimestamp) { if (posted) { this.videoTimestamp.textContent = posted; this.videoTimestamp.classList.remove("hidden"); } else { this.videoTimestamp.textContent = ""; this.videoTimestamp.classList.add("hidden"); } } if (this.videoEditedTimestamp) { if (edited) { this.videoEditedTimestamp.textContent = edited; this.videoEditedTimestamp.classList.remove("hidden"); } else { this.videoEditedTimestamp.textContent = ""; this.videoEditedTimestamp.classList.add("hidden"); } } } updateCreator({ name, avatarUrl, npub } = {}) { if (this.creatorName) { this.creatorName.textContent = name || "Unknown"; } if (this.creatorAvatar) { this.creatorAvatar.src = avatarUrl || "assets/svg/default-profile.svg"; this.creatorAvatar.alt = name || "Unknown"; } if (this.creatorNpub) { this.creatorNpub.textContent = npub || ""; } } setSimilarContent(entries = []) { const normalizedItems = Array.isArray(entries) ? entries.filter((item) => item && typeof item === "object") : []; const limitedItems = SIMILAR_CONTENT_LIMIT > 0 ? normalizedItems.slice(0, SIMILAR_CONTENT_LIMIT) : normalizedItems.slice(); if (!this.similarContentList) { this.pendingSimilarContent = limitedItems.slice(); return; } this.pendingSimilarContent = null; this.clearSimilarContent(); if (!limitedItems.length) { return; } const fragment = this.document.createDocumentFragment(); const renderedCards = []; const viewSubscriptions = []; limitedItems.forEach((item, position) => { const baseVideo = item && typeof item === "object" && item.video && typeof item.video === "object" ? item.video : item; if ( !baseVideo || typeof baseVideo !== "object" || !baseVideo.id || !baseVideo.title ) { return; } const pointerInfo = this.normalizeSimilarPointerInfo(item, baseVideo); const shareUrl = typeof item.shareUrl === "string" && item.shareUrl.trim() ? item.shareUrl.trim() : typeof baseVideo.shareUrl === "string" && baseVideo.shareUrl.trim() ? baseVideo.shareUrl.trim() : typeof baseVideo.url === "string" && baseVideo.url.trim() ? baseVideo.url.trim() : "#"; const timeAgo = typeof item.timeAgo === "string" && item.timeAgo ? item.timeAgo : typeof baseVideo.timeAgo === "string" && baseVideo.timeAgo ? baseVideo.timeAgo : ""; const postedAtCandidate = Number.isFinite(item.postedAt) ? Math.floor(item.postedAt) : Number.isFinite(baseVideo.postedAt) ? Math.floor(baseVideo.postedAt) : Number.isFinite(baseVideo.rootCreatedAt) ? Math.floor(baseVideo.rootCreatedAt) : Number.isFinite(baseVideo.created_at) ? Math.floor(baseVideo.created_at) : null; const nsfwContext = item && typeof item.nsfwContext === "object" && item.nsfwContext ? { ...item.nsfwContext } : { isNsfw: baseVideo?.isNsfw === true, allowNsfw: item?.allowNsfw !== false, viewerIsOwner: item?.viewerIsOwner === true || Boolean(item?.capabilities?.canEdit), }; const identity = this.buildSimilarCardIdentity( baseVideo, item?.identity || null ); let card; try { card = new SimilarContentCard({ document: this.document, video: baseVideo, index: position, shareUrl, pointerInfo, timeAgo, postedAt: postedAtCandidate, identity, nsfwContext, designSystem: item?.designSystem || null, thumbnailCache: this.thumbnailCache, fallbackThumbnailSrc: this.fallbackThumbnailSrc, }); } catch (error) { this.log("[VideoModal] Failed to render similar content card", error); return; } const cardIndex = renderedCards.length; this.prepareSimilarVideoCard( card, { video: baseVideo, pointerInfo, shareUrl }, cardIndex ); const root = card.getRoot(); if (!root) { return; } if (root.classList) { root.classList.add("player-modal__similar-card"); } const listItem = this.document.createElement("li"); listItem.classList.add("player-modal__module-item"); listItem.appendChild(root); fragment.appendChild(listItem); renderedCards.push(card); const viewSubscription = this.attachSimilarCardViewCounter( card, pointerInfo ); if (viewSubscription) { viewSubscriptions.push(viewSubscription); } }); this.similarContentList.appendChild(fragment); this.similarContentCards = renderedCards; this.similarContentViewCountSubscriptions = viewSubscriptions; this.observeLazyMedia(this.similarContentList); this.toggleSimilarContentVisibility(renderedCards.length > 0); } clearSimilarContent(options = {}) { this.teardownSimilarContentSubscriptions(); if (Array.isArray(this.similarContentCards)) { this.similarContentCards.forEach((card) => { if (!card) { return; } try { card.closeMoreMenu?.({ restoreFocus: false }); } catch (error) { this.log("[VideoModal] Failed to close more menu during cleanup", error); } try { card.closeSettingsMenu?.({ restoreFocus: false }); } catch (error) { this.log( "[VideoModal] Failed to close settings menu during cleanup", error ); } card.onPlay = null; }); } this.similarContentCards = []; if (this.similarContentList) { this.similarContentList.textContent = ""; } if (!options?.preservePending) { this.pendingSimilarContent = null; } this.toggleSimilarContentVisibility(false); } normalizeModalLayoutStructure( playerModal, { layout, primary, secondary, similarContainer, statsContainer } ) { if (!playerModal || !layout || !primary || !secondary) { return; } const layoutClassList = layout.classList; const legacyLayoutClasses = [ "flex", "flex-col", "flex-row", "gap-4", "gap-6", "gap-8", "gap-y-6", "gap-y-8", ]; legacyLayoutClasses.forEach((className) => { if (layoutClassList.contains(className)) { layoutClassList.remove(className); } }); if (layout.hasAttribute("style")) { layout.removeAttribute("style"); } if (primary.hasAttribute("style")) { primary.removeAttribute("style"); } if (secondary.hasAttribute("style")) { secondary.removeAttribute("style"); } if (!layout.contains(primary)) { layout.appendChild(primary); } if (!layout.contains(secondary)) { layout.appendChild(secondary); } if (primary.nextElementSibling !== secondary) { layout.appendChild(primary); layout.appendChild(secondary); } if (similarContainer) { if (similarContainer.hasAttribute("style")) { similarContainer.removeAttribute("style"); } if (!secondary.contains(similarContainer)) { secondary.prepend(similarContainer); } } if (statsContainer) { if (statsContainer.hasAttribute("style")) { statsContainer.removeAttribute("style"); } if (!primary.contains(statsContainer)) { primary.appendChild(statsContainer); } } } teardownSimilarContentSubscriptions() { if (!Array.isArray(this.similarContentViewCountSubscriptions)) { this.similarContentViewCountSubscriptions = []; return; } for (const entry of this.similarContentViewCountSubscriptions) { if (!entry || !entry.pointer || !entry.token) { continue; } try { unsubscribeFromVideoViewCount(entry.pointer, entry.token); } catch (error) { this.log( "[VideoModal] Failed to unsubscribe similar view counter", error ); } } this.similarContentViewCountSubscriptions = []; } toggleSimilarContentVisibility(isVisible) { this.similarContentVisible = Boolean(isVisible); this.refreshSimilarContentVisibility(); } setMediaLoader(mediaLoader) { if (mediaLoader && typeof mediaLoader.observe === "function") { this.mediaLoader = mediaLoader; if (this.similarContentList && this.similarContentList.children.length) { this.observeLazyMedia(this.similarContentList); } return; } this.mediaLoader = null; } refreshSimilarContentVisibility() { const shouldShow = this.similarContentVisible && this.matchesSimilarContentDesktop(); this.setElementVisibility(this.similarContentContainer, shouldShow); this.setElementVisibility(this.similarContentHeading, shouldShow); this.setElementVisibility(this.similarContentList, shouldShow); } setElementVisibility(element, shouldShow) { if (!element) { return; } if (shouldShow) { element.removeAttribute("hidden"); element.setAttribute("aria-hidden", "false"); } else { element.setAttribute("hidden", ""); element.setAttribute("aria-hidden", "true"); } } observeLazyMedia(container) { if ( !container || !this.mediaLoader || typeof this.mediaLoader.observe !== "function" ) { return; } const lazyNodes = container.querySelectorAll("[data-lazy]"); if (!lazyNodes.length) { return; } lazyNodes.forEach((node) => { try { this.mediaLoader.observe(node); } catch (error) { this.log("[VideoModal] Failed to observe lazy media", error); } }); } matchesSimilarContentDesktop() { if (this.similarContentMediaQuery) { return this.similarContentMediaQuery.matches; } const win = this.window; if (!win || typeof win.matchMedia !== "function") { return true; } try { return win.matchMedia("(min-width: 1024px)").matches; } catch (error) { this.log( "[VideoModal] Failed to evaluate similar content media query", error ); return true; } } setupSimilarContentMediaQuery() { const win = this.window; if (!win || typeof win.matchMedia !== "function") { this.similarContentMediaQuery = null; this.refreshSimilarContentVisibility(); return; } const query = win.matchMedia("(min-width: 1024px)"); if (this.similarContentMediaQuery === query) { this.refreshSimilarContentVisibility(); return; } this.teardownSimilarContentMediaQuery(); this.similarContentMediaQuery = query; if (query) { if (typeof query.addEventListener === "function") { query.addEventListener("change", this.handleSimilarContentMediaChange); } else if (typeof query.addListener === "function") { query.addListener(this.handleSimilarContentMediaChange); } } this.refreshSimilarContentVisibility(); } teardownSimilarContentMediaQuery() { const query = this.similarContentMediaQuery; if (!query) { return; } if (typeof query.removeEventListener === "function") { query.removeEventListener( "change", this.handleSimilarContentMediaChange ); } else if (typeof query.removeListener === "function") { query.removeListener(this.handleSimilarContentMediaChange); } this.similarContentMediaQuery = null; } handleSimilarContentMediaChange() { this.refreshSimilarContentVisibility(); } normalizeSimilarPointerInfo(entry, video) { const candidateInfo = entry && typeof entry.pointerInfo === "object" ? entry.pointerInfo : video && typeof video.pointerInfo === "object" ? video.pointerInfo : null; let pointer = candidateInfo?.pointer ?? null; let key = typeof candidateInfo?.key === "string" && candidateInfo.key ? candidateInfo.key : typeof candidateInfo?.pointerKey === "string" && candidateInfo.pointerKey ? candidateInfo.pointerKey : ""; if (!pointer) { pointer = entry?.pointer ?? entry?.pointerTag ?? video?.pointer ?? video?.pointerTag ?? null; } if (!key) { const keyCandidates = [ entry?.pointerKey, entry?.pointerId, entry?.pointerIdentifier, video?.pointerKey, video?.pointerId, video?.pointerIdentifier, ]; for (const candidateKey of keyCandidates) { if (typeof candidateKey === "string" && candidateKey.trim()) { key = candidateKey.trim(); break; } } } if (!pointer) { if (!key) { return null; } return { pointer: null, key }; } if (!key) { key = this.derivePointerKeyFromInput(pointer); } return { pointer, key }; } derivePointerKeyFromInput(pointer) { if (!pointer) { return ""; } if (Array.isArray(pointer)) { const [type, value] = pointer; if (typeof value === "string" && value.trim()) { const normalizedType = type === "a" ? "a" : "e"; return `${normalizedType}:${value.trim()}`; } return ""; } if (typeof pointer === "object") { if (typeof pointer.key === "string" && pointer.key.trim()) { return pointer.key.trim(); } if ( typeof pointer.pointerKey === "string" && pointer.pointerKey.trim() ) { return pointer.pointerKey.trim(); } if ( typeof pointer.type === "string" && typeof pointer.value === "string" && pointer.value.trim() ) { const normalizedType = pointer.type === "a" ? "a" : "e"; return `${normalizedType}:${pointer.value.trim()}`; } if (Array.isArray(pointer.tag)) { return this.derivePointerKeyFromInput(pointer.tag); } return ""; } if (typeof pointer === "string") { const trimmed = pointer.trim(); if (!trimmed) { return ""; } if (/^(?:naddr|nevent)/i.test(trimmed)) { return ""; } if (trimmed.includes(":")) { return trimmed; } return `e:${trimmed}`; } return ""; } prepareSimilarVideoCard(card, meta, index) { if (!card) { return; } const pointerInfo = meta?.pointerInfo || null; const shareUrl = typeof meta?.shareUrl === "string" ? meta.shareUrl : "#"; const fallbackVideo = meta?.video || null; card.onPlay = ({ event, video: selectedVideo, card: sourceCard }) => { this.dispatch("similar:select", { event, video: selectedVideo || fallbackVideo, card: sourceCard || card, index, pointerInfo: pointerInfo, shareUrl, }); }; if (card.moreMenuButton) { const button = card.moreMenuButton; const parent = button.parentElement; if (parent) { parent.removeChild(button); if (!parent.childElementCount) { parent.remove(); } } else { button.remove(); } card.moreMenuButton = null; } } buildSimilarCardIdentity(video, overrides) { const override = overrides && typeof overrides === "object" ? overrides : {}; const pubkeyCandidates = [ override.pubkey, video?.pubkey, video?.author?.pubkey, video?.creator?.pubkey, ]; let pubkey = ""; for (const candidate of pubkeyCandidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { pubkey = trimmed; break; } } const npubCandidates = [ override.npub, video?.npub, video?.authorNpub, video?.creatorNpub, video?.profile?.npub, ]; let npub = ""; for (const candidate of npubCandidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { npub = trimmed; break; } } if (!npub && pubkey) { try { const encoded = this.helpers.safeEncodeNpub(pubkey); if (typeof encoded === "string" && encoded.trim()) { npub = encoded.trim(); } } catch { /* noop */ } } const shortNpubCandidates = [override.shortNpub, video?.shortNpub]; let shortNpub = ""; for (const candidate of shortNpubCandidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { shortNpub = trimmed; break; } } if (!shortNpub && npub) { shortNpub = this.helpers.formatShortNpub(npub) || npub; } const nameCandidates = [ override.name, override.displayName, override.username, video?.authorName, video?.creatorName, video?.creator?.name, video?.author?.name, video?.profile?.display_name, video?.profile?.name, video?.profile?.username, ]; let name = ""; for (const candidate of nameCandidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (!trimmed) { continue; } if (HEX64_REGEX.test(trimmed)) { continue; } name = trimmed; break; } if (!name && shortNpub) { name = shortNpub; } if (!name && npub) { name = npub; } const pictureCandidates = [ override.picture, override.image, override.photo, video?.picture, video?.authorPicture, video?.creatorPicture, video?.author?.picture, video?.creator?.picture, video?.profile?.picture, ]; let picture = ""; for (const candidate of pictureCandidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (!trimmed) { continue; } const sanitized = sanitizeProfileMediaUrl(trimmed); if (sanitized) { picture = sanitized; break; } } if (!picture && pubkey) { const robohash = `https://robohash.org/${pubkey}`; picture = sanitizeProfileMediaUrl(robohash) || robohash; } if (!picture) { const fallbackAvatar = typeof this.DEFAULT_PROFILE_AVATAR === "string" && this.DEFAULT_PROFILE_AVATAR.trim() ? this.DEFAULT_PROFILE_AVATAR.trim() : "assets/svg/default-profile.svg"; picture = sanitizeProfileMediaUrl(fallbackAvatar) || fallbackAvatar; } return { name, npub, shortNpub, pubkey, picture, }; } attachSimilarCardViewCounter(card, pointerInfo) { if (!card || typeof card.getViewCountElement !== "function") { return null; } const viewEl = card.getViewCountElement(); if (!viewEl) { return null; } if (pointerInfo?.key) { viewEl.dataset.viewPointer = pointerInfo.key; } else if (viewEl.dataset?.viewPointer) { delete viewEl.dataset.viewPointer; } if (!pointerInfo || !pointerInfo.pointer) { viewEl.textContent = "– views"; return null; } viewEl.textContent = "Loading views…"; try { const token = subscribeToVideoViewCount( pointerInfo.pointer, ({ total, status }) => { if (!viewEl || !viewEl.isConnected) { return; } viewEl.textContent = this.getViewCountLabel(total, status); } ); return { pointer: pointerInfo.pointer, token }; } catch (error) { this.log("[VideoModal] Failed to subscribe similar view counter", error); viewEl.textContent = "– views"; return null; } } getViewCountLabel(total, status) { if (Number.isFinite(total)) { return this.formatViewCountLabel(Number(total)); } if (status === "hydrating") { return "Loading views…"; } return "– views"; } formatViewCountLabel(total) { const numeric = Number.isFinite(total) ? Math.max(0, Number(total)) : 0; const label = numeric === 1 ? "view" : "views"; return `${formatViewCount(numeric)} ${label}`; } syncMoreMenuData({ currentVideo, canManageBlacklist }) { this.modalMoreMenuContext.video = currentVideo || null; this.modalMoreMenuContext.canManageBlacklist = !!canManageBlacklist; const pointerArray = Array.isArray(currentVideo?.pointer) && currentVideo.pointer.length >= 2 ? currentVideo.pointer : null; const pointerKey = typeof currentVideo?.pointerKey === "string" ? currentVideo.pointerKey : ""; this.modalMoreMenuContext.pointerInfo = pointerArray ? { pointer: pointerArray, key: pointerKey } : null; this.modalMoreMenuContext.playbackUrl = typeof currentVideo?.url === "string" ? currentVideo.url : ""; const magnetCandidate = (() => { if (typeof currentVideo?.magnet === "string" && currentVideo.magnet) { return currentVideo.magnet; } if ( typeof currentVideo?.originalMagnet === "string" && currentVideo.originalMagnet ) { return currentVideo.originalMagnet; } if (typeof currentVideo?.infoHash === "string" && currentVideo.infoHash) { return currentVideo.infoHash; } return ""; })(); this.modalMoreMenuContext.playbackMagnet = magnetCandidate; if (this.modalMorePopover?.isOpen?.()) { this.refreshModalMoreMenuPanel(); } } }