diff --git a/components/video-modal.html b/components/video-modal.html index fd8bf4f5..c374876d 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -96,13 +96,19 @@ Content or user blocked by a trusted contact. - - Show anyway - + + Show anyway + + { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : this.currentVideo || null; + const trigger = detail?.trigger || null; + + if (!targetVideo) { + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + return; + } + + Promise.resolve( + this.handleModerationBlock({ + video: targetVideo, + card: detail?.card || null, + }), + ) + .then((handled) => { + if (handled === false && trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to handle modal moderation block:", + error, + ); + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }); + }; + this.videoModal.addEventListener( + "video:moderation-block", + this.boundVideoModalModerationBlockHandler, + ); + this.boundVideoModalModerationHideHandler = (event) => { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : this.currentVideo || null; + const trigger = detail?.trigger || null; + + if (!targetVideo) { + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + return; + } + + Promise.resolve( + this.handleModerationHide({ + video: targetVideo, + card: detail?.card || null, + }), + ) + .then((handled) => { + if (handled === false && trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to handle modal moderation hide:", + error, + ); + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }); + }; + this.videoModal.addEventListener( + "video:moderation-hide", + this.boundVideoModalModerationHideHandler, + ); this.boundVideoModalTagActivateHandler = (event) => { const detail = event?.detail || {}; const nativeEvent = detail?.nativeEvent || null; @@ -1392,6 +1478,9 @@ class Application { this.videoListView.setModerationBlockHandler((detail = {}) => this.handleModerationBlock(detail) ); + this.videoListView.setModerationHideHandler((detail = {}) => + this.handleModerationHide(detail) + ); if (this.moreMenuController) { this.moreMenuController.attachVideoListView(this.videoListView); @@ -7800,8 +7889,77 @@ class Application { return true; } - handleModerationHide(detail) { - return this.handleModerationBlock(detail); + handleModerationHide({ video, card }) { + if (!video || typeof video !== "object" || !video.id) { + return false; + } + + try { + clearModerationOverride(video.id); + } catch (error) { + devLogger.warn("[Application] Failed to clear moderation override:", error); + } + + const storedVideo = + this.videosMap instanceof Map && video.id + ? this.videosMap.get(video.id) + : null; + const target = storedVideo || video; + + const resetOverrideState = (subject) => { + if (!subject || typeof subject !== "object") { + return; + } + const moderation = + subject.moderation && typeof subject.moderation === "object" + ? subject.moderation + : null; + if (!moderation) { + return; + } + if (moderation.viewerOverride) { + delete moderation.viewerOverride; + } + }; + + if (target) { + resetOverrideState(target); + this.decorateVideoModeration(target); + } + + if (this.currentVideo && this.currentVideo.id === video.id) { + resetOverrideState(this.currentVideo); + this.decorateVideoModeration(this.currentVideo); + } + + if (card && typeof card.refreshModerationUi === "function") { + try { + card.refreshModerationUi(); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh moderation UI after hide:", + error, + ); + } + } + + const doc = + (this.videoModal && this.videoModal.document) || + (typeof document !== "undefined" ? document : null); + if (doc && typeof doc.dispatchEvent === "function") { + try { + doc.dispatchEvent( + new CustomEvent("video:moderation-hide", { detail: { video: target } }), + ); + } catch (eventError) { + devLogger.warn( + "[Application] Failed to dispatch moderation hide event:", + eventError, + ); + } + } + + return true; } getVideoAddressPointer(video) { diff --git a/js/channelProfile.js b/js/channelProfile.js index fc051f4a..a978e5ff 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4314,6 +4314,14 @@ export async function renderChannelVideosFromList({ }) : false; + videoCard.onModerationHide = ({ video: hideVideo, card: hideCard }) => + typeof app?.handleModerationHide === "function" + ? app.handleModerationHide({ + video: hideVideo, + card: hideCard, + }) + : false; + videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { app?.handleEditVideo?.({ eventId: editVideo?.id || "", diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 8711a71a..1153d7d8 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -120,6 +120,7 @@ export class VideoCard { onCloseSettingsMenu: null, onModerationOverride: null, onModerationBlock: null, + onModerationHide: null, }; this.moderationBadgeEl = null; @@ -129,6 +130,8 @@ export class VideoCard { this.moderationBadgeIconSvg = null; this.moderationActionButton = null; this.moderationBlockButton = null; + this.moderationActionsContainer = null; + this.moderationActionButtonMode = ""; this.moderationBadgeId = ""; this.badgesContainerEl = null; this.moderationBadgeSlot = null; @@ -136,6 +139,8 @@ export class VideoCard { this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event); this.boundModerationBlockHandler = (event) => this.handleModerationBlockClick(event); + this.boundModerationHideHandler = (event) => + this.handleModerationHideClick(event); this.root = null; this.anchorEl = null; @@ -216,6 +221,11 @@ export class VideoCard { typeof fn === "function" ? fn : null; } + set onModerationHide(fn) { + this.callbacks.onModerationHide = + typeof fn === "function" ? fn : null; + } + set onMoreAction(fn) { this.callbacks.onMoreAction = typeof fn === "function" ? fn : null; } @@ -1004,6 +1014,20 @@ export class VideoCard { return button; } + createModerationHideButton() { + const button = this.createElement("button", { + classNames: ["moderation-badge__action", "flex-shrink-0"], + attrs: { + type: "button", + "data-moderation-action": "hide", + "aria-describedby": this.getModerationBadgeId(), + }, + textContent: "Hide", + }); + button.addEventListener("click", this.boundModerationHideHandler); + return button; + } + createModerationBlockButton() { const button = this.createElement("button", { classNames: ["moderation-badge__action", "flex-shrink-0"], @@ -1118,6 +1142,66 @@ export class VideoCard { return; } + Promise.resolve(result) + .then((handled) => { + if (handled === false) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + this.refreshModerationUi(); + }) + .catch((error) => { + userLogger.warn("[VideoCard] Moderation override failed", error); + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + }); + } + + handleModerationHideClick(event) { + if (event) { + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + } + + const button = this.moderationActionButton; + if (button) { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } + + if (!this.callbacks.onModerationHide) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + + let result; + try { + result = this.callbacks.onModerationHide({ + event, + video: this.video, + card: this, + }); + } catch (error) { + userLogger.warn("[VideoCard] onModerationHide callback threw", error); + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + Promise.resolve(result) .then((handled) => { if (handled === false) { @@ -1130,7 +1214,7 @@ export class VideoCard { this.refreshModerationUi(); }) .catch((error) => { - userLogger.warn("[VideoCard] Moderation override failed", error); + userLogger.warn("[VideoCard] Moderation hide failed", error); if (button) { button.disabled = false; button.removeAttribute("aria-busy"); @@ -1221,12 +1305,21 @@ export class VideoCard { this.moderationBadgeIconWrapper = null; this.moderationBadgeIconSvg = null; if (this.moderationActionButton) { - this.moderationActionButton.removeEventListener( - "click", - this.boundShowAnywayHandler, - ); + if (this.moderationActionButtonMode === "override") { + this.moderationActionButton.removeEventListener( + "click", + this.boundShowAnywayHandler, + ); + } else if (this.moderationActionButtonMode === "hide") { + this.moderationActionButton.removeEventListener( + "click", + this.boundModerationHideHandler, + ); + } + this.moderationActionButton.remove(); } this.moderationActionButton = null; + this.moderationActionButtonMode = ""; if (this.moderationBlockButton) { this.moderationBlockButton.removeEventListener( "click", @@ -1234,6 +1327,10 @@ export class VideoCard { ); } this.moderationBlockButton = null; + if (this.moderationActionsContainer) { + this.moderationActionsContainer.remove(); + this.moderationActionsContainer = null; + } return null; } @@ -1325,32 +1422,68 @@ export class VideoCard { this.moderationBadgeLabelEl = label; this.moderationBadgeTextEl = text; - if (!context.overrideActive && context.allowOverride) { - const button = this.createModerationOverrideButton(); - badge.appendChild(button); - this.moderationActionButton = button; + const actions = this.createElement("div", { + classNames: ["moderation-badge__actions"], + }); + let hasActions = false; + + if (context.allowOverride) { + if (context.overrideActive) { + const hideButton = this.createModerationHideButton(); + actions.appendChild(hideButton); + this.moderationActionButton = hideButton; + this.moderationActionButtonMode = "hide"; + } else { + const showButton = this.createModerationOverrideButton(); + actions.appendChild(showButton); + this.moderationActionButton = showButton; + this.moderationActionButtonMode = "override"; + } + hasActions = true; } else { + if (this.moderationActionButton) { + if (this.moderationActionButtonMode === "override") { + this.moderationActionButton.removeEventListener( + "click", + this.boundShowAnywayHandler, + ); + } else if (this.moderationActionButtonMode === "hide") { + this.moderationActionButton.removeEventListener( + "click", + this.boundModerationHideHandler, + ); + } + this.moderationActionButton.remove(); + } this.moderationActionButton = null; + this.moderationActionButtonMode = ""; } if (this.shouldShowModerationBlockButton(context)) { - if (!this.moderationBlockButton) { - const blockButton = this.createModerationBlockButton(); - badge.appendChild(blockButton); - this.moderationBlockButton = blockButton; - } else { - this.moderationBlockButton.disabled = false; - this.moderationBlockButton.removeAttribute("aria-busy"); + const blockButton = this.createModerationBlockButton(); + actions.appendChild(blockButton); + this.moderationBlockButton = blockButton; + this.moderationBlockButton.disabled = false; + this.moderationBlockButton.removeAttribute("aria-busy"); + hasActions = true; + } else { + if (this.moderationBlockButton) { + this.moderationBlockButton.removeEventListener( + "click", + this.boundModerationBlockHandler, + ); + this.moderationBlockButton.remove(); } - } else if (this.moderationBlockButton) { - this.moderationBlockButton.removeEventListener( - "click", - this.boundModerationBlockHandler, - ); - this.moderationBlockButton.remove(); this.moderationBlockButton = null; } + if (hasActions) { + badge.appendChild(actions); + this.moderationActionsContainer = actions; + } else { + this.moderationActionsContainer = null; + } + return badge; } @@ -1409,16 +1542,25 @@ export class VideoCard { slot.setAttribute("aria-hidden", "true"); } if (this.moderationActionButton) { - this.moderationActionButton.removeEventListener( - "click", - this.boundShowAnywayHandler, - ); + if (this.moderationActionButtonMode === "override") { + this.moderationActionButton.removeEventListener( + "click", + this.boundShowAnywayHandler, + ); + } else if (this.moderationActionButtonMode === "hide") { + this.moderationActionButton.removeEventListener( + "click", + this.boundModerationHideHandler, + ); + } + this.moderationActionButton.remove(); } if (this.moderationBlockButton) { this.moderationBlockButton.removeEventListener( "click", this.boundModerationBlockHandler, ); + this.moderationBlockButton.remove(); this.moderationBlockButton = null; } this.moderationBadgeEl = null; @@ -1427,6 +1569,11 @@ export class VideoCard { this.moderationBadgeIconWrapper = null; this.moderationBadgeIconSvg = null; this.moderationActionButton = null; + this.moderationActionButtonMode = ""; + if (this.moderationActionsContainer) { + this.moderationActionsContainer.remove(); + this.moderationActionsContainer = null; + } if (this.hiddenSummaryEl && this.hiddenSummaryEl.parentElement === this.root) { this.hiddenSummaryEl.remove(); } @@ -1515,33 +1662,106 @@ export class VideoCard { } } - if (context.overrideActive || !context.allowOverride) { + let actions = this.moderationActionsContainer; + if ((!actions || !actions.isConnected) && badge) { + const existing = badge.querySelector(".moderation-badge__actions"); + actions = existing || this.createElement("div", { + classNames: ["moderation-badge__actions"], + }); + this.moderationActionsContainer = actions; + } + + const badgeId = this.getModerationBadgeId(); + let actionsAttached = false; + + if (context.allowOverride) { + const desiredMode = context.overrideActive ? "hide" : "override"; + + if (!this.moderationActionButton) { + this.moderationActionButton = + desiredMode === "hide" + ? this.createModerationHideButton() + : this.createModerationOverrideButton(); + this.moderationActionButtonMode = desiredMode; + } else if (this.moderationActionButtonMode !== desiredMode) { + if (this.moderationActionButtonMode === "override") { + this.moderationActionButton.removeEventListener( + "click", + this.boundShowAnywayHandler, + ); + } else if (this.moderationActionButtonMode === "hide") { + this.moderationActionButton.removeEventListener( + "click", + this.boundModerationHideHandler, + ); + } + + if (desiredMode === "hide") { + this.moderationActionButton.textContent = "Hide"; + this.moderationActionButton.dataset.moderationAction = "hide"; + this.moderationActionButton.removeAttribute("aria-pressed"); + this.moderationActionButton.addEventListener( + "click", + this.boundModerationHideHandler, + ); + } else { + this.moderationActionButton.textContent = "Show anyway"; + this.moderationActionButton.dataset.moderationAction = "override"; + this.moderationActionButton.setAttribute("aria-pressed", "false"); + this.moderationActionButton.addEventListener( + "click", + this.boundShowAnywayHandler, + ); + } + + this.moderationActionButtonMode = desiredMode; + } + if (this.moderationActionButton) { + this.moderationActionButton.disabled = false; + this.moderationActionButton.removeAttribute("aria-busy"); + if (badgeId) { + this.moderationActionButton.setAttribute("aria-describedby", badgeId); + } else { + this.moderationActionButton.removeAttribute("aria-describedby"); + } + if (actions && this.moderationActionButton.parentElement !== actions) { + actions.appendChild(this.moderationActionButton); + } + actionsAttached = true; + } + } else if (this.moderationActionButton) { + if (this.moderationActionButtonMode === "override") { this.moderationActionButton.removeEventListener( "click", this.boundShowAnywayHandler, ); - this.moderationActionButton.remove(); + } else if (this.moderationActionButtonMode === "hide") { + this.moderationActionButton.removeEventListener( + "click", + this.boundModerationHideHandler, + ); } + this.moderationActionButton.remove(); this.moderationActionButton = null; - } else if (!this.moderationActionButton) { - const button = this.createModerationOverrideButton(); - badge.appendChild(button); - this.moderationActionButton = button; - } else { - this.moderationActionButton.disabled = false; - this.moderationActionButton.removeAttribute("aria-busy"); + this.moderationActionButtonMode = ""; } if (this.shouldShowModerationBlockButton(context)) { if (!this.moderationBlockButton) { - const blockButton = this.createModerationBlockButton(); - badge.appendChild(blockButton); - this.moderationBlockButton = blockButton; - } else { - this.moderationBlockButton.disabled = false; - this.moderationBlockButton.removeAttribute("aria-busy"); + this.moderationBlockButton = this.createModerationBlockButton(); } + this.moderationBlockButton.disabled = false; + this.moderationBlockButton.removeAttribute("aria-busy"); + if (badgeId) { + this.moderationBlockButton.setAttribute("aria-describedby", badgeId); + } else { + this.moderationBlockButton.removeAttribute("aria-describedby"); + } + if (actions && this.moderationBlockButton.parentElement !== actions) { + actions.appendChild(this.moderationBlockButton); + } + actionsAttached = true; } else if (this.moderationBlockButton) { this.moderationBlockButton.removeEventListener( "click", @@ -1550,6 +1770,19 @@ export class VideoCard { this.moderationBlockButton.remove(); this.moderationBlockButton = null; } + + 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; + } + } } ensureHiddenSummaryContainer() { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index aecce096..50d49681 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -243,7 +243,10 @@ export class VideoModal { this.moderationOverlay = null; this.moderationBadge = null; this.moderationBadgeText = null; - this.moderationOverrideButton = null; + this.moderationActionsContainer = null; + this.moderationPrimaryButton = null; + this.moderationBlockButton = null; + this.moderationPrimaryMode = ""; this.moderationBadgeId = ""; this.handleCopyRequest = this.handleCopyRequest.bind(this); @@ -254,6 +257,10 @@ export class VideoModal { 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 = @@ -275,6 +282,10 @@ export class VideoModal { "video:moderation-block", this.handleGlobalModerationBlock, ); + this.document.addEventListener( + "video:moderation-hide", + this.handleGlobalModerationHide, + ); } } @@ -430,16 +441,32 @@ export class VideoModal { this.teardownAmbientGlow({ clear: false }); - if (this.moderationOverrideButton) { - this.moderationOverrideButton.removeEventListener( + 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.handleModerationOverrideClick, + this.handleModerationBlockClick, ); } this.moderationOverlay = null; this.moderationBadge = null; this.moderationBadgeText = null; - this.moderationOverrideButton = null; + this.moderationActionsContainer = null; + this.moderationPrimaryButton = null; + this.moderationPrimaryMode = ""; + this.moderationBlockButton = null; const previousScrollRegion = this.scrollRegion; if (previousScrollRegion && this.modalNavScrollHandler) { @@ -530,12 +557,45 @@ export class VideoModal { this.moderationOverlay?.querySelector("[data-moderation-badge='true']") || null; this.moderationBadgeText = this.moderationOverlay?.querySelector("[data-moderation-text]") || null; - const overrideButton = - this.moderationOverlay?.querySelector("[data-moderation-action='override']") || null; - if (overrideButton) { - overrideButton.addEventListener("click", this.handleModerationOverrideClick); - this.moderationOverrideButton = overrideButton; + 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.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; @@ -2086,18 +2146,21 @@ export class VideoModal { "video:moderation-block", this.handleGlobalModerationBlock, ); - } - - if (this.moderationOverrideButton) { - this.moderationOverrideButton.removeEventListener( - "click", - this.handleModerationOverrideClick, + this.document.removeEventListener( + "video:moderation-hide", + this.handleGlobalModerationHide, ); } + + this.removeModerationPrimaryButton(); + this.removeModerationBlockButton(); this.moderationOverlay = null; this.moderationBadge = null; this.moderationBadgeText = null; - this.moderationOverrideButton = null; + this.moderationActionsContainer = null; + this.moderationPrimaryButton = null; + this.moderationPrimaryMode = ""; + this.moderationBlockButton = null; this.moderationBadgeId = ""; this.activeModerationContext = null; @@ -3922,46 +3985,54 @@ export class VideoModal { const overlay = this.moderationOverlay; const badge = this.moderationBadge; const textEl = this.moderationBadgeText; - const button = this.moderationOverrideButton; + const shouldShow = Boolean(context?.shouldShow); const shouldBlur = Boolean(context?.activeBlur && !context?.overrideActive); + const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive); - if (!shouldBlur) { + if (!shouldShow) { 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"); } - if (badge.hasAttribute("aria-label")) { - badge.removeAttribute("aria-label"); - } - } - if (button) { - button.disabled = false; - button.removeAttribute("aria-busy"); + badge.removeAttribute("aria-label"); } + this.removeModerationPrimaryButton(); + this.removeModerationBlockButton(); return; } if (stage) { - stage.dataset.visualState = "blurred"; + if (shouldBlur) { + stage.dataset.visualState = "blurred"; + } else if (stage.dataset.visualState === "blurred") { + delete stage.dataset.visualState; + } } if (overlay) { overlay.removeAttribute("hidden"); + overlay.dataset.overlayState = context?.overrideActive ? "override" : "active"; } - if (this.modalVideo && typeof this.modalVideo.pause === "function") { + if (shouldBlur && this.modalVideo && typeof this.modalVideo.pause === "function") { try { this.modalVideo.pause(); } catch (error) { @@ -3969,27 +4040,42 @@ export class VideoModal { } } + const badgeId = this.getModerationBadgeId(); + const textContent = buildModerationBadgeText(context, { variant: "modal" }); + if (badge) { - const state = context?.trustedMuted ? "trusted-mute" : "blocked"; - badge.dataset.variant = "warning"; + const state = context?.overrideActive + ? "override" + : hiddenActive + ? "hidden" + : context?.trustedMuted + ? "trusted-mute" + : "blocked"; + badge.dataset.variant = context?.overrideActive ? "neutral" : "warning"; badge.dataset.moderationState = state; - const badgeId = this.getModerationBadgeId(); + 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"); } - const textContent = buildModerationBadgeText(context, { variant: "modal" }); if (textEl) { textEl.textContent = textContent; } - const muteNames = Array.isArray(context.trustedMuteDisplayNames) + const muteNames = Array.isArray(context?.trustedMuteDisplayNames) ? context.trustedMuteDisplayNames .map((name) => (typeof name === "string" ? name.trim() : "")) .filter(Boolean) : []; - const reporterNames = Array.isArray(context.reporterDisplayNames) + const reporterNames = Array.isArray(context?.reporterDisplayNames) ? context.reporterDisplayNames .map((name) => (typeof name === "string" ? name.trim() : "")) .filter(Boolean) @@ -4020,7 +4106,7 @@ export class VideoModal { ? "Muted by" : "Reported by"; badge.title = `${prefix} ${joined}`; - const baseLabel = textEl?.textContent?.trim() || textContent || ""; + const baseLabel = textContent?.trim() || textEl?.textContent?.trim() || ""; if (baseLabel) { badge.setAttribute("aria-label", `${baseLabel}. ${prefix} ${joined}.`); } else { @@ -4028,7 +4114,7 @@ export class VideoModal { } } else { badge.removeAttribute("title"); - const baseLabel = textEl?.textContent?.trim(); + const baseLabel = textContent?.trim() || textEl?.textContent?.trim(); if (baseLabel) { badge.setAttribute("aria-label", `${baseLabel}.`); } else { @@ -4037,23 +4123,205 @@ export class VideoModal { } } - if (button) { - button.disabled = false; - button.removeAttribute("aria-busy"); - const badgeId = this.getModerationBadgeId(); - if (badgeId) { - button.setAttribute("aria-describedby", badgeId); + const actions = this.ensureModerationActionsContainer(); + let actionsAttached = false; + + if (context?.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 { - button.removeAttribute("aria-describedby"); + 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") { + button.textContent = "Show anyway"; + button.dataset.moderationAction = "override"; + button.setAttribute("aria-pressed", "false"); + button.addEventListener("click", this.handleModerationOverrideClick); + } else { + button.textContent = "Hide"; + button.dataset.moderationAction = "hide"; + button.removeAttribute("aria-pressed"); + 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.moderationOverrideButton; + const button = event?.currentTarget || this.moderationPrimaryButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); @@ -4081,6 +4349,70 @@ export class VideoModal { } } + 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; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 25a94bed..6d5ddf26 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -190,6 +190,7 @@ export class VideoListView { blacklist: null, moderationOverride: null, moderationBlock: null, + moderationHide: null, tagActivate: null, }; @@ -304,6 +305,11 @@ export class VideoListView { typeof handler === "function" ? handler : null; } + setModerationHideHandler(handler) { + this.handlers.moderationHide = + typeof handler === "function" ? handler : null; + } + setTagActivationHandler(handler) { this.handlers.tagActivate = typeof handler === "function" ? handler : null; } @@ -621,6 +627,20 @@ export class VideoListView { }); }; + videoCard.onModerationHide = ({ event: hideEvent }) => { + if (!this.handlers.moderationHide) { + return false; + } + const trigger = + hideEvent?.currentTarget || hideEvent?.target || null; + return this.handlers.moderationHide({ + event: hideEvent, + video, + card: videoCard, + trigger, + }); + }; + videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => { if (this.handlers.edit) { const trigger = editEvent?.currentTarget || editEvent?.target || null;