diff --git a/docs/moderation/README.md b/docs/moderation/README.md index 13dcb9e1..f8b08490 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -33,7 +33,8 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc - When a trusted mute forces a blur (without a report threshold firing) the badge copy shortens to **"Muted by a trusted contact"** so we don't repeat the blur state in the label. Reports still surface as "Blurred · {reason}" until the viewer taps "Show anyway". - The moderation badge now renders as a dedicated yellow chip with an icon and "Show anyway" pill that matches the rest of the design system tokens. Override states flip to the neutral palette and swap the icon for a confirmation check so operators can tell the difference at a glance. - The badge tooltip and `aria-label` continue to list the specific trusted contacts who muted or reported the content when that metadata is available. -- Viewers who override a trusted mute hide keep a **Hide** pill on the same badge so they can re-hide the clip without hunting through another menu. +- Viewers who override a trusted mute hide keep a **Block** pill on the same badge so they can block the creator without hunting through another menu. +- Selecting **Block** immediately publishes an account block for the creator, clears any temporary overrides, and reloads the feed so the author disappears from subsequent fetches. - Trusted mute decisions now block autoplay even when report thresholds have not fired, and any associated avatars or channel banners adopt the blurred state alongside the primary thumbnail so the entire card reflects the moderation choice. ## Where to extend diff --git a/js/app.js b/js/app.js index 2e2175ea..66144973 100644 --- a/js/app.js +++ b/js/app.js @@ -1386,8 +1386,8 @@ class Application { this.videoListView.setModerationOverrideHandler((detail = {}) => this.handleModerationOverride(detail) ); - this.videoListView.setModerationHideHandler((detail = {}) => - this.handleModerationHide(detail) + this.videoListView.setModerationBlockHandler((detail = {}) => + this.handleModerationBlock(detail) ); if (this.moreMenuController) { @@ -3753,6 +3753,29 @@ class Application { } } + describeUserBlockActionError(error) { + const code = + error && typeof error.code === "string" ? error.code.trim().toLowerCase() : ""; + + switch (code) { + case "nip04-missing": + return "Your Nostr extension must support NIP-04 to manage private block lists."; + case "extension-permission-denied": + return "Permission to update your block list was denied by your Nostr extension."; + case "sign-event-missing": + case "signer-missing": + return "Connect a Nostr signer that can encrypt and sign updates before managing block lists."; + case "nostr-extension-missing": + return "Connect a Nostr extension before updating your block list."; + case "invalid": + return "Unable to block that account. Please try again."; + case "self": + return "You cannot block yourself."; + default: + return ""; + } + } + describeHashtagPreferencesError(error, options = {}) { const { operation = "update", fallbackMessage } = options && typeof options === "object" ? options : {}; @@ -7629,11 +7652,72 @@ class Application { return true; } - handleModerationHide({ video, card }) { + async handleModerationBlock({ video, card }) { if (!video || typeof video !== "object" || !video.id) { return false; } + if (!this.isUserLoggedIn()) { + this.showStatus("Log in to block accounts.", { showSpinner: false }); + return false; + } + + const viewerHex = this.normalizeHexPubkey(this.pubkey); + if (!viewerHex) { + this.showError("Select a profile before blocking accounts."); + return false; + } + + const targetHex = this.normalizeHexPubkey(video?.pubkey); + if (!targetHex) { + this.showError("Unable to determine which account to block."); + return false; + } + + if (viewerHex === targetHex) { + this.showError("You cannot block yourself."); + return false; + } + + try { + await userBlocks.ensureLoaded(viewerHex); + } catch (error) { + devLogger.warn( + "[Application] Failed to load block list before blocking:", + error, + ); + this.showError("Unable to load your block list. Please try again."); + return false; + } + + let alreadyBlocked = + typeof userBlocks.isBlocked === "function" && + userBlocks.isBlocked(targetHex); + let blockApplied = false; + + if (!alreadyBlocked) { + try { + const result = await userBlocks.addBlock(targetHex, viewerHex); + alreadyBlocked = true; + blockApplied = result?.already !== true; + } catch (error) { + const message = + (typeof this.describeUserBlockActionError === "function" + ? this.describeUserBlockActionError(error) + : "") || "Failed to block this creator. Please try again."; + this.showError(message); + devLogger.warn("[Application] Failed to block creator:", error); + return false; + } + } + + if (alreadyBlocked) { + const statusMessage = blockApplied + ? "Creator blocked. Their videos will disappear from your feed." + : "Creator already blocked. Their videos will disappear from your feed."; + this.showStatus(statusMessage, { showSpinner: false }); + } + try { clearModerationOverride(video.id); } catch (error) { @@ -7677,7 +7761,10 @@ class Application { try { card.refreshModerationUi(); } catch (error) { - devLogger.warn("[Application] Failed to refresh moderation UI after hide:", error); + devLogger.warn( + "[Application] Failed to refresh moderation UI after block:", + error, + ); } } @@ -7686,22 +7773,33 @@ class Application { (typeof document !== "undefined" ? document : null); if (doc && typeof doc.dispatchEvent === "function") { try { - doc.dispatchEvent( - new CustomEvent("video:moderation-hide", { - detail: { video: target }, - }), - ); + const detail = { video: target }; + doc.dispatchEvent(new CustomEvent("video:moderation-block", { detail })); + doc.dispatchEvent(new CustomEvent("video:moderation-hide", { detail })); } catch (eventError) { devLogger.warn( - "[Application] Failed to dispatch moderation hide event:", + "[Application] Failed to dispatch moderation block event:", eventError, ); } } + try { + await this.onVideosShouldRefresh({ reason: "user-block-update" }); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh videos after block:", + error, + ); + } + return true; } + handleModerationHide(detail) { + return this.handleModerationBlock(detail); + } + getVideoAddressPointer(video) { if ( this.discussionCountService && diff --git a/js/channelProfile.js b/js/channelProfile.js index 219e832d..5737950b 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -76,9 +76,9 @@ const channelModerationBadgeState = { iconSvg: null, iconWrapper: null, overrideButton: null, - hideButton: null, + blockButton: null, boundOverride: null, - boundHide: null, + boundBlock: null, badgeId: "", app: null, }; @@ -348,7 +348,7 @@ function getChannelModerationState(context) { return "blocked"; } -function shouldShowChannelHideButton(context) { +function shouldShowChannelBlockButton(context) { if (!context || !context.trustedMuted) { return false; } @@ -452,9 +452,9 @@ function ensureChannelModerationBadgeResources(doc) { handleChannelModerationOverride(event); } - if (typeof channelModerationBadgeState.boundHide !== "function") { - channelModerationBadgeState.boundHide = (event) => - handleChannelModerationHide(event); + if (typeof channelModerationBadgeState.boundBlock !== "function") { + channelModerationBadgeState.boundBlock = (event) => + handleChannelModerationBlock(event); } if (!channelModerationBadgeState.overrideButton) { @@ -467,14 +467,14 @@ function ensureChannelModerationBadgeResources(doc) { channelModerationBadgeState.overrideButton = button; } - if (!channelModerationBadgeState.hideButton) { - const hideButton = doc.createElement("button"); - hideButton.type = "button"; - hideButton.className = "moderation-badge__action flex-shrink-0"; - hideButton.dataset.moderationAction = "hide"; - hideButton.textContent = "Hide"; - hideButton.addEventListener("click", channelModerationBadgeState.boundHide); - channelModerationBadgeState.hideButton = hideButton; + if (!channelModerationBadgeState.blockButton) { + const blockButton = doc.createElement("button"); + blockButton.type = "button"; + blockButton.className = "moderation-badge__action flex-shrink-0"; + blockButton.dataset.moderationAction = "block"; + blockButton.textContent = "Block"; + blockButton.addEventListener("click", channelModerationBadgeState.boundBlock); + channelModerationBadgeState.blockButton = blockButton; } return channelModerationBadgeState; @@ -571,14 +571,14 @@ function renderChannelModerationBadge({ badge.removeChild(ref.overrideButton); } - if (hasVideoTarget && shouldShowChannelHideButton(normalizedContext) && ref.hideButton) { - if (ref.hideButton.parentNode !== badge) { - badge.appendChild(ref.hideButton); + if (hasVideoTarget && shouldShowChannelBlockButton(normalizedContext) && ref.blockButton) { + if (ref.blockButton.parentNode !== badge) { + badge.appendChild(ref.blockButton); } - ref.hideButton.disabled = false; - ref.hideButton.removeAttribute("aria-busy"); - } else if (ref.hideButton && ref.hideButton.parentNode === badge) { - badge.removeChild(ref.hideButton); + ref.blockButton.disabled = false; + ref.blockButton.removeAttribute("aria-busy"); + } else if (ref.blockButton && ref.blockButton.parentNode === badge) { + badge.removeChild(ref.blockButton); } if (ref.overrideButton) { @@ -589,11 +589,11 @@ function renderChannelModerationBadge({ } } - if (ref.hideButton) { + if (ref.blockButton) { if (badge.id) { - ref.hideButton.setAttribute("aria-describedby", badge.id); + ref.blockButton.setAttribute("aria-describedby", badge.id); } else { - ref.hideButton.removeAttribute("aria-describedby"); + ref.blockButton.removeAttribute("aria-describedby"); } } @@ -643,7 +643,7 @@ function handleChannelModerationOverride(event) { } } -function handleChannelModerationHide(event) { +function handleChannelModerationBlock(event) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); @@ -664,7 +664,7 @@ function handleChannelModerationHide(event) { return; } - const button = channelModerationBadgeState.hideButton; + const button = channelModerationBadgeState.blockButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); @@ -672,9 +672,9 @@ function handleChannelModerationHide(event) { let handled = false; try { - handled = app?.handleModerationHide?.({ video: targetVideo }) === true; + handled = app?.handleModerationBlock?.({ video: targetVideo }) === true; } catch (error) { - devLogger.warn("[ChannelProfile] Failed to handle channel moderation hide", error); + devLogger.warn("[ChannelProfile] Failed to handle channel moderation block", error); } if (handled) { @@ -899,7 +899,7 @@ function ensureChannelModerationEvents() { if (typeof document !== "undefined") { document.addEventListener("video:moderation-override", handleVideoModerationEvent); - document.addEventListener("video:moderation-hide", handleVideoModerationEvent); + document.addEventListener("video:moderation-block", handleVideoModerationEvent); } if (moderationService && typeof moderationService.on === "function") { @@ -4215,11 +4215,11 @@ export async function renderChannelVideosFromList({ }) : false; - videoCard.onModerationHide = ({ video: hideVideo, card: hideCard }) => - typeof app?.handleModerationHide === "function" - ? app.handleModerationHide({ - video: hideVideo, - card: hideCard, + videoCard.onModerationBlock = ({ video: blockVideo, card: blockCard }) => + typeof app?.handleModerationBlock === "function" + ? app.handleModerationBlock({ + video: blockVideo, + card: blockCard, }) : false; diff --git a/js/historyView.js b/js/historyView.js index ab99e8ba..4333df05 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -388,7 +388,7 @@ function getHistoryCardModerationState(context) { return "blocked"; } -function shouldShowHistoryCardHideButton(context) { +function shouldShowHistoryCardBlockButton(context) { if (!context || !context.trustedMuted) { return false; } @@ -466,8 +466,8 @@ function cleanupHistoryCard(pointerKey) { if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") { ref.overrideButton.removeEventListener("click", ref.boundOverride); } - if (ref.hideButton && typeof ref.hideButton.removeEventListener === "function" && typeof ref.boundHide === "function") { - ref.hideButton.removeEventListener("click", ref.boundHide); + if (ref.blockButton && typeof ref.blockButton.removeEventListener === "function" && typeof ref.boundBlock === "function") { + ref.blockButton.removeEventListener("click", ref.boundBlock); } if (ref.badgeEl && ref.badgeEl.parentNode) { ref.badgeEl.parentNode.removeChild(ref.badgeEl); @@ -587,8 +587,8 @@ function createHistoryCardBadge(ref, context) { if (typeof ref.boundOverride !== "function") { ref.boundOverride = (event) => handleHistoryCardModerationOverride(event, ref); } - if (typeof ref.boundHide !== "function") { - ref.boundHide = (event) => handleHistoryCardModerationHide(event, ref); + if (typeof ref.boundBlock !== "function") { + ref.boundBlock = (event) => handleHistoryCardModerationBlock(event, ref); } if (!ref.overrideButton) { const overrideButton = doc.createElement("button"); @@ -599,14 +599,14 @@ function createHistoryCardBadge(ref, context) { overrideButton.addEventListener("click", ref.boundOverride); ref.overrideButton = overrideButton; } - if (!ref.hideButton) { - const hideButton = doc.createElement("button"); - hideButton.type = "button"; - hideButton.className = "moderation-badge__action flex-shrink-0"; - hideButton.dataset.moderationAction = "hide"; - hideButton.textContent = "Hide"; - hideButton.addEventListener("click", ref.boundHide); - ref.hideButton = hideButton; + if (!ref.blockButton) { + const blockButton = doc.createElement("button"); + blockButton.type = "button"; + blockButton.className = "moderation-badge__action flex-shrink-0"; + blockButton.dataset.moderationAction = "block"; + blockButton.textContent = "Block"; + blockButton.addEventListener("click", ref.boundBlock); + ref.blockButton = blockButton; } return badge; } @@ -617,8 +617,8 @@ function updateHistoryCardBadge(ref, context) { if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") { ref.overrideButton.removeEventListener("click", ref.boundOverride); } - if (ref.hideButton && typeof ref.hideButton.removeEventListener === "function" && typeof ref.boundHide === "function") { - ref.hideButton.removeEventListener("click", ref.boundHide); + if (ref.blockButton && typeof ref.blockButton.removeEventListener === "function" && typeof ref.boundBlock === "function") { + ref.blockButton.removeEventListener("click", ref.boundBlock); } if (ref.badgeEl && ref.badgeEl.parentNode) { ref.badgeEl.parentNode.removeChild(ref.badgeEl); @@ -629,7 +629,7 @@ function updateHistoryCardBadge(ref, context) { ref.badgeIconSvg = null; ref.badgeIconWrapper = null; ref.overrideButton = null; - ref.hideButton = null; + ref.blockButton = null; return; } @@ -723,14 +723,14 @@ function updateHistoryCardBadge(ref, context) { badge.removeChild(ref.overrideButton); } - if (shouldShowHistoryCardHideButton(context) && ref.hideButton) { - if (ref.hideButton.parentNode !== badge) { - badge.appendChild(ref.hideButton); + if (shouldShowHistoryCardBlockButton(context) && ref.blockButton) { + if (ref.blockButton.parentNode !== badge) { + badge.appendChild(ref.blockButton); } - ref.hideButton.disabled = false; - ref.hideButton.removeAttribute("aria-busy"); - } else if (ref.hideButton && ref.hideButton.parentNode === badge) { - badge.removeChild(ref.hideButton); + ref.blockButton.disabled = false; + ref.blockButton.removeAttribute("aria-busy"); + } else if (ref.blockButton && ref.blockButton.parentNode === badge) { + badge.removeChild(ref.blockButton); } } @@ -782,11 +782,11 @@ function updateHistoryCardAria(ref) { } } - if (ref.hideButton) { + if (ref.blockButton) { if (badgeId) { - ref.hideButton.setAttribute("aria-describedby", badgeId); + ref.blockButton.setAttribute("aria-describedby", badgeId); } else { - ref.hideButton.removeAttribute("aria-describedby"); + ref.blockButton.removeAttribute("aria-describedby"); } } @@ -889,7 +889,7 @@ function handleHistoryCardModerationOverride(event, ref) { }); } -function handleHistoryCardModerationHide(event, ref) { +function handleHistoryCardModerationBlock(event, ref) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); @@ -899,7 +899,7 @@ function handleHistoryCardModerationHide(event, ref) { } } - const button = ref.hideButton; + const button = ref.blockButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); @@ -917,12 +917,15 @@ function handleHistoryCardModerationHide(event, ref) { const app = getAppInstance(); let result; try { - if (typeof app?.handleModerationHide === "function") { - result = app.handleModerationHide({ video }); + if (typeof app?.handleModerationBlock === "function") { + result = app.handleModerationBlock({ video }); } else { const doc = (ref.article && ref.article.ownerDocument) || (typeof document !== "undefined" ? document : null); if (doc && typeof doc.dispatchEvent === "function") { doc.dispatchEvent( + new CustomEvent("video:moderation-block", { detail: { video } }) + ); + document.dispatchEvent( new CustomEvent("video:moderation-hide", { detail: { video } }) ); } @@ -934,7 +937,7 @@ function handleHistoryCardModerationHide(event, ref) { button.removeAttribute("aria-busy"); } if (isDevEnv) { - userLogger.warn("[historyView] Moderation hide handler threw:", error); + userLogger.warn("[historyView] Moderation block handler threw:", error); } return; } @@ -953,7 +956,7 @@ function handleHistoryCardModerationHide(event, ref) { }) .catch((error) => { if (isDevEnv) { - userLogger.warn("[historyView] Moderation hide failed:", error); + userLogger.warn("[historyView] Moderation block failed:", error); } if (button) { button.disabled = false; @@ -1228,10 +1231,10 @@ export function buildHistoryCard({ item, video, profile, metadataPreference }) { badgeIconSvg: null, badgeIconWrapper: null, overrideButton: null, - hideButton: null, + blockButton: null, badgeId: "", boundOverride: null, - boundHide: null + boundBlock: null }; registerHistoryCard(item.pointerKey, cardRef); @@ -1738,10 +1741,10 @@ export function createWatchHistoryRenderer(config = {}) { handleModerationUpdate(video); }; document.addEventListener("video:moderation-override", handler); - document.addEventListener("video:moderation-hide", handler); + document.addEventListener("video:moderation-block", handler); subscriptions.add(() => { document.removeEventListener("video:moderation-override", handler); - document.removeEventListener("video:moderation-hide", handler); + document.removeEventListener("video:moderation-block", handler); }); } diff --git a/js/subscriptions.js b/js/subscriptions.js index f089d389..544da1f8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1254,11 +1254,11 @@ class SubscriptionsManager { return app.handleModerationOverride(buildModerationPayload(detail)); }); - listView.setModerationHideHandler((detail = {}) => { - if (typeof app?.handleModerationHide !== "function") { + listView.setModerationBlockHandler((detail = {}) => { + if (typeof app?.handleModerationBlock !== "function") { return false; } - return app.handleModerationHide(buildModerationPayload(detail)); + return app.handleModerationBlock(buildModerationPayload(detail)); }); listView.addEventListener("video:share", (event) => { diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 138fc5ea..8711a71a 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -119,7 +119,7 @@ export class VideoCard { onRequestSettingsMenu: null, onCloseSettingsMenu: null, onModerationOverride: null, - onModerationHide: null, + onModerationBlock: null, }; this.moderationBadgeEl = null; @@ -128,13 +128,14 @@ export class VideoCard { this.moderationBadgeIconWrapper = null; this.moderationBadgeIconSvg = null; this.moderationActionButton = null; - this.moderationHideButton = null; + this.moderationBlockButton = null; this.moderationBadgeId = ""; this.badgesContainerEl = null; this.moderationBadgeSlot = null; this.hiddenSummaryEl = null; this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event); - this.boundModerationHideHandler = (event) => this.handleModerationHideClick(event); + this.boundModerationBlockHandler = (event) => + this.handleModerationBlockClick(event); this.root = null; this.anchorEl = null; @@ -210,8 +211,9 @@ export class VideoCard { this.callbacks.onModerationOverride = typeof fn === "function" ? fn : null; } - set onModerationHide(fn) { - this.callbacks.onModerationHide = typeof fn === "function" ? fn : null; + set onModerationBlock(fn) { + this.callbacks.onModerationBlock = + typeof fn === "function" ? fn : null; } set onMoreAction(fn) { @@ -1002,21 +1004,21 @@ export class VideoCard { return button; } - createModerationHideButton() { + createModerationBlockButton() { const button = this.createElement("button", { classNames: ["moderation-badge__action", "flex-shrink-0"], attrs: { type: "button", - "data-moderation-action": "hide", + "data-moderation-action": "block", "aria-describedby": this.getModerationBadgeId(), }, - textContent: "Hide", + textContent: "Block", }); - button.addEventListener("click", this.boundModerationHideHandler); + button.addEventListener("click", this.boundModerationBlockHandler); return button; } - handleModerationHideClick(event) { + handleModerationBlockClick(event) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); @@ -1026,13 +1028,13 @@ export class VideoCard { } } - const button = this.moderationHideButton; + const button = this.moderationBlockButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } - if (!this.callbacks.onModerationHide) { + if (!this.callbacks.onModerationBlock) { if (button) { button.disabled = false; button.removeAttribute("aria-busy"); @@ -1042,13 +1044,13 @@ export class VideoCard { let result; try { - result = this.callbacks.onModerationHide({ + result = this.callbacks.onModerationBlock({ event, video: this.video, card: this, }); } catch (error) { - userLogger.warn("[VideoCard] onModerationHide callback threw", error); + userLogger.warn("[VideoCard] onModerationBlock callback threw", error); if (button) { button.disabled = false; button.removeAttribute("aria-busy"); @@ -1068,7 +1070,7 @@ export class VideoCard { this.refreshModerationUi(); }) .catch((error) => { - userLogger.warn("[VideoCard] Moderation hide failed", error); + userLogger.warn("[VideoCard] Moderation block failed", error); if (button) { button.disabled = false; button.removeAttribute("aria-busy"); @@ -1201,7 +1203,7 @@ export class VideoCard { return wrapper; } - shouldShowModerationHideButton(context = this.getModerationContext()) { + shouldShowModerationBlockButton(context = this.getModerationContext()) { if (!context || !context.trustedMuted) { return false; } @@ -1225,13 +1227,13 @@ export class VideoCard { ); } this.moderationActionButton = null; - if (this.moderationHideButton) { - this.moderationHideButton.removeEventListener( + if (this.moderationBlockButton) { + this.moderationBlockButton.removeEventListener( "click", - this.boundModerationHideHandler, + this.boundModerationBlockHandler, ); } - this.moderationHideButton = null; + this.moderationBlockButton = null; return null; } @@ -1331,22 +1333,22 @@ export class VideoCard { this.moderationActionButton = null; } - if (this.shouldShowModerationHideButton(context)) { - if (!this.moderationHideButton) { - const hideButton = this.createModerationHideButton(); - badge.appendChild(hideButton); - this.moderationHideButton = hideButton; + if (this.shouldShowModerationBlockButton(context)) { + if (!this.moderationBlockButton) { + const blockButton = this.createModerationBlockButton(); + badge.appendChild(blockButton); + this.moderationBlockButton = blockButton; } else { - this.moderationHideButton.disabled = false; - this.moderationHideButton.removeAttribute("aria-busy"); + this.moderationBlockButton.disabled = false; + this.moderationBlockButton.removeAttribute("aria-busy"); } - } else if (this.moderationHideButton) { - this.moderationHideButton.removeEventListener( + } else if (this.moderationBlockButton) { + this.moderationBlockButton.removeEventListener( "click", - this.boundModerationHideHandler, + this.boundModerationBlockHandler, ); - this.moderationHideButton.remove(); - this.moderationHideButton = null; + this.moderationBlockButton.remove(); + this.moderationBlockButton = null; } return badge; @@ -1412,12 +1414,12 @@ export class VideoCard { this.boundShowAnywayHandler, ); } - if (this.moderationHideButton) { - this.moderationHideButton.removeEventListener( + if (this.moderationBlockButton) { + this.moderationBlockButton.removeEventListener( "click", - this.boundModerationHideHandler, + this.boundModerationBlockHandler, ); - this.moderationHideButton = null; + this.moderationBlockButton = null; } this.moderationBadgeEl = null; this.moderationBadgeLabelEl = null; @@ -1531,22 +1533,22 @@ export class VideoCard { this.moderationActionButton.removeAttribute("aria-busy"); } - if (this.shouldShowModerationHideButton(context)) { - if (!this.moderationHideButton) { - const hideButton = this.createModerationHideButton(); - badge.appendChild(hideButton); - this.moderationHideButton = hideButton; + if (this.shouldShowModerationBlockButton(context)) { + if (!this.moderationBlockButton) { + const blockButton = this.createModerationBlockButton(); + badge.appendChild(blockButton); + this.moderationBlockButton = blockButton; } else { - this.moderationHideButton.disabled = false; - this.moderationHideButton.removeAttribute("aria-busy"); + this.moderationBlockButton.disabled = false; + this.moderationBlockButton.removeAttribute("aria-busy"); } - } else if (this.moderationHideButton) { - this.moderationHideButton.removeEventListener( + } else if (this.moderationBlockButton) { + this.moderationBlockButton.removeEventListener( "click", - this.boundModerationHideHandler, + this.boundModerationBlockHandler, ); - this.moderationHideButton.remove(); - this.moderationHideButton = null; + this.moderationBlockButton.remove(); + this.moderationBlockButton = null; } } diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 3678f580..015e276f 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -256,8 +256,10 @@ export class VideoModal { this.handleModerationOverrideClick.bind(this); this.handleGlobalModerationOverride = this.handleGlobalModerationOverride.bind(this); - this.handleGlobalModerationHide = - this.handleGlobalModerationHide.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"; @@ -270,8 +272,8 @@ export class VideoModal { this.handleGlobalModerationOverride, ); this.document.addEventListener( - "video:moderation-hide", - this.handleGlobalModerationHide, + "video:moderation-block", + this.handleGlobalModerationBlock, ); } } @@ -2081,8 +2083,8 @@ export class VideoModal { this.handleGlobalModerationOverride, ); this.document.removeEventListener( - "video:moderation-hide", - this.handleGlobalModerationHide, + "video:moderation-block", + this.handleGlobalModerationBlock, ); } @@ -4094,7 +4096,7 @@ export class VideoModal { this.refreshActiveVideoModeration({ video: this.activeVideo }); } - handleGlobalModerationHide(event) { + 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) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 3a682874..25a94bed 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -189,7 +189,7 @@ export class VideoListView { delete: null, blacklist: null, moderationOverride: null, - moderationHide: null, + moderationBlock: null, tagActivate: null, }; @@ -299,8 +299,9 @@ export class VideoListView { this.handlers.moderationOverride = typeof handler === "function" ? handler : null; } - setModerationHideHandler(handler) { - this.handlers.moderationHide = typeof handler === "function" ? handler : null; + setModerationBlockHandler(handler) { + this.handlers.moderationBlock = + typeof handler === "function" ? handler : null; } setTagActivationHandler(handler) { @@ -606,13 +607,14 @@ export class VideoListView { }); }; - videoCard.onModerationHide = ({ event: hideEvent }) => { - if (!this.handlers.moderationHide) { + videoCard.onModerationBlock = ({ event: blockEvent }) => { + if (!this.handlers.moderationBlock) { return false; } - const trigger = hideEvent?.currentTarget || hideEvent?.target || null; - return this.handlers.moderationHide({ - event: hideEvent, + const trigger = + blockEvent?.currentTarget || blockEvent?.target || null; + return this.handlers.moderationBlock({ + event: blockEvent, video, card: videoCard, trigger, diff --git a/tests/app/moderation-overrides.test.mjs b/tests/app/moderation-overrides.test.mjs index 2b33ce5d..30fcec5f 100644 --- a/tests/app/moderation-overrides.test.mjs +++ b/tests/app/moderation-overrides.test.mjs @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { mock } from "node:test"; import assert from "node:assert/strict"; import "../test-helpers/setup-localstorage.mjs"; @@ -6,6 +6,7 @@ import { withMockedNostrTools, createModerationAppHarness, } from "../helpers/moderation-test-helpers.mjs"; +import { userBlocks } from "../../js/userBlocks.js"; const REPORTER_HEX = "b".repeat(64); const MUTER_HEX = "c".repeat(64); @@ -108,7 +109,7 @@ test("handleModerationOverride resumes deferred playback", async (t) => { }); }); -test("handleModerationHide clears overrides and re-applies hidden state", async (t) => { +test("handleModerationBlock requests a block, clears overrides, and refreshes hidden state", async (t) => { withMockedNostrTools(t); const app = await createModerationAppHarness(); @@ -118,10 +119,19 @@ test("handleModerationHide clears overrides and re-applies hidden state", async trustedMuteHideThreshold: 1, trustedSpamHideThreshold: Number.POSITIVE_INFINITY, }); + app.pubkey = "f".repeat(64); + app.isUserLoggedIn = () => true; + app.showStatus = mock.fn(() => {}); + app.showError = mock.fn(() => {}); + app.onVideosShouldRefresh = mock.fn(async () => {}); + + const viewerHex = app.normalizeHexPubkey(app.pubkey); + const authorHex = "9".repeat(64); const videoId = "b".repeat(64); const incomingVideo = { id: videoId, + pubkey: authorHex, moderation: { trustedMuted: true, trustedMuteCount: 1, @@ -133,6 +143,7 @@ test("handleModerationHide clears overrides and re-applies hidden state", async const storedVideo = { id: videoId, + pubkey: authorHex, moderation: { trustedMuted: true, trustedMuteCount: 1, @@ -144,6 +155,7 @@ test("handleModerationHide clears overrides and re-applies hidden state", async const currentVideo = { id: videoId, + pubkey: authorHex, moderation: { trustedMuted: true, trustedMuteCount: 1, @@ -160,20 +172,56 @@ test("handleModerationHide clears overrides and re-applies hidden state", async app.decorateVideoModeration(storedVideo); app.decorateVideoModeration(currentVideo); + let refreshCount = 0; const card = { refreshModerationUi() { - /* noop */ + refreshCount += 1; }, }; - app.handleModerationOverride({ video: incomingVideo, card }); + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalAddBlock = userBlocks.addBlock; + const originalIsBlocked = userBlocks.isBlocked; + + const ensureLoadedMock = mock.fn(async () => {}); + const addBlockMock = mock.fn(async () => ({ ok: true })); + const isBlockedMock = mock.fn(() => false); + + userBlocks.ensureLoaded = ensureLoadedMock; + userBlocks.addBlock = addBlockMock; + userBlocks.isBlocked = isBlockedMock; + + t.after(() => { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.addBlock = originalAddBlock; + userBlocks.isBlocked = originalIsBlocked; + }); + + await app.handleModerationOverride({ video: incomingVideo, card }); assert.equal(storedVideo.moderation.viewerOverride?.showAnyway, true); assert.equal(currentVideo.moderation.viewerOverride?.showAnyway, true); - const result = app.handleModerationHide({ video: incomingVideo, card }); + refreshCount = 0; + + const result = await app.handleModerationBlock({ video: incomingVideo, card }); assert.equal(result, true); + assert.ok(addBlockMock.mock.calls.length >= 1); + assert.deepEqual(addBlockMock.mock.calls[0]?.arguments, [authorHex, viewerHex]); + assert.equal(ensureLoadedMock.mock.calls.length, 1); + assert.deepEqual(ensureLoadedMock.mock.calls[0]?.arguments, [viewerHex]); + assert.ok(isBlockedMock.mock.calls.length >= 1); + assert.equal(refreshCount, 1); + assert.equal( + app.onVideosShouldRefresh.mock.calls[0]?.arguments?.[0]?.reason, + "user-block-update", + ); + assert.equal( + app.showStatus.mock.calls[0]?.arguments?.[0], + "Creator blocked. Their videos will disappear from your feed.", + ); + assert.equal(incomingVideo.moderation.viewerOverride, undefined); assert.equal(storedVideo.moderation.viewerOverride, undefined); assert.equal(currentVideo.moderation.viewerOverride, undefined); @@ -181,3 +229,48 @@ test("handleModerationHide clears overrides and re-applies hidden state", async assert.equal(storedVideo.moderation.hidden, true); assert.equal(currentVideo.moderation.hidden, true); }); + +test("handleModerationBlock returns false when viewer is logged out", async (t) => { + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + app.pubkey = "f".repeat(64); + app.isUserLoggedIn = () => false; + app.showStatus = mock.fn(() => {}); + app.showError = mock.fn(() => {}); + + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalAddBlock = userBlocks.addBlock; + const originalIsBlocked = userBlocks.isBlocked; + + const ensureLoadedMock = mock.fn(async () => {}); + const addBlockMock = mock.fn(async () => ({ ok: true })); + const isBlockedMock = mock.fn(() => false); + + userBlocks.ensureLoaded = ensureLoadedMock; + userBlocks.addBlock = addBlockMock; + userBlocks.isBlocked = isBlockedMock; + + t.after(() => { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.addBlock = originalAddBlock; + userBlocks.isBlocked = originalIsBlocked; + }); + + const video = { + id: "c".repeat(64), + pubkey: "d".repeat(64), + moderation: { trustedMuted: true }, + }; + + const result = await app.handleModerationBlock({ video }); + + assert.equal(result, false); + assert.equal(addBlockMock.mock.calls.length, 0); + assert.equal(ensureLoadedMock.mock.calls.length, 0); + assert.equal(isBlockedMock.mock.calls.length, 0); + assert.equal( + app.showStatus.mock.calls[0]?.arguments?.[0], + "Log in to block accounts.", + ); +}); diff --git a/tests/helpers/moderation-test-helpers.mjs b/tests/helpers/moderation-test-helpers.mjs index c05d1610..57bfce13 100644 --- a/tests/helpers/moderation-test-helpers.mjs +++ b/tests/helpers/moderation-test-helpers.mjs @@ -197,6 +197,7 @@ export async function createModerationAppHarness() { app.deriveModerationTrustedCount = Application.prototype.deriveModerationTrustedCount; app.getReporterDisplayName = Application.prototype.getReporterDisplayName; app.handleModerationOverride = Application.prototype.handleModerationOverride; + app.handleModerationBlock = Application.prototype.handleModerationBlock; app.handleModerationHide = Application.prototype.handleModerationHide; app.videosMap = new Map(); diff --git a/tests/moderation/video-card.test.mjs b/tests/moderation/video-card.test.mjs index e8172b08..a5875d95 100644 --- a/tests/moderation/video-card.test.mjs +++ b/tests/moderation/video-card.test.mjs @@ -13,6 +13,7 @@ import { applyModerationContextDatasets, normalizeVideoModerationContext, } from "../../js/ui/moderationUiHelpers.js"; +import { userBlocks } from "../../js/userBlocks.js"; function setupDom(t) { const dom = new JSDOM("", { @@ -327,14 +328,15 @@ test("VideoCard blurs thumbnails when trusted mute triggers without reports", as card.moderationBadgeTextEl.textContent, "Autoplay blocked · Muted by a trusted contact", ); - assert.ok(card.moderationHideButton); + assert.ok(card.moderationBlockButton); assert.equal( - card.moderationHideButton?.dataset?.moderationAction, - "hide", + card.moderationBlockButton?.dataset?.moderationAction, + "block", ); + assert.equal(card.moderationBlockButton.textContent, "Block"); }); -test("VideoCard hide action restores trusted mute hide state after override", async (t) => { +test("VideoCard block action restores trusted mute hide state after override", async (t) => { const { document } = setupDom(t); withMockedNostrTools(t); @@ -345,6 +347,25 @@ test("VideoCard hide action restores trusted mute hide state after override", as trustedMuteHideThreshold: 1, trustedSpamHideThreshold: Number.POSITIVE_INFINITY, }); + app.pubkey = "f".repeat(64); + app.isUserLoggedIn = () => true; + app.showStatus = () => {}; + app.showError = () => {}; + app.onVideosShouldRefresh = async () => {}; + + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalAddBlock = userBlocks.addBlock; + const originalIsBlocked = userBlocks.isBlocked; + + userBlocks.ensureLoaded = async () => {}; + userBlocks.addBlock = async () => ({ ok: true }); + userBlocks.isBlocked = () => false; + + t.after(() => { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.addBlock = originalAddBlock; + userBlocks.isBlocked = originalIsBlocked; + }); const videoId = "d".repeat(64); const video = { @@ -394,19 +415,19 @@ test("VideoCard hide action restores trusted mute hide state after override", as ); assert.equal(contextAfterOverride.overrideActive, true); assert.equal(contextAfterOverride.activeHidden, false); - assert.ok(card.moderationHideButton); + assert.ok(card.moderationBlockButton); - card.onModerationHide = ({ video: targetVideo, card: targetCard }) => - app.handleModerationHide({ video: targetVideo, card: targetCard }); + card.onModerationBlock = ({ video: targetVideo, card: targetCard }) => + app.handleModerationBlock({ video: targetVideo, card: targetCard }); - card.moderationHideButton.click(); + card.moderationBlockButton.click(); await new Promise((resolve) => setTimeout(resolve, 0)); const contextAfterHide = normalizeVideoModerationContext(card.video?.moderation); assert.equal(contextAfterHide.overrideActive, false); assert.equal(contextAfterHide.activeHidden, true); assert.equal(card.getRoot().dataset.moderationHidden, "true"); - assert.equal(card.moderationHideButton, null); + assert.equal(card.moderationBlockButton, null); }); test("applyModerationContextDatasets clears blur when overrides are active", (t) => { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 52868a7f..747ddf50 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -698,7 +698,7 @@ test( const manager = new SubscriptionsManager(); const overrideCalls = []; - const hideCalls = []; + const blockCalls = []; const app = { videosMap: new Map(), @@ -706,8 +706,8 @@ test( overrideCalls.push(detail); return true; }, - handleModerationHide(detail) { - hideCalls.push(detail); + handleModerationBlock(detail) { + blockCalls.push(detail); return true; }, ensureGlobalMoreMenuHandlers() {}, @@ -761,26 +761,26 @@ test( ); assert.equal(overrideDetail.trigger, overrideButton); - const hideButton = container.querySelector('[data-moderation-action="hide"]'); - assert.ok(hideButton, "hide button should render for moderated video"); + const blockButton = container.querySelector('[data-moderation-action="block"]'); + assert.ok(blockButton, "block button should render for moderated video"); - hideButton.click(); + blockButton.click(); await Promise.resolve(); assert.equal( - hideCalls.length, + blockCalls.length, 1, - "app.handleModerationHide should receive one call", + "app.handleModerationBlock should receive one call", ); - const hideDetail = hideCalls[0]; - assert.equal(hideDetail.video, video); - assert.equal(hideDetail.card?.video, video); + const blockDetail = blockCalls[0]; + assert.equal(blockDetail.video, video); + assert.equal(blockDetail.card?.video, video); assert.equal( - hideDetail.context, + blockDetail.context, "subscriptions", - "hide payload should include the subscriptions context", + "block payload should include the subscriptions context", ); - assert.equal(hideDetail.trigger, hideButton); + assert.equal(blockDetail.trigger, blockButton); if (manager.subscriptionListView?.destroy) { manager.subscriptionListView.destroy(); diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs index 6c401e1b..dfd18df5 100644 --- a/tests/ui/video-list-view-popular-tags.test.mjs +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -108,14 +108,14 @@ test("VideoListView hides popular tags when no tags are available", () => { assert.equal(tagsRoot.childElementCount, 0); }); -test("VideoListView setModerationHideHandler stores callable", () => { +test("VideoListView setModerationBlockHandler stores callable", () => { const { document } = createViewDom(); const view = new VideoListView({ document }); const handler = () => {}; - view.setModerationHideHandler(handler); - assert.equal(view.handlers.moderationHide, handler); + view.setModerationBlockHandler(handler); + assert.equal(view.handlers.moderationBlock, handler); - view.setModerationHideHandler(null); - assert.equal(view.handlers.moderationHide, null); + view.setModerationBlockHandler(null); + assert.equal(view.handlers.moderationBlock, null); }); diff --git a/tests/video-modal-moderation.test.mjs b/tests/video-modal-moderation.test.mjs index 57b788b5..968fa5b8 100644 --- a/tests/video-modal-moderation.test.mjs +++ b/tests/video-modal-moderation.test.mjs @@ -86,7 +86,7 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a delete video.moderation.viewerOverride; video.moderation.blurThumbnail = true; - const hideSignal = new window.CustomEvent("video:moderation-hide", { + const hideSignal = new window.CustomEvent("video:moderation-block", { detail: { video }, }); document.dispatchEvent(hideSignal); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 9ca19358..7c3a05bf 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1918,6 +1918,9 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { overrideCalls.push(video); return true; }, + handleModerationBlock() { + return true; + }, handleModerationHide() { return true; },