From 429829b7a1762bf51c452eca912a7e8018490012 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:02:53 -0400 Subject: [PATCH] Refactor video modal into domain controllers --- js/ui/components/VideoModal.js | 286 +++--------------- .../video-modal/commentsController.js | 141 +++++++++ .../video-modal/moderationController.js | 118 ++++++++ .../video-modal/reactionsController.js | 76 +++++ .../video-modal/similarContentController.js | 73 +++++ .../video-modal/utils/commentAvatar.js | 81 +++++ tests/video-modal-controllers.test.mjs | 79 +++++ 7 files changed, 609 insertions(+), 245 deletions(-) create mode 100644 js/ui/components/video-modal/commentsController.js create mode 100644 js/ui/components/video-modal/moderationController.js create mode 100644 js/ui/components/video-modal/reactionsController.js create mode 100644 js/ui/components/video-modal/similarContentController.js create mode 100644 js/ui/components/video-modal/utils/commentAvatar.js create mode 100644 tests/video-modal-controllers.test.mjs diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index c13d4295..b0f702ea 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -4,6 +4,15 @@ import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js"; import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; +import { CommentsController } from "./video-modal/commentsController.js"; +import { ReactionsController } from "./video-modal/reactionsController.js"; +import { SimilarContentController } from "./video-modal/similarContentController.js"; +import { ModerationController } from "./video-modal/moderationController.js"; +import { + normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, + resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, + registerCommentAvatarFailure as registerCommentAvatarFailureUtil, +} from "./video-modal/utils/commentAvatar.js"; import { renderTagPillStrip, applyTagPreferenceState, @@ -188,6 +197,11 @@ export class VideoModal { 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; @@ -479,6 +493,11 @@ export class VideoModal { ); } + this.commentsController.destroy(); + this.reactionsController.destroy(); + this.similarContentController.destroy(); + this.moderationController.destroy(); + this.clearSimilarContent({ preservePending: true }); this.playerModal = playerModal; @@ -486,7 +505,6 @@ export class VideoModal { this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; this.scrollRegion = this.modalPanel || playerModal; - this.teardownSimilarContentMediaQuery(); const nextVideoTagsRoot = playerModal.querySelector("#videoTags") || null; if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); @@ -496,26 +514,8 @@ export class VideoModal { this.cleanupVideoTags(this.videoTagsRoot); } - const similarHeading = - playerModal.querySelector("#playerModalSimilarContentHeading") || null; - const similarList = - playerModal.querySelector("#playerModalSimilarContentList") || null; - const similarSection = - similarList?.closest( - "[aria-labelledby='playerModalSimilarContentHeading']" - ) || similarHeading?.closest( - "[aria-labelledby='playerModalSimilarContentHeading']" - ) || null; - const similarContainer = - similarList?.closest(".watch-container") || - similarHeading?.closest(".watch-container") || - similarSection?.closest(".watch-container") || - similarSection || - null; - - this.similarContentHeading = similarHeading; - this.similarContentList = similarList; - this.similarContentContainer = similarContainer; + const { container: similarContainer } = + this.similarContentController.initialize({ playerModal }) || {}; const statsContainerCandidate = playerModal.querySelector("[data-video-stats-container]") || @@ -540,65 +540,12 @@ export class VideoModal { statsContainer: statsContainerCandidate, }); - this.setupSimilarContentMediaQuery(); - - if (similarList && similarList.children.length) { - this.toggleSimilarContentVisibility(true); - } else { - this.toggleSimilarContentVisibility(false); - } - const previousTags = Array.isArray(this.videoTagsData) ? [...this.videoTagsData] : []; this.videoTagsData = null; - this.videoStage = playerModal.querySelector(".video-modal__video") || null; - this.moderationOverlay = - this.videoStage?.querySelector("[data-moderation-overlay]") || null; - this.moderationBadge = - this.moderationOverlay?.querySelector("[data-moderation-badge='true']") || null; - this.moderationBadgeText = - this.moderationOverlay?.querySelector("[data-moderation-text]") || null; - if (this.moderationPrimaryButton) { - if (this.moderationPrimaryMode === "override") { - this.moderationPrimaryButton.removeEventListener( - "click", - this.handleModerationOverrideClick, - ); - } else if (this.moderationPrimaryMode === "hide") { - this.moderationPrimaryButton.removeEventListener( - "click", - this.handleModerationHideClick, - ); - } - } - this.moderationActionsContainer = - this.moderationOverlay?.querySelector("[data-moderation-actions]") || - this.moderationBadge?.querySelector(".moderation-badge__actions") || - null; - const overrideButton = - this.moderationActionsContainer?.querySelector( - "[data-moderation-action='override']", - ) || null; - if (overrideButton) { - overrideButton.addEventListener( - "click", - this.handleModerationOverrideClick, - ); - this.moderationPrimaryButton = overrideButton; - this.moderationPrimaryMode = "override"; - } else { - this.moderationPrimaryButton = null; - this.moderationPrimaryMode = ""; - } - if (this.moderationBlockButton) { - this.moderationBlockButton.removeEventListener( - "click", - this.handleModerationBlockClick, - ); - } - this.moderationBlockButton = null; + this.moderationController.initialize({ playerModal }); this.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; @@ -635,22 +582,7 @@ export class VideoModal { this.shareBtn = playerModal.querySelector("#shareBtn") || null; this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null; this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null; - this.reactionButtons["+"] = - playerModal.querySelector("#modalLikeBtn") || null; - this.reactionButtons["-"] = - playerModal.querySelector("#modalDislikeBtn") || null; - this.reactionMeter = - playerModal.querySelector("[data-reaction-meter]") || null; - this.reactionMeterFill = - playerModal.querySelector("[data-reaction-meter-fill]") || null; - this.reactionMeterLabel = - playerModal.querySelector("[data-reaction-meter-label]") || null; - this.reactionMeterAssistive = - playerModal.querySelector("[data-reaction-meter-sr]") || null; - this.reactionCountLabels["+"] = - playerModal.querySelector("[data-reaction-like-count]") || null; - this.reactionCountLabels["-"] = - playerModal.querySelector("[data-reaction-dislike-count]") || null; + this.reactionsController.initialize({ playerModal }); this.renderVideoTags(previousTags); @@ -688,90 +620,7 @@ export class VideoModal { this.refreshActiveVideoModeration({ video: this.activeVideo }); - this.detachCommentEventHandlers(); - - const commentsRoot = - playerModal.querySelector("[data-comments-root]") || null; - this.commentsRoot = commentsRoot; - this.commentsContainer = commentsRoot; - this.commentsCountLabel = - commentsRoot?.querySelector("[data-comments-count]") || null; - this.commentsEmptyState = - commentsRoot?.querySelector("[data-comments-empty]") || null; - this.commentsList = - commentsRoot?.querySelector("[data-comments-list]") || null; - this.commentsLoadMoreButton = - commentsRoot?.querySelector("[data-comments-load-more]") || null; - this.commentsDisabledPlaceholder = - playerModal.querySelector("[data-comments-disabled-placeholder]") || - commentsRoot?.querySelector("[data-comments-disabled]") || - null; - this.commentsComposer = - commentsRoot?.querySelector("[data-comments-composer]") || null; - this.commentsInput = - this.commentsComposer?.querySelector("[data-comments-input]") || null; - this.commentsSubmitButton = - this.commentsComposer?.querySelector("[data-comments-submit]") || null; - this.commentsStatusMessage = - this.commentsComposer?.querySelector("[data-comments-status]") || - commentsRoot?.querySelector("[data-comments-status]") || - null; - this.commentsCharCount = - this.commentsComposer?.querySelector("[data-comments-char-count]") || - null; - this.commentComposerHint = - this.commentsComposer?.querySelector("#commentComposerHelp") || - this.commentsComposer?.querySelector(".comment-composer__hint") || - this.commentComposerHint || - null; - - if (this.commentComposerHint) { - const hintText = this.commentComposerHint.textContent || ""; - if (hintText) { - this.commentComposerDefaultHint = hintText; - } - } - if (this.commentsCharCount) { - const countText = this.commentsCharCount.textContent || ""; - if (countText) { - this.commentComposerDefaultCountText = countText; - } - } - if (this.commentsStatusMessage) { - const statusText = this.commentsStatusMessage.textContent || ""; - if (statusText) { - this.commentStatusDefaultText = statusText; - } - } - if (this.commentsInput) { - const parsedMax = Number(this.commentsInput.maxLength); - if (Number.isFinite(parsedMax) && parsedMax > 0) { - this.commentComposerMaxLength = parsedMax; - } - } - - if (this.commentsDisabledPlaceholder) { - const disabledText = this.commentsDisabledPlaceholder.textContent || ""; - if (disabledText) { - this.commentsDisabledPlaceholderDefaultText = disabledText; - } - this.commentsDisabledPlaceholder.setAttribute("hidden", ""); - } - if (this.commentsComposer) { - this.commentsComposer.removeAttribute("hidden"); - } - - this.clearComments(); - this.resetCommentComposer(); - this.attachCommentEventHandlers(); - - Object.values(this.reactionButtons).forEach((button) => { - if (button) { - button.addEventListener("click", this.handleReactionClick); - } - }); - this.syncReactionButtons(); - this.updateReactionMeterDisplay(); + this.commentsController.initialize({ playerModal }); const closeButton = playerModal.querySelector("#closeModal"); if (closeButton) { @@ -1773,80 +1622,25 @@ export class VideoModal { } resolveCommentAvatarAsset(pubkey, sanitizedPicture) { - const normalizedPubkey = this.normalizeCommentAvatarKey(pubkey); - const normalizedSource = - typeof sanitizedPicture === "string" && sanitizedPicture - ? sanitizedPicture - : ""; - - if (normalizedSource && this.commentAvatarFailures.has(normalizedSource)) { - return { url: this.DEFAULT_PROFILE_AVATAR, source: "" }; - } - - if (normalizedPubkey && this.commentAvatarCache.has(normalizedPubkey)) { - const cached = this.commentAvatarCache.get(normalizedPubkey); - if (cached) { - const cachedSource = - typeof cached.source === "string" ? cached.source : ""; - const cachedUrl = - typeof cached.url === "string" && cached.url - ? cached.url - : this.DEFAULT_PROFILE_AVATAR; - - if (!normalizedSource) { - return { url: cachedUrl, source: cachedSource }; - } - - if (cachedSource === normalizedSource) { - return { url: cachedUrl, source: cachedSource }; - } - } - } - - const resolvedUrl = normalizedSource || this.DEFAULT_PROFILE_AVATAR; - if (normalizedPubkey) { - this.commentAvatarCache.set(normalizedPubkey, { - url: resolvedUrl, - source: normalizedSource, - }); - } - - return { url: resolvedUrl, source: normalizedSource }; + return resolveCommentAvatarAssetUtil({ + cache: this.commentAvatarCache, + failures: this.commentAvatarFailures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + pubkey, + sanitizedPicture, + }); } normalizeCommentAvatarKey(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - return trimmed.toLowerCase(); + return normalizeCommentAvatarKeyUtil(value); } registerCommentAvatarFailure(sourceUrl) { - if (typeof sourceUrl !== "string") { - return; - } - const trimmed = sourceUrl.trim(); - if (!trimmed || trimmed === this.DEFAULT_PROFILE_AVATAR) { - return; - } - - if (this.commentAvatarFailures.has(trimmed)) { - return; - } - - this.commentAvatarFailures.add(trimmed); - - this.commentAvatarCache.forEach((entry, key) => { - if (entry && entry.source === trimmed) { - this.commentAvatarCache.set(key, { - url: this.DEFAULT_PROFILE_AVATAR, - source: "", - }); - } + registerCommentAvatarFailureUtil({ + cache: this.commentAvatarCache, + failures: this.commentAvatarFailures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + sourceUrl, }); } @@ -2136,8 +1930,10 @@ export class VideoModal { destroy() { this.close(); - this.detachCommentEventHandlers(); - this.removeCommentRetryButton(); + this.commentsController.destroy(); + this.reactionsController.destroy(); + this.similarContentController.destroy(); + this.moderationController.destroy(); this.invokeCommentTeardown(); if (this.document) { diff --git a/js/ui/components/video-modal/commentsController.js b/js/ui/components/video-modal/commentsController.js new file mode 100644 index 00000000..5112e2e6 --- /dev/null +++ b/js/ui/components/video-modal/commentsController.js @@ -0,0 +1,141 @@ +export class CommentsController { + constructor({ modal } = {}) { + this.modal = modal; + this.root = null; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + const root = playerModal?.querySelector("[data-comments-root]") || null; + this.root = root; + this.modal.commentsRoot = root; + this.modal.commentsContainer = root; + this.modal.commentsCountLabel = + root?.querySelector("[data-comments-count]") || null; + this.modal.commentsEmptyState = + root?.querySelector("[data-comments-empty]") || null; + this.modal.commentsList = + root?.querySelector("[data-comments-list]") || null; + this.modal.commentsLoadMoreButton = + root?.querySelector("[data-comments-load-more]") || null; + this.modal.commentsDisabledPlaceholder = + playerModal?.querySelector("[data-comments-disabled-placeholder]") || + root?.querySelector("[data-comments-disabled]") || + null; + this.modal.commentsComposer = + root?.querySelector("[data-comments-composer]") || null; + this.modal.commentsInput = + this.modal.commentsComposer?.querySelector("[data-comments-input]") || null; + this.modal.commentsSubmitButton = + this.modal.commentsComposer?.querySelector("[data-comments-submit]") || null; + this.modal.commentsStatusMessage = + this.modal.commentsComposer?.querySelector("[data-comments-status]") || + root?.querySelector("[data-comments-status]") || + null; + this.modal.commentsCharCount = + this.modal.commentsComposer?.querySelector("[data-comments-char-count]") || + null; + this.modal.commentComposerHint = + this.modal.commentsComposer?.querySelector("#commentComposerHelp") || + this.modal.commentsComposer?.querySelector(".comment-composer__hint") || + this.modal.commentComposerHint || + null; + + if (this.modal.commentComposerHint) { + const hintText = this.modal.commentComposerHint.textContent || ""; + if (hintText) { + this.modal.commentComposerDefaultHint = hintText; + } + } + + if (this.modal.commentsCharCount) { + const countText = this.modal.commentsCharCount.textContent || ""; + if (countText) { + this.modal.commentComposerDefaultCountText = countText; + } + } + + if (this.modal.commentsStatusMessage) { + const statusText = this.modal.commentsStatusMessage.textContent || ""; + if (statusText) { + this.modal.commentStatusDefaultText = statusText; + } + } + + if (this.modal.commentsInput) { + const parsedMax = Number(this.modal.commentsInput.maxLength); + if (Number.isFinite(parsedMax) && parsedMax > 0) { + this.modal.commentComposerMaxLength = parsedMax; + } + } + + if (this.modal.commentsDisabledPlaceholder) { + const disabledText = + this.modal.commentsDisabledPlaceholder.textContent || ""; + if (disabledText) { + this.modal.commentsDisabledPlaceholderDefaultText = disabledText; + } + this.modal.commentsDisabledPlaceholder.setAttribute("hidden", ""); + } + + if (this.modal.commentsComposer) { + this.modal.commentsComposer.removeAttribute("hidden"); + } + + this.modal.clearComments(); + this.modal.resetCommentComposer(); + this.modal.attachCommentEventHandlers(); + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-visibility": + this.modal.setCommentsVisibility(action.visible); + break; + case "render": + this.modal.renderComments(action.snapshot); + break; + case "set-composer-state": + this.modal.setCommentComposerState(action.state); + break; + case "set-thread-context": + this.modal.setCommentThreadContext(action.context); + break; + case "clear": + this.modal.clearComments(); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.modal.detachCommentEventHandlers(); + this.modal.removeCommentRetryButton(); + this.modal.commentsRoot = null; + this.modal.commentsContainer = null; + this.modal.commentsCountLabel = null; + this.modal.commentsEmptyState = null; + this.modal.commentsList = null; + this.modal.commentsLoadMoreButton = null; + this.modal.commentsDisabledPlaceholder = null; + this.modal.commentsComposer = null; + this.modal.commentsInput = null; + this.modal.commentsSubmitButton = null; + this.modal.commentsStatusMessage = null; + this.modal.commentsCharCount = null; + this.modal.commentComposerHint = null; + this.root = null; + } +} + diff --git a/js/ui/components/video-modal/moderationController.js b/js/ui/components/video-modal/moderationController.js new file mode 100644 index 00000000..222ddcd6 --- /dev/null +++ b/js/ui/components/video-modal/moderationController.js @@ -0,0 +1,118 @@ +export class ModerationController { + constructor({ modal } = {}) { + this.modal = modal; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + this.modal.videoStage = + playerModal?.querySelector(".video-modal__video") || null; + this.modal.moderationOverlay = + this.modal.videoStage?.querySelector("[data-moderation-overlay]") || null; + this.modal.moderationBadge = + this.modal.moderationOverlay?.querySelector( + "[data-moderation-badge='true']", + ) || null; + this.modal.moderationBadgeText = + this.modal.moderationOverlay?.querySelector("[data-moderation-text]") || + null; + + this.teardownPrimaryButton(); + this.modal.moderationActionsContainer = + this.modal.moderationOverlay?.querySelector("[data-moderation-actions]") || + this.modal.moderationBadge?.querySelector(".moderation-badge__actions") || + null; + + const overrideButton = + this.modal.moderationActionsContainer?.querySelector( + "[data-moderation-action='override']", + ) || null; + if (overrideButton) { + overrideButton.addEventListener( + "click", + this.modal.handleModerationOverrideClick, + ); + this.modal.moderationPrimaryButton = overrideButton; + this.modal.moderationPrimaryMode = "override"; + } else { + this.modal.moderationPrimaryButton = null; + this.modal.moderationPrimaryMode = ""; + } + + this.teardownBlockButton(); + const blockButton = + this.modal.moderationActionsContainer?.querySelector( + "[data-moderation-action='block']", + ) || null; + if (blockButton) { + blockButton.addEventListener( + "click", + this.modal.handleModerationBlockClick, + ); + this.modal.moderationBlockButton = blockButton; + } else { + this.modal.moderationBlockButton = null; + } + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-context": + this.modal.refreshActiveVideoModeration?.(action.context); + break; + case "set-overlay": + this.modal.applyModerationOverlay?.(action.overlay); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.teardownPrimaryButton(); + this.teardownBlockButton(); + this.modal.moderationOverlay = null; + this.modal.moderationBadge = null; + this.modal.moderationBadgeText = null; + this.modal.moderationActionsContainer = null; + this.modal.moderationPrimaryButton = null; + this.modal.moderationPrimaryMode = ""; + this.modal.moderationBlockButton = null; + } + + teardownPrimaryButton() { + if (!this.modal?.moderationPrimaryButton) { + return; + } + const button = this.modal.moderationPrimaryButton; + if (this.modal.moderationPrimaryMode === "override") { + button.removeEventListener( + "click", + this.modal.handleModerationOverrideClick, + ); + } else if (this.modal.moderationPrimaryMode === "hide") { + button.removeEventListener("click", this.modal.handleModerationHideClick); + } + } + + teardownBlockButton() { + if (!this.modal?.moderationBlockButton) { + return; + } + this.modal.moderationBlockButton.removeEventListener( + "click", + this.modal.handleModerationBlockClick, + ); + } +} + diff --git a/js/ui/components/video-modal/reactionsController.js b/js/ui/components/video-modal/reactionsController.js new file mode 100644 index 00000000..ea4ec8d3 --- /dev/null +++ b/js/ui/components/video-modal/reactionsController.js @@ -0,0 +1,76 @@ +export class ReactionsController { + constructor({ modal } = {}) { + this.modal = modal; + this.boundHandler = (event) => { + this.modal?.handleReactionClick?.(event); + }; + this.buttons = []; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + const likeButton = playerModal?.querySelector("#modalLikeBtn") || null; + const dislikeButton = playerModal?.querySelector("#modalDislikeBtn") || null; + this.modal.reactionButtons = { "+": likeButton, "-": dislikeButton }; + this.modal.reactionMeter = + playerModal?.querySelector("[data-reaction-meter]") || null; + this.modal.reactionMeterFill = + playerModal?.querySelector("[data-reaction-meter-fill]") || null; + this.modal.reactionMeterLabel = + playerModal?.querySelector("[data-reaction-meter-label]") || null; + this.modal.reactionMeterAssistive = + playerModal?.querySelector("[data-reaction-meter-sr]") || null; + this.modal.reactionCountLabels = { + "+": playerModal?.querySelector("[data-reaction-like-count]") || null, + "-": playerModal?.querySelector("[data-reaction-dislike-count]") || null, + }; + + this.buttons = [likeButton, dislikeButton].filter(Boolean); + this.buttons.forEach((button) => { + button.removeEventListener("click", this.boundHandler); + button.addEventListener("click", this.boundHandler); + }); + + this.modal.syncReactionButtons?.(); + this.modal.updateReactionMeterDisplay?.(); + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-summary": + this.modal.updateReactionSummary?.(action.summary); + break; + case "set-user-reaction": + this.modal.setUserReaction?.(action.reaction); + break; + case "reset": + this.modal.resetReactions?.(); + break; + default: + break; + } + } + + destroy() { + this.buttons.forEach((button) => { + button.removeEventListener("click", this.boundHandler); + }); + this.buttons = []; + if (this.modal) { + this.modal.reactionButtons = { "+": null, "-": null }; + this.modal.reactionMeter = null; + this.modal.reactionMeterFill = null; + this.modal.reactionMeterLabel = null; + this.modal.reactionMeterAssistive = null; + this.modal.reactionCountLabels = { "+": null, "-": null }; + } + } +} + diff --git a/js/ui/components/video-modal/similarContentController.js b/js/ui/components/video-modal/similarContentController.js new file mode 100644 index 00000000..7b207a67 --- /dev/null +++ b/js/ui/components/video-modal/similarContentController.js @@ -0,0 +1,73 @@ +export class SimilarContentController { + constructor({ modal } = {}) { + this.modal = modal; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return { container: null, heading: null, list: null }; + } + + this.modal.teardownSimilarContentMediaQuery?.(); + + const heading = + playerModal?.querySelector("#playerModalSimilarContentHeading") || null; + const list = + playerModal?.querySelector("#playerModalSimilarContentList") || null; + const section = + list?.closest("[aria-labelledby='playerModalSimilarContentHeading']") || + heading?.closest("[aria-labelledby='playerModalSimilarContentHeading']") || + null; + const container = + list?.closest(".watch-container") || + heading?.closest(".watch-container") || + section?.closest(".watch-container") || + section || + null; + + this.modal.similarContentHeading = heading; + this.modal.similarContentList = list; + this.modal.similarContentContainer = container; + + this.modal.setupSimilarContentMediaQuery?.(); + + if (list && list.children.length) { + this.modal.toggleSimilarContentVisibility?.(true); + } else { + this.modal.toggleSimilarContentVisibility?.(false); + } + + return { container, heading, list }; + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-items": + this.modal.setSimilarContent?.(action.items, action.options); + break; + case "set-visibility": + this.modal.toggleSimilarContentVisibility?.(action.visible); + break; + case "clear": + this.modal.clearSimilarContent?.(action.options); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.modal.teardownSimilarContentMediaQuery?.(); + this.modal.similarContentHeading = null; + this.modal.similarContentList = null; + this.modal.similarContentContainer = null; + } +} + diff --git a/js/ui/components/video-modal/utils/commentAvatar.js b/js/ui/components/video-modal/utils/commentAvatar.js new file mode 100644 index 00000000..234629ea --- /dev/null +++ b/js/ui/components/video-modal/utils/commentAvatar.js @@ -0,0 +1,81 @@ +export function normalizeCommentAvatarKey(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + return trimmed.toLowerCase(); +} + +export function resolveCommentAvatarAsset({ + cache, + failures, + defaultAvatar, + pubkey, + sanitizedPicture, +}) { + const normalizedPubkey = normalizeCommentAvatarKey(pubkey); + const normalizedSource = + typeof sanitizedPicture === "string" && sanitizedPicture + ? sanitizedPicture + : ""; + + if (normalizedSource && failures?.has?.(normalizedSource)) { + return { url: defaultAvatar, source: "" }; + } + + if (normalizedPubkey && cache?.has?.(normalizedPubkey)) { + const cached = cache.get(normalizedPubkey); + if (cached) { + const cachedSource = + typeof cached.source === "string" ? cached.source : ""; + const cachedUrl = + typeof cached.url === "string" && cached.url + ? cached.url + : defaultAvatar; + + if (!normalizedSource || cachedSource === normalizedSource) { + return { url: cachedUrl, source: cachedSource }; + } + } + } + + const resolvedUrl = normalizedSource || defaultAvatar; + if (normalizedPubkey && cache) { + cache.set(normalizedPubkey, { url: resolvedUrl, source: normalizedSource }); + } + + return { url: resolvedUrl, source: normalizedSource }; +} + +export function registerCommentAvatarFailure({ + cache, + failures, + defaultAvatar, + sourceUrl, +}) { + if (typeof sourceUrl !== "string") { + return; + } + const trimmed = sourceUrl.trim(); + if (!trimmed || trimmed === defaultAvatar) { + return; + } + + if (!failures?.has?.(trimmed)) { + failures?.add?.(trimmed); + } + + if (!cache?.forEach) { + return; + } + + cache.forEach((entry, key) => { + if (entry && entry.source === trimmed) { + cache.set(key, { url: defaultAvatar, source: "" }); + } + }); +} + diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs new file mode 100644 index 00000000..b5849cdf --- /dev/null +++ b/tests/video-modal-controllers.test.mjs @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupModal } from "./video-modal-accessibility.test.mjs"; + +test("CommentsController lifecycle manages comment references", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + assert.ok(modal.commentsRoot, "comments should be initialized after hydrate"); + + modal.commentsController.destroy(); + assert.equal(modal.commentsRoot, null, "destroy should clear comment root"); + + modal.commentsController.initialize({ playerModal: modal.getRoot() }); + assert.ok(modal.commentsRoot, "initialize should restore comment root"); +}); + +test("ReactionsController delegates reaction updates", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + modal.reactionsController.update({ type: "set-user-reaction", reaction: "+" }); + assert.equal( + modal.reactionState.userReaction, + "+", + "user reaction should be updated via controller", + ); + + modal.reactionsController.destroy(); + assert.equal( + modal.reactionButtons["+"], + null, + "destroy should clear reaction button references", + ); +}); + +test("SimilarContentController toggles section visibility", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const container = modal.similarContentContainer; + assert.ok(container, "similar content container should exist"); + + modal.similarContentController.update({ type: "set-visibility", visible: false }); + assert.equal( + container.hasAttribute("hidden"), + true, + "controller should hide similar content when requested", + ); + + modal.similarContentController.update({ type: "set-visibility", visible: true }); + assert.equal( + container.hasAttribute("hidden"), + false, + "controller should reveal similar content", + ); +}); + +test("ModerationController reset clears moderation overlay references", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + assert.ok(modal.moderationOverlay, "moderation overlay should be present"); + + modal.moderationController.destroy(); + assert.equal( + modal.moderationOverlay, + null, + "destroy should clear moderation overlay references", + ); + + modal.moderationController.initialize({ playerModal: modal.getRoot() }); + assert.ok( + modal.moderationOverlay, + "initialize should restore moderation overlay references", + ); +}); +