Files
bitvid/js/ui/components/VideoModal.js
T
google-labs-jules[bot]andPR0M3TH3AN bf96a02fec fix(VideoModal): ensure normalized tags retain hash prefix
Fixes a unit test failure in `tests/video-modal-controllers.test.mjs` where tags were expected to be prefixed with `#` but were stripped.

Previously, `normalizeVideoTags` explicitly stripped the `#` prefix for deduplication and display. This caused a mismatch with tests that expected the prefix.

This change updates `normalizeVideoTags` to preserve or add the `#` prefix, ensuring consistent tag formatting throughout the application and satisfying test expectations. This aligns with `tagPillList.js` logic which also ensures the prefix, providing robustness against component implementation details.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-27 17:46:13 +00:00

6277 lines
179 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createModalAccessibility } from "./modalAccessibility.js";
import createPopover from "../overlay/popoverEngine.js";
import {
createVideoMoreMenuPanel,
createVideoShareMenuPanel,
} 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";
import { getBreakpointLg } from "../../designSystem/metrics.js";
import { LinkPreviewService } from "../../services/linkPreviewService.js";
import {
allowLinkPreviewDomain,
getLinkPreviewSettings,
isLinkPreviewDomainAllowed,
subscribeToLinkPreviewSettings,
} from "../../utils/linkPreviewSettings.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.videoDescriptionPreviews = 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.embedBtn = 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.reactionMeterStyleNode = 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.shareNostrAuthState = {
isLoggedIn: false,
hasSigner: false,
};
this.commentThreadContext = {
videoEventId: "",
parentCommentId: null,
};
this.commentProfiles = new Map();
this.commentNodes = new Map();
this.commentChildLists = new Map();
this.commentNodeElements = new Map();
this.commentNpubEncodingFailures = new Set();
this.commentAvatarCache = new Map();
this.commentAvatarFailures = new Set();
this.commentRetryButton = null;
this.commentCallbacks = {
teardown: null,
};
this.linkPreviewService = new LinkPreviewService();
this.linkPreviewAbortControllers = new Map();
this.linkPreviewSettingsUnsubscribe = null;
this.currentDescriptionText = "";
this.handleLinkPreviewSettingsChange =
this.handleLinkPreviewSettingsChange.bind(this);
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.handleEmbedRequest = this.handleEmbedRequest.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();
try {
if (this.modalVideo) {
// give the element the fill class so CSS can control layout
this.modalVideo.classList.add("video-modal__media--fill");
// ensure the stage/wrapper allow stretching (safety)
const videoWrap =
this.playerModal?.querySelector(".video-modal__video") ||
this.document.querySelector(".video-modal__video");
if (videoWrap) {
videoWrap.style.display = "flex";
videoWrap.style.alignItems = "stretch";
videoWrap.style.justifyContent = "center";
videoWrap.style.padding = "0";
}
// choose object-fit dynamically after metadata loads so we can inspect intrinsic size:
const decideObjectFit = () => {
try {
const vw =
this.modalVideo.videoWidth ||
this.modalVideo.naturalWidth ||
this.modalVideo.clientWidth;
const vh =
this.modalVideo.videoHeight ||
this.modalVideo.naturalHeight ||
this.modalVideo.clientHeight;
const wrap = this.modalVideo.parentElement;
const cw = wrap ? wrap.clientWidth : this.modalVideo.clientWidth;
const ch = wrap ? wrap.clientHeight : this.modalVideo.clientHeight;
// If we can't compute, default to cover
if (!vw || !vh || !cw || !ch) {
this.modalVideo.style.objectFit = "cover";
return;
}
const videoRatio = vw / vh;
const containerRatio = cw / ch;
const diff = Math.abs(containerRatio - videoRatio);
// if aspect ratios are close, prefer contain to avoid cropping small edges,
// otherwise use cover to avoid large letterbox bars
const THRESHOLD = 0.12; // tweakable
this.modalVideo.style.objectFit =
diff > THRESHOLD ? "cover" : "contain";
} catch (e) {
this.modalVideo.style.objectFit = "cover";
}
};
if (this.modalVideo.readyState >= 1) {
// metadata already available
decideObjectFit();
} else {
const onMeta = () => {
decideObjectFit();
this.modalVideo.removeEventListener("loadedmetadata", onMeta);
};
this.modalVideo.addEventListener("loadedmetadata", onMeta);
}
// always ensure background transparent
this.modalVideo.style.background = "transparent";
this.modalVideo.style.objectPosition = "center center";
}
} catch (err) {
// swallow, but log for diagnostics
this.logger?.log?.(
"[VideoModal] setVideoElement style enforcement failed",
err
);
}
} 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 });
// Update overlay reference to the new bar selector
this.moderationOverlay =
playerModal.querySelector("[data-moderation-bar]") || null;
this.moderationBadge =
this.moderationOverlay?.querySelector("[data-moderation-badge='true']") ||
null;
this.moderationBadgeText =
this.moderationOverlay?.querySelector("[data-moderation-text]") || null;
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.sourceToggleContainer =
playerModal.querySelector("[data-source-toggle-container]") || null;
this.sourceToggleButtons =
playerModal.querySelectorAll("[data-source-toggle]") || [];
this.activeServersLabel =
playerModal.querySelector("[data-active-servers-count]") || null;
this.activePeersLabel =
playerModal.querySelector("[data-active-peers-count]") || null;
this.setTorrentStatsVisibility(false);
this.videoTitle = playerModal.querySelector("#videoTitle") || null;
this.videoDescription =
playerModal.querySelector("#videoDescription") || null;
this.videoDescriptionPreviews =
playerModal.querySelector("#videoDescriptionPreviews") || 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.embedBtn = playerModal.querySelector("#embedBtn") || 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.setupModalSharePopover();
this.refreshActiveVideoModeration({ video: this.activeVideo });
this.commentsController.initialize({ playerModal });
if (this.linkPreviewSettingsUnsubscribe) {
this.linkPreviewSettingsUnsubscribe();
}
this.linkPreviewSettingsUnsubscribe = subscribeToLinkPreviewSettings(
this.handleLinkPreviewSettingsChange,
);
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.setEmbedEnabled(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();
this.clearLinkPreviews();
if (this.linkPreviewSettingsUnsubscribe) {
this.linkPreviewSettingsUnsubscribe();
this.linkPreviewSettingsUnsubscribe = null;
}
if (this.document) {
this.document.removeEventListener(
"video:moderation-override",
this.handleGlobalModerationOverride,
);
this.document.removeEventListener(
"video:moderation-block",
this.handleGlobalModerationBlock,
);
this.document.removeEventListener(
"video:moderation-hide",
this.handleGlobalModerationHide,
);
}
this.removeModerationPrimaryButton();
this.removeModerationBlockButton();
this.moderationOverlay = null;
this.moderationBadge = null;
this.moderationBadgeText = null;
this.moderationActionsContainer = null;
this.moderationPrimaryButton = null;
this.moderationPrimaryMode = "";
this.moderationBlockButton = null;
this.moderationBadgeId = "";
this.activeModerationContext = null;
this.commentProfiles = new Map();
this.commentNodes = new Map();
this.commentChildLists = new Map();
this.commentNodeElements = new Map();
this.commentComposerState = {
disabled: true,
reason: "",
parentCommentId: null,
};
this.commentThreadContext = {
videoEventId: "",
parentCommentId: null,
};
this.commentComposerDefaultHint = "";
this.commentComposerDefaultCountText = "";
this.commentStatusDefaultText = "";
this.commentComposerMaxLength = 0;
this.commentsRoot = null;
this.commentsContainer = null;
this.commentsCountLabel = null;
this.commentsEmptyState = null;
this.commentsList = null;
this.commentsLoadMoreButton = null;
this.commentsDisabledPlaceholder = null;
this.commentsDisabledPlaceholderDefaultText = "";
this.commentsComposer = null;
this.commentsInput = null;
this.commentsSubmitButton = null;
this.commentsStatusMessage = null;
this.commentsCharCount = null;
this.commentComposerHint = null;
this.commentRetryButton = null;
}
bindVideoEvents() {
if (
!this.modalVideo ||
!this.window ||
typeof this.window.HTMLVideoElement === "undefined" ||
!(this.modalVideo instanceof this.window.HTMLVideoElement)
) {
return;
}
const loadedHandler = () => {
this.dispatch("playback:loadeddata", {
video: this.modalVideo,
active: this.activeVideo
});
};
const playingHandler = () => {
this.dispatch("playback:playing", {
video: this.modalVideo,
active: this.activeVideo
});
};
this.modalVideo.addEventListener("loadeddata", loadedHandler);
this.modalVideo.addEventListener("playing", playingHandler);
this.videoEventCleanup = () => {
if (!this.modalVideo) {
return;
}
this.modalVideo.removeEventListener("loadeddata", loadedHandler);
this.modalVideo.removeEventListener("playing", playingHandler);
this.videoEventCleanup = null;
};
}
detachVideoEvents() {
if (typeof this.videoEventCleanup === "function") {
this.videoEventCleanup();
}
this.videoEventCleanup = null;
}
getAmbientFallbackColor() {
const canvas = this.ambientCanvas;
const doc =
canvas?.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
const win = doc?.defaultView || (typeof window !== "undefined" ? window : null);
if (doc && win && typeof win.getComputedStyle === "function") {
const styles = win.getComputedStyle(doc.documentElement);
const tokens = [
"--color-overlay-strong",
"--color-overlay",
"--color-surface",
"--color-bg",
];
for (const token of tokens) {
const value = styles.getPropertyValue(token);
if (value && value.trim()) {
return value.trim();
}
}
}
return "rgb(0, 0, 0)";
}
clearAmbientCanvas() {
const canvas = this.ambientCanvas;
if (!canvas || typeof canvas.getContext !== "function") {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
const width = canvas.width || canvas.clientWidth || 0;
const height = canvas.height || canvas.clientHeight || 0;
if (!width || !height) {
return;
}
context.fillStyle = this.getAmbientFallbackColor();
context.fillRect(0, 0, width, height);
}
teardownAmbientGlow({ clear = true } = {}) {
const detach = this.detachAmbientBackground;
this.detachAmbientBackground = null;
if (typeof detach === "function") {
try {
detach({ clear });
return;
} catch (error) {
this.log("[VideoModal] Failed to teardown ambient background", error);
}
}
if (clear) {
this.clearAmbientCanvas();
}
}
attachAmbientGlow() {
if (!this.modalVideo || !this.ambientCanvas) {
this.clearAmbientCanvas();
return;
}
try {
this.detachAmbientBackground = attachAmbientBackground(
this.modalVideo,
this.ambientCanvas,
);
} catch (error) {
this.detachAmbientBackground = null;
this.log("[VideoModal] Failed to attach ambient background", error);
this.clearAmbientCanvas();
}
}
bindActionButtons() {
if (this.copyMagnetBtn) {
this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest);
}
if (this.shareBtn && this.shareBtn.dataset.modalMenuHandler !== "true") {
this.shareBtn.dataset.modalMenuHandler = "true";
this.shareBtn.addEventListener("click", this.handleShareRequest);
}
if (this.embedBtn) {
this.embedBtn.addEventListener("click", this.handleEmbedRequest);
}
if (this.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.sourceToggleButtons.length > 0) {
this.sourceToggleButtons.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const mode = btn.dataset.sourceToggle;
if (mode) {
this.handleSourceToggle(mode);
}
});
});
}
if (this.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;
}
setupModalSharePopover() {
if (!this.shareBtn) {
this.modalSharePopover = null;
return;
}
const documentRef =
this.shareBtn.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
const render = ({ document: doc, close }) => {
const panel = this.buildModalShareMenuPanel({ document: doc, close });
return panel;
};
const popover = createPopover(this.shareBtn, render, {
document: documentRef,
placement: "bottom-end",
restoreFocusOnClose: true,
});
if (!popover) {
this.modalSharePopover = null;
return;
}
const originalOpen = popover.open?.bind(popover);
if (originalOpen) {
popover.open = async (...args) => {
this.refreshModalShareMenuPanel();
return originalOpen(...args);
};
}
const originalDestroy = popover.destroy?.bind(popover);
if (originalDestroy) {
popover.destroy = (...args) => {
originalDestroy(...args);
if (this.modalSharePopover === popover) {
this.modalSharePopover = null;
}
};
}
this.shareBtn.dataset.shareMenuToggleBound = "true";
this.modalSharePopover = popover;
}
buildModalShareMenuPanel({ document: doc, close }) {
const isLoggedIn = this.shareNostrAuthState.isLoggedIn;
const hasSigner = this.shareNostrAuthState.hasSigner;
const hasMagnet = Boolean(
(typeof this.activeVideo?.magnet === "string" &&
this.activeVideo.magnet.trim()) ||
this.activeVideo?.torrentSupported
);
const hasCdn = Boolean(
typeof this.activeVideo?.url === "string" && this.activeVideo.url.trim()
);
const panel = createVideoShareMenuPanel({
document: doc,
video: this.activeVideo,
isLoggedIn,
hasSigner,
hasMagnet,
hasCdn,
context: "modal",
});
if (!panel) {
return null;
}
const buttons = panel.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const dataset = {};
Object.entries(button.dataset || {}).forEach(([key, value]) => {
dataset[key] = value;
});
if (!dataset.context) {
dataset.context = "modal";
}
const action = dataset.action || "";
// Map menu actions to events
if (action === "share") {
this.dispatch("video:share", { video: this.activeVideo });
} else if (action === "share-nostr") {
this.dispatch("video:share-nostr", { video: this.activeVideo });
} else if (action === "copy-magnet") {
this.dispatch("video:copy-magnet", { video: this.activeVideo });
} else if (action === "copy-cdn") {
this.dispatch("video:copy-cdn", { video: this.activeVideo });
}
if (typeof close === "function") {
close({ reason: "action" });
}
});
});
return panel;
}
refreshModalShareMenuPanel() {
if (!this.modalSharePopover) {
return;
}
const existingPanel = this.modalSharePopover.getPanel?.();
const documentRef =
existingPanel?.ownerDocument ||
this.shareBtn?.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
if (!documentRef) {
return;
}
const nextPanel = this.buildModalShareMenuPanel({
document: documentRef,
close: (options) => this.modalSharePopover?.close(options),
});
if (!nextPanel) {
return;
}
if (existingPanel && existingPanel.parentNode) {
existingPanel.parentNode.replaceChild(nextPanel, existingPanel);
}
}
setShareNostrAuthState({ isLoggedIn, hasSigner } = {}) {
const nextIsLoggedIn = Boolean(isLoggedIn);
const nextHasSigner = Boolean(hasSigner);
const previous = this.shareNostrAuthState;
if (
previous &&
previous.isLoggedIn === nextIsLoggedIn &&
previous.hasSigner === nextHasSigner
) {
return;
}
this.shareNostrAuthState = {
isLoggedIn: nextIsLoggedIn,
hasSigner: nextHasSigner,
};
this.refreshModalShareMenuPanel();
}
buildModalMoreMenuPanel({ document: doc, close }) {
const panel = createVideoMoreMenuPanel({
document: doc,
video: this.modalMoreMenuContext.video,
pointerInfo: this.modalMoreMenuContext.pointerInfo,
playbackUrl: this.modalMoreMenuContext.playbackUrl,
playbackMagnet: this.modalMoreMenuContext.playbackMagnet,
canManageBlacklist: this.modalMoreMenuContext.canManageBlacklist,
context: "modal",
});
if (!panel) {
return null;
}
const buttons = panel.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const dataset = {};
Object.entries(button.dataset || {}).forEach(([key, value]) => {
dataset[key] = value;
});
if (!dataset.context) {
dataset.context = "modal";
}
const action = dataset.action || "";
this.dispatch("video:context-action", {
action,
dataset,
});
if (typeof close === "function") {
close({ reason: "action" });
}
});
});
return panel;
}
refreshModalMoreMenuPanel() {
if (!this.modalMorePopover) {
return;
}
const existingPanel =
this.modalMorePopover.getPanel?.() || this.modalMoreMenuPanel;
const documentRef =
existingPanel?.ownerDocument ||
this.modalMoreBtn?.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : null);
if (!documentRef) {
return;
}
const nextPanel = this.buildModalMoreMenuPanel({
document: documentRef,
close: (options) => this.modalMorePopover?.close(options),
});
if (!nextPanel) {
return;
}
const currentPanel = this.modalMorePopover.getPanel?.();
if (currentPanel && currentPanel.parentNode) {
currentPanel.parentNode.replaceChild(nextPanel, currentPanel);
} else if (existingPanel?.parentNode) {
existingPanel.parentNode.replaceChild(nextPanel, existingPanel);
}
this.modalMoreMenuPanel = nextPanel;
}
handleCopyRequest(event) {
event?.preventDefault?.();
if (this.copyMagnetBtn?.disabled) {
return;
}
this.dispatch("video:copy-magnet", { video: this.activeVideo });
}
handleSourceToggle(mode) {
if (!this.activeVideo || (mode !== "url" && mode !== "torrent")) {
return;
}
this.dispatch("playback:switch-source", {
video: this.activeVideo,
source: mode,
});
}
updateSourceAvailability(video) {
const hasUrl = Boolean(
typeof video?.url === "string" && video.url.trim()
);
const hasMagnet = Boolean(
(typeof video?.magnet === "string" && video.magnet.trim()) ||
video?.torrentSupported
);
const hasPointer = this.hasResolvablePointer(video);
this.setShareEnabled(hasUrl || hasMagnet);
this.setEmbedEnabled(hasPointer);
if (!this.sourceToggleContainer) {
return;
}
if (hasUrl || hasMagnet) {
this.sourceToggleContainer.removeAttribute("hidden");
this.sourceToggleContainer.classList.remove("hidden");
} else {
this.sourceToggleContainer.setAttribute("hidden", "");
this.sourceToggleContainer.classList.add("hidden");
}
this.sourceToggleButtons.forEach((btn) => {
const mode = btn.dataset.sourceToggle;
if (mode === "url") {
btn.disabled = !hasUrl;
} else if (mode === "torrent") {
btn.disabled = !hasMagnet;
}
});
if (this.activeServersLabel) {
const count = hasUrl ? 1 : 0;
this.activeServersLabel.textContent =
count === 1 ? "1 server" : `${count} servers`;
}
}
updateSourceToggleState(activeSource) {
if (!this.sourceToggleButtons.length) {
return;
}
const current = activeSource === "torrent" ? "torrent" : "url";
this.sourceToggleButtons.forEach((btn) => {
const mode = btn.dataset.sourceToggle;
const isActive = mode === current;
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
handleShareRequest(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.shareBtn?.disabled) {
return;
}
if (this.modalSharePopover?.toggle) {
this.modalSharePopover.toggle();
return;
}
if (this.modalSharePopover?.isOpen?.()) {
this.modalSharePopover.close?.();
} else {
this.modalSharePopover?.open?.();
}
}
handleEmbedRequest(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.embedBtn?.disabled) {
return;
}
this.dispatch("video:embed", { video: this.activeVideo });
}
handleCreatorNavigation(event) {
event?.preventDefault?.();
this.dispatch("creator:navigate", { video: this.activeVideo });
}
handleModalMoreButtonClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (this.modalMoreBtn?.disabled) {
return;
}
if (this.modalMorePopover?.toggle) {
this.modalMorePopover.toggle();
return;
}
if (this.modalMorePopover?.isOpen?.()) {
this.modalMorePopover.close?.();
} else {
this.modalMorePopover?.open?.();
}
}
handleReactionClick(event) {
event?.preventDefault?.();
event?.stopPropagation?.();
const button = event?.currentTarget;
if (!button || typeof button !== "object") {
return;
}
if (button.disabled || button.getAttribute("aria-disabled") === "true") {
return;
}
const reactionValue =
typeof button.dataset?.reaction === "string"
? button.dataset.reaction.trim()
: "";
const normalized =
reactionValue === "+"
? "+"
: reactionValue === "-"
? "-"
: "";
if (!normalized) {
return;
}
const currentReaction = this.reactionState?.userReaction || "";
if (normalized === currentReaction) {
return;
}
this.dispatch("video:reaction", {
video: this.activeVideo,
reaction: normalized,
previousReaction: currentReaction,
});
}
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.updateSourceAvailability(this.activeVideo);
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.clearLinkPreviews();
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();
if (!trimmed) {
continue;
}
let key = trimmed.toLowerCase();
if (key.startsWith("#")) {
key = key.slice(1);
}
if (!key) {
continue;
}
if (seen.has(key)) {
continue;
}
seen.add(key);
const display = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
normalized.push(display);
}
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;
}
this.updateReactionMeterFill(likeRatio);
}
updateReactionMeterFill(likeRatio) {
const doc = this.playerModal?.ownerDocument || this.document;
if (!doc) {
return;
}
const clamped = Math.max(0, Math.min(100, Math.round(likeRatio)));
const widthValue = `${clamped}%`;
if (!this.reactionMeterStyleNode || !this.reactionMeterStyleNode.isConnected) {
const existing = doc.getElementById?.("videoModalReactionStyles");
if (existing instanceof HTMLStyleElement) {
this.reactionMeterStyleNode = existing;
} else {
const styleNode = doc.createElement("style");
styleNode.id = "videoModalReactionStyles";
doc.head?.appendChild(styleNode);
this.reactionMeterStyleNode = styleNode;
}
}
if (this.reactionMeterStyleNode) {
this.reactionMeterStyleNode.textContent =
`#playerModal { --video-modal-reaction-fill: ${widthValue}; }`;
}
}
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);
this.updateSourceToggleState(visible ? "torrent" : "url");
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 || "";
}
if (this.activePeersLabel) {
const match = (text || "").match(/\d+/);
const count = match ? parseInt(match[0], 10) : 0;
this.activePeersLabel.textContent =
count === 1 ? "1 peer" : `${count} peers`;
}
}
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);
}
setEmbedEnabled(enabled) {
if (!this.embedBtn) {
return;
}
this.embedBtn.disabled = !enabled;
this.embedBtn.setAttribute("aria-disabled", (!enabled).toString());
this.embedBtn.classList.toggle("opacity-50", !enabled);
this.embedBtn.classList.toggle("cursor-not-allowed", !enabled);
}
hasResolvablePointer(video) {
const candidateInfo =
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 = video?.pointer ?? video?.pointerTag ?? null;
}
if (!key) {
const keyCandidates = [
video?.pointerKey,
video?.pointerId,
video?.pointerIdentifier,
];
for (const candidateKey of keyCandidates) {
if (typeof candidateKey === "string" && candidateKey.trim()) {
key = candidateKey.trim();
break;
}
}
}
if (!key && pointer) {
key = this.derivePointerKeyFromInput(pointer);
}
return Boolean(key);
}
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) {
return;
}
const textEl = this.videoViewCountEl.querySelector(
"[data-view-count-text]"
);
if (textEl) {
textEl.textContent = text || "–";
} else {
// Fallback if span is missing
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;
} 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.updateSourceAvailability(this.activeVideo);
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 (this.creatorAvatar) {
if (shouldBlur) {
this.creatorAvatar.dataset.visualState = "blurred";
} else if (this.creatorAvatar.dataset.visualState === "blurred") {
delete this.creatorAvatar.dataset.visualState;
}
}
if (overlay) {
// Changed logic: Always show the bar if moderation is active, even if overridden
if (showModeration) {
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) {
this.clearLinkPreviews();
return;
}
const normalized =
typeof description === "string" ? description : String(description ?? "");
if (!normalized) {
this.clearLinkPreviews();
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);
this.renderLinkPreviews(normalized);
}
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 };
}
handleLinkPreviewSettingsChange(event) {
const detail = event?.detail?.settings || null;
if (!this.videoDescriptionPreviews) {
return;
}
const nextText = this.currentDescriptionText || "";
if (!nextText) {
return;
}
if (detail && typeof detail.autoFetchUnknownDomains === "boolean") {
this.renderLinkPreviews(nextText);
}
}
clearLinkPreviewRequests() {
if (!(this.linkPreviewAbortControllers instanceof Map)) {
this.linkPreviewAbortControllers = new Map();
return;
}
this.linkPreviewAbortControllers.forEach((controller) => {
try {
controller.abort();
} catch {
// noop
}
});
this.linkPreviewAbortControllers.clear();
}
clearLinkPreviews() {
this.clearLinkPreviewRequests();
this.currentDescriptionText = "";
if (this.videoDescriptionPreviews) {
this.videoDescriptionPreviews.textContent = "";
this.videoDescriptionPreviews.setAttribute("hidden", "");
}
}
renderLinkPreviews(description) {
const root = this.videoDescriptionPreviews;
if (!root || !this.document) {
return;
}
const text =
typeof description === "string" ? description : String(description ?? "");
this.currentDescriptionText = text;
this.clearLinkPreviewRequests();
root.textContent = "";
const urls = this.extractDescriptionUrls(text);
if (!urls.length) {
root.setAttribute("hidden", "");
return;
}
root.removeAttribute("hidden");
const fragment = this.document.createDocumentFragment();
urls.forEach((url) => {
const domain = this.extractPreviewDomain(url);
const card = this.createLinkPreviewCard({ url, domain });
fragment.appendChild(card);
void this.resolveLinkPreview(url, domain, card);
});
root.appendChild(fragment);
}
extractDescriptionUrls(text) {
if (!text) {
return [];
}
const urlPattern = /\bhttps?:\/\/[^\s<>"']+/gi;
const urls = new Set();
let match;
while ((match = urlPattern.exec(text)) !== null) {
const normalized = this.normalizePreviewUrl(match[0]);
if (normalized) {
urls.add(normalized);
}
}
return Array.from(urls);
}
normalizePreviewUrl(candidate) {
if (!candidate) {
return "";
}
let href = typeof candidate === "string" ? candidate : String(candidate ?? "");
let trailing = "";
const trailingPattern = /[)\]\}>"',.;!?]+$/;
while (href && trailingPattern.test(href)) {
trailing = href.slice(-1) + trailing;
href = href.slice(0, -1);
}
return href.trim();
}
extractPreviewDomain(url) {
if (!url) {
return "";
}
try {
return new URL(url).hostname || "";
} catch {
return "";
}
}
createLinkPreviewCard({ url, domain }) {
const card = this.document.createElement("article");
card.className = "card flex flex-col gap-2 p-3";
card.dataset.linkPreviewUrl = url;
const header = this.document.createElement("div");
header.className = "flex items-start gap-3";
const imageWrapper = this.document.createElement("div");
imageWrapper.className =
"flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-border/60 bg-surface-muted text-2xs text-muted";
const image = this.document.createElement("img");
image.hidden = true;
image.loading = "lazy";
image.decoding = "async";
image.className = "h-full w-full object-cover";
const imagePlaceholder = this.document.createElement("span");
imagePlaceholder.textContent = "Preview";
imageWrapper.appendChild(image);
imageWrapper.appendChild(imagePlaceholder);
const content = this.document.createElement("div");
content.className = "flex min-w-0 flex-1 flex-col gap-1";
const domainEl = this.document.createElement("p");
domainEl.className = "text-3xs uppercase tracking-wide text-muted";
domainEl.textContent = domain || "Link preview";
const titleEl = this.document.createElement("a");
titleEl.className =
"text-sm font-semibold text-text break-words hover:underline focus-ring";
titleEl.href = url;
titleEl.target = "_blank";
titleEl.rel = "noopener noreferrer";
titleEl.textContent = url;
const descEl = this.document.createElement("p");
descEl.className = "text-xs text-muted";
descEl.textContent = "";
content.appendChild(domainEl);
content.appendChild(titleEl);
content.appendChild(descEl);
header.appendChild(imageWrapper);
header.appendChild(content);
const statusEl = this.document.createElement("p");
statusEl.className = "text-xs text-muted";
const actions = this.document.createElement("div");
actions.className = "flex flex-wrap gap-2";
card.appendChild(header);
card.appendChild(statusEl);
card.appendChild(actions);
card.__linkPreviewElements = {
imageWrapper,
image,
imagePlaceholder,
domainEl,
titleEl,
descEl,
statusEl,
actions,
};
return card;
}
setLinkPreviewStatus(card, { message = "", actionLabel = "", onAction } = {}) {
const elements = card?.__linkPreviewElements;
if (!elements) {
return;
}
const statusText = typeof message === "string" ? message : "";
elements.statusEl.textContent = statusText;
elements.actions.textContent = "";
if (actionLabel && typeof onAction === "function") {
const button = this.document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = actionLabel;
button.addEventListener("click", onAction);
elements.actions.appendChild(button);
}
}
updateLinkPreviewCard(card, preview, { domain } = {}) {
const elements = card?.__linkPreviewElements;
if (!elements || !preview) {
return;
}
const title = preview.title || preview.siteName || preview.url || "";
elements.titleEl.textContent = title || preview.url;
elements.descEl.textContent = preview.description || "";
const label = preview.siteName || domain || "Link preview";
elements.domainEl.textContent = label;
if (preview.image) {
elements.image.src = preview.image;
elements.image.alt = title || "Link preview";
elements.image.hidden = false;
elements.imagePlaceholder.hidden = true;
} else {
elements.image.hidden = true;
elements.imagePlaceholder.hidden = false;
}
this.setLinkPreviewStatus(card, { message: "" });
}
async resolveLinkPreview(url, domain, card) {
const settings = getLinkPreviewSettings();
const isAllowed =
settings.autoFetchUnknownDomains ||
isLinkPreviewDomainAllowed(domain, settings);
if (!isAllowed) {
this.setLinkPreviewStatus(card, {
message: "Preview disabled for new domains.",
actionLabel: domain ? `Allow previews for ${domain}` : "Allow previews",
onAction: () => {
if (domain) {
allowLinkPreviewDomain(domain);
}
},
});
return;
}
this.setLinkPreviewStatus(card, { message: "Loading preview…" });
const controller = new AbortController();
this.linkPreviewAbortControllers.set(url, controller);
const preview = await this.linkPreviewService.getPreview(url, {
signal: controller.signal,
});
this.linkPreviewAbortControllers.delete(url);
if (!preview) {
this.setLinkPreviewStatus(card, { message: "Preview unavailable." });
return;
}
this.updateLinkPreviewCard(card, preview, { domain });
if (domain) {
allowLinkPreviewDomain(domain, { silent: true });
}
}
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
);
// Wire up moderation callbacks
if (card) {
card.onModerationOverride = (detail) => {
this.dispatch("video:moderation-override", detail);
return true;
};
card.onModerationHide = (detail) => {
this.dispatch("video:moderation-hide", detail);
return true;
};
card.onModerationBlock = (detail) => {
this.dispatch("video:moderation-block", detail);
return true;
};
}
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;
}
const breakpointLg = getBreakpointLg({ documentRef: this.document });
try {
return win.matchMedia(`(min-width: ${breakpointLg})`).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 breakpointLg = getBreakpointLg({ documentRef: this.document });
const query = win.matchMedia(`(min-width: ${breakpointLg})`);
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…";
}
return "–";
}
formatViewCountLabel(total) {
const numeric = Number.isFinite(total) ? Math.max(0, Number(total)) : 0;
return formatViewCount(numeric);
}
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();
}
}
}