Add restore-default moderation control

This commit is contained in:
thePR0M3TH3AN committed 2025-10-26 11:13:37 -04:00
1 parent 78d5dc42d5
commit 065dafff88
10 files changed
+372 -14

No files matched your search

+1
View File
@@ -105,6 +105,7 @@
class="moderation-badge__action flex-shrink-0"
data-moderation-action="override"
aria-pressed="false"
aria-label="Show anyway"
>
Show anyway
</button>
+3 -3
View File
@@ -32,7 +32,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
- Every moderation payload now includes a `blurReason` that records why a thumbnail was blurred (`trusted-report`, `trusted-mute`, or a hide reason such as `trusted-mute-hide`). This travels with both the metadata stage output and the decorated `video.moderation.original` snapshot so UI can explain the decision even after a viewer override.
- 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 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, expose a "Restore default moderation" pill, 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 **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.
@@ -44,7 +44,7 @@ Thread new moderation behaviors through the same service → stage → app → U
## Defaults (policy)
- Blur video thumbnails when trusted `nudity` reports meet `DEFAULT_BLUR_THRESHOLD`.
- Disable autoplay preview when the trusted `nudity` report count reaches `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`.
- Hide videos when `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` trusted contacts mute the author. Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · {count} trusted mute(s)`, and a "Show anyway" button becomes available.
- Hide videos when `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` trusted contacts mute the author. Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · {count} trusted mute(s)`, and a "Show anyway" button becomes available alongside a "Restore default moderation" control once the override is active.
- Hide videos when `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` trusted contacts file spam reports. The badge copy escalates to `Hidden · {count} trusted spam report(s)` and the card stays hidden until the viewer overrides it.
- Downrank author when any F1 has them in mute list (10000).
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content, but whitelist entries no longer bypass moderation gates—they only influence Discovery rankings when a viewer opts into that list.
@@ -59,7 +59,7 @@ Thread new moderation behaviors through the same service → stage → app → U
- `TRUSTED_MUTE_HIDE_THRESHOLD` and `TRUSTED_SPAM_HIDE_THRESHOLD` determine when moderation metadata marks an item as hidden. Both feed the `original.hide*` fields that `VideoCard` inspects before deciding whether to render the hidden summary container.
- `FEATURE_TRUSTED_HIDE_CONTROLS` shows the additional threshold inputs in the profile modal so operators can raise/lower the hide triggers client-side.
- When any hide threshold fires we persist the active counts (`hideCounts.trustedMuteCount`, `hideCounts.trustedReportCount`) so analytics/debugging tools can show why an item disappeared.
- Cards that arrive with `original.hidden === true` automatically expose the "Show anyway" chip so viewers can inspect hidden content. Activating the override clears blur/autoplay restrictions alongside the hidden state.
- Cards that arrive with `original.hidden === true` automatically expose the "Show anyway" chip so viewers can inspect hidden content. Activating the override clears blur/autoplay restrictions alongside the hidden state and replaces the CTA with "Restore default moderation" for easy rollback.
## Community blacklist federation
+2 -2
View File
@@ -8,7 +8,7 @@
- [ ] Hide videos once trusted mute/spam thresholds are met.
- Automated coverage: `tests/visual/moderation.spec.ts` trusted hide scenarios (run via `npm run test:visual`).
- Manual: adjust thresholds in the profile modal, simulate trusted mutes/reports, and verify cards disappear until "show anyway" is used.
- [ ] “Show anyway” works for blurred and hidden cards and records only local pref.
- [ ] “Show anyway” works for blurred and hidden cards, surfaces a “Restore default moderation” control, and records only local pref.
- Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence and trusted hide scenarios (run via `npm run test:visual`).
- [ ] Mute list (10000) downranks/hides consistently.
- Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh.
@@ -56,7 +56,7 @@ Expected:
}
```
Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 1) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content.
Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`, sourced from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); upstream example: 1) is met, cards render with "Hidden · {count} trusted mute(s)" and require a "Show anyway" override to display content plus a "Restore default moderation" control to revert.
**Exercises:** [`ModerationService.ingestTrustedMuteEvent()`](../../js/services/moderationService.js) updates [`ModerationService.isAuthorMutedByTrusted()`](../../js/services/moderationService.js) / [`getTrustedMutersForAuthor()`](../../js/services/moderationService.js), which [`createModerationStage()`](../../js/feedEngine/stages.js) propagates to the video item before [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js) renders the mute state.
+104 -1
View File
@@ -25,6 +25,7 @@ import {
} from "./constants.js";
import {
applyModerationContextDatasets,
getModerationOverrideActionLabels,
normalizeVideoModerationContext,
} from "./ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "./ui/moderationCopy.js";
@@ -76,8 +77,10 @@ const channelModerationBadgeState = {
iconSvg: null,
iconWrapper: null,
overrideButton: null,
hideButton: null,
blockButton: null,
boundOverride: null,
boundHide: null,
boundBlock: null,
badgeId: "",
app: null,
@@ -464,15 +467,36 @@ function ensureChannelModerationBadgeResources(doc) {
}
if (!channelModerationBadgeState.overrideButton) {
const { text: overrideLabel, ariaLabel: overrideAria } =
getModerationOverrideActionLabels({ overrideActive: false });
const button = doc.createElement("button");
button.type = "button";
button.className = "moderation-badge__action flex-shrink-0";
button.dataset.moderationAction = "override";
button.textContent = "Show anyway";
button.textContent = overrideLabel;
button.setAttribute("aria-label", overrideAria);
button.addEventListener("click", channelModerationBadgeState.boundOverride);
channelModerationBadgeState.overrideButton = button;
}
if (typeof channelModerationBadgeState.boundHide !== "function") {
channelModerationBadgeState.boundHide = (event) =>
handleChannelModerationHide(event);
}
if (!channelModerationBadgeState.hideButton) {
const { text: restoreLabel, ariaLabel: restoreAria } =
getModerationOverrideActionLabels({ overrideActive: true });
const hideButton = doc.createElement("button");
hideButton.type = "button";
hideButton.className = "moderation-badge__action flex-shrink-0";
hideButton.dataset.moderationAction = "hide";
hideButton.textContent = restoreLabel;
hideButton.setAttribute("aria-label", restoreAria);
hideButton.addEventListener("click", channelModerationBadgeState.boundHide);
channelModerationBadgeState.hideButton = hideButton;
}
if (!channelModerationBadgeState.blockButton) {
const blockButton = doc.createElement("button");
blockButton.type = "button";
@@ -568,6 +592,10 @@ function renderChannelModerationBadge({
normalizedContext.allowOverride &&
ref.overrideButton
) {
const { text: overrideLabel, ariaLabel: overrideAria } =
getModerationOverrideActionLabels({ overrideActive: false });
ref.overrideButton.textContent = overrideLabel;
ref.overrideButton.setAttribute("aria-label", overrideAria);
if (ref.overrideButton.parentNode !== badge) {
badge.appendChild(ref.overrideButton);
}
@@ -577,6 +605,20 @@ function renderChannelModerationBadge({
badge.removeChild(ref.overrideButton);
}
if (hasVideoTarget && normalizedContext.overrideActive && ref.hideButton) {
const { text: restoreLabel, ariaLabel: restoreAria } =
getModerationOverrideActionLabels({ overrideActive: true });
ref.hideButton.textContent = restoreLabel;
ref.hideButton.setAttribute("aria-label", restoreAria);
if (ref.hideButton.parentNode !== badge) {
badge.appendChild(ref.hideButton);
}
ref.hideButton.disabled = false;
ref.hideButton.removeAttribute("aria-busy");
} else if (ref.hideButton && ref.hideButton.parentNode === badge) {
badge.removeChild(ref.hideButton);
}
if (hasVideoTarget && shouldShowChannelBlockButton(normalizedContext) && ref.blockButton) {
if (ref.blockButton.parentNode !== badge) {
badge.appendChild(ref.blockButton);
@@ -595,6 +637,14 @@ function renderChannelModerationBadge({
}
}
if (ref.hideButton) {
if (badge.id) {
ref.hideButton.setAttribute("aria-describedby", badge.id);
} else {
ref.hideButton.removeAttribute("aria-describedby");
}
}
if (ref.blockButton) {
if (badge.id) {
ref.blockButton.setAttribute("aria-describedby", badge.id);
@@ -649,6 +699,59 @@ function handleChannelModerationOverride(event) {
}
}
function handleChannelModerationHide(event) {
if (event) {
if (typeof event.preventDefault === "function") {
event.preventDefault();
}
if (typeof event.stopPropagation === "function") {
event.stopPropagation();
}
}
const sourceVideo = currentChannelModerationSourceVideo;
const app = channelModerationBadgeState.app || getApp();
const targetVideo =
app?.videosMap instanceof Map && sourceVideo?.id
? app.videosMap.get(sourceVideo.id) || sourceVideo
: sourceVideo;
if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) {
return;
}
const button = channelModerationBadgeState.hideButton;
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
}
let handled = false;
try {
handled =
typeof app?.handleModerationHide === "function"
? app.handleModerationHide({
video: targetVideo,
}) === true
: false;
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to handle channel moderation hide",
error,
);
}
if (handled) {
updateChannelModerationVisuals({
app,
pubkey: sourceVideo?.pubkey || currentChannelHex || "",
});
} else if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
function handleChannelModerationBlock(event) {
if (event) {
if (typeof event.preventDefault === "function") {
+123 -1
View File
@@ -15,6 +15,7 @@ import { userLogger } from "./utils/logger.js";
import {
normalizeVideoModerationContext,
applyModerationContextDatasets,
getModerationOverrideActionLabels,
} from "./ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "./ui/moderationCopy.js";
@@ -587,18 +588,36 @@ 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 { text: overrideLabel, ariaLabel: overrideAria } =
getModerationOverrideActionLabels({ overrideActive: false });
const overrideButton = doc.createElement("button");
overrideButton.type = "button";
overrideButton.className = "moderation-badge__action flex-shrink-0";
overrideButton.dataset.moderationAction = "override";
overrideButton.textContent = "Show anyway";
overrideButton.textContent = overrideLabel;
overrideButton.setAttribute("aria-label", overrideAria);
overrideButton.addEventListener("click", ref.boundOverride);
ref.overrideButton = overrideButton;
}
if (!ref.hideButton) {
const { text: restoreLabel, ariaLabel: restoreAria } =
getModerationOverrideActionLabels({ overrideActive: true });
const hideButton = doc.createElement("button");
hideButton.type = "button";
hideButton.className = "moderation-badge__action flex-shrink-0";
hideButton.dataset.moderationAction = "hide";
hideButton.textContent = restoreLabel;
hideButton.setAttribute("aria-label", restoreAria);
hideButton.addEventListener("click", ref.boundHide);
ref.hideButton = hideButton;
}
if (!ref.blockButton) {
const blockButton = doc.createElement("button");
blockButton.type = "button";
@@ -617,6 +636,9 @@ 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);
}
@@ -629,6 +651,7 @@ function updateHistoryCardBadge(ref, context) {
ref.badgeIconSvg = null;
ref.badgeIconWrapper = null;
ref.overrideButton = null;
ref.hideButton = null;
ref.blockButton = null;
return;
}
@@ -714,6 +737,10 @@ function updateHistoryCardBadge(ref, context) {
}
if (!context.overrideActive && context.allowOverride && ref.overrideButton) {
const { text: overrideLabel, ariaLabel: overrideAria } =
getModerationOverrideActionLabels({ overrideActive: false });
ref.overrideButton.textContent = overrideLabel;
ref.overrideButton.setAttribute("aria-label", overrideAria);
if (ref.overrideButton.parentNode !== badge) {
badge.appendChild(ref.overrideButton);
}
@@ -723,6 +750,20 @@ function updateHistoryCardBadge(ref, context) {
badge.removeChild(ref.overrideButton);
}
if (context.overrideActive && ref.hideButton) {
const { text: restoreLabel, ariaLabel: restoreAria } =
getModerationOverrideActionLabels({ overrideActive: true });
ref.hideButton.textContent = restoreLabel;
ref.hideButton.setAttribute("aria-label", restoreAria);
if (ref.hideButton.parentNode !== badge) {
badge.appendChild(ref.hideButton);
}
ref.hideButton.disabled = false;
ref.hideButton.removeAttribute("aria-busy");
} else if (ref.hideButton && ref.hideButton.parentNode === badge) {
badge.removeChild(ref.hideButton);
}
if (shouldShowHistoryCardBlockButton(context) && ref.blockButton) {
if (ref.blockButton.parentNode !== badge) {
badge.appendChild(ref.blockButton);
@@ -782,6 +823,14 @@ function updateHistoryCardAria(ref) {
}
}
if (ref.hideButton) {
if (badgeId) {
ref.hideButton.setAttribute("aria-describedby", badgeId);
} else {
ref.hideButton.removeAttribute("aria-describedby");
}
}
if (ref.blockButton) {
if (badgeId) {
ref.blockButton.setAttribute("aria-describedby", badgeId);
@@ -889,6 +938,79 @@ function handleHistoryCardModerationOverride(event, ref) {
});
}
function handleHistoryCardModerationHide(event, ref) {
if (event) {
if (typeof event.preventDefault === "function") {
event.preventDefault();
}
if (typeof event.stopPropagation === "function") {
event.stopPropagation();
}
}
const button = ref.hideButton;
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
}
const video = ref.video;
if (!video || typeof video !== "object" || !video.id) {
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
const app = getAppInstance();
let result;
try {
if (typeof app?.handleModerationHide === "function") {
result = app.handleModerationHide({ 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-hide", { detail: { video } }));
}
result = true;
}
} catch (error) {
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
if (isDevEnv) {
userLogger.warn("[historyView] Moderation hide handler threw:", error);
}
return;
}
Promise.resolve(result)
.then((handled) => {
if (handled === false) {
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
const context = normalizeVideoModerationContext(ref.video?.moderation);
applyHistoryCardModeration(ref, context);
})
.catch((error) => {
if (isDevEnv) {
userLogger.warn("[historyView] Moderation hide failed:", error);
}
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
});
}
function handleHistoryCardModerationBlock(event, ref) {
if (event) {
if (typeof event.preventDefault === "function") {
+21 -4
View File
@@ -3,6 +3,7 @@ import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibilit
import { userLogger } from "../../utils/logger.js";
import {
applyModerationContextDatasets,
getModerationOverrideActionLabels,
normalizeVideoModerationContext,
} from "../moderationUiHelpers.js";
import { buildModerationBadgeText } from "../moderationCopy.js";
@@ -1000,6 +1001,9 @@ export class VideoCard {
}
createModerationOverrideButton() {
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: false,
});
const button = this.createElement("button", {
classNames: ["moderation-badge__action", "flex-shrink-0"],
attrs: {
@@ -1007,22 +1011,27 @@ export class VideoCard {
"data-moderation-action": "override",
"aria-pressed": "false",
"aria-describedby": this.getModerationBadgeId(),
"aria-label": ariaLabel,
},
textContent: "Show anyway",
textContent: label,
});
button.addEventListener("click", this.boundShowAnywayHandler);
return button;
}
createModerationHideButton() {
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: true,
});
const button = this.createElement("button", {
classNames: ["moderation-badge__action", "flex-shrink-0"],
attrs: {
type: "button",
"data-moderation-action": "hide",
"aria-describedby": this.getModerationBadgeId(),
"aria-label": ariaLabel,
},
textContent: "Hide",
textContent: label,
});
button.addEventListener("click", this.boundModerationHideHandler);
return button;
@@ -1697,17 +1706,25 @@ export class VideoCard {
}
if (desiredMode === "hide") {
this.moderationActionButton.textContent = "Hide";
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: true,
});
this.moderationActionButton.textContent = label;
this.moderationActionButton.dataset.moderationAction = "hide";
this.moderationActionButton.removeAttribute("aria-pressed");
this.moderationActionButton.setAttribute("aria-label", ariaLabel);
this.moderationActionButton.addEventListener(
"click",
this.boundModerationHideHandler,
);
} else {
this.moderationActionButton.textContent = "Show anyway";
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: false,
});
this.moderationActionButton.textContent = label;
this.moderationActionButton.dataset.moderationAction = "override";
this.moderationActionButton.setAttribute("aria-pressed", "false");
this.moderationActionButton.setAttribute("aria-label", ariaLabel);
this.moderationActionButton.addEventListener(
"click",
this.boundShowAnywayHandler,
+14 -3
View File
@@ -14,7 +14,10 @@ import {
unsubscribeFromVideoViewCount,
formatViewCount,
} from "../../viewCounter.js";
import { normalizeVideoModerationContext } from "../moderationUiHelpers.js";
import {
getModerationOverrideActionLabels,
normalizeVideoModerationContext,
} from "../moderationUiHelpers.js";
import { buildModerationBadgeText } from "../moderationCopy.js";
import {
formatAbsoluteTimestamp,
@@ -4247,14 +4250,22 @@ export class VideoModal {
}
if (mode === "override") {
button.textContent = "Show anyway";
const { text, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: false,
});
button.textContent = text;
button.dataset.moderationAction = "override";
button.setAttribute("aria-pressed", "false");
button.setAttribute("aria-label", ariaLabel);
button.addEventListener("click", this.handleModerationOverrideClick);
} else {
button.textContent = "Hide";
const { text, ariaLabel } = getModerationOverrideActionLabels({
overrideActive: true,
});
button.textContent = text;
button.dataset.moderationAction = "hide";
button.removeAttribute("aria-pressed");
button.setAttribute("aria-label", ariaLabel);
button.addEventListener("click", this.handleModerationHideClick);
}
+21
View File
@@ -1,3 +1,18 @@
const SHOW_ANYWAY_LABEL = "Show anyway";
const RESTORE_DEFAULT_MODERATION_LABEL = "Restore default moderation";
export function getModerationOverrideActionLabels({ overrideActive } = {}) {
const active = overrideActive === true;
const label = active
? RESTORE_DEFAULT_MODERATION_LABEL
: SHOW_ANYWAY_LABEL;
return {
text: label,
ariaLabel: label,
};
}
export function normalizeVideoModerationContext(moderationInput) {
const moderation =
moderationInput && typeof moderationInput === "object"
@@ -230,6 +245,8 @@ export function applyModerationContextDatasets(
});
};
const restoreCopy = getModerationOverrideActionLabels({ overrideActive: true });
applyToRoots((el) => {
if (context.originalBlockAutoplay && !context.overrideActive) {
el.dataset.autoplayPolicy = "blocked";
@@ -239,8 +256,12 @@ export function applyModerationContextDatasets(
if (context.overrideActive) {
el.dataset.moderationOverride = "show-anyway";
el.dataset.moderationOverrideActionLabel = restoreCopy.text;
} else if (el.dataset.moderationOverride) {
delete el.dataset.moderationOverride;
if (el.dataset.moderationOverrideActionLabel) {
delete el.dataset.moderationOverrideActionLabel;
}
}
});
+24
View File
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { setupModal } from "./video-modal-accessibility.test.mjs";
const RESTORE_BUTTON_LABEL = "Restore default moderation";
test("VideoModal blurs and restores playback when moderation overlay toggles", async (t) => {
const { modal, cleanup } = await setupModal();
const document = modal.document;
@@ -59,9 +61,13 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
);
let overrideEvent = null;
let hideEvent = null;
modal.addEventListener("video:moderation-override", (event) => {
overrideEvent = event;
});
modal.addEventListener("video:moderation-hide", (event) => {
hideEvent = event;
});
const showButton = overlay.querySelector(
"[data-moderation-action='override']",
@@ -83,6 +89,24 @@ test("VideoModal blurs and restores playback when moderation overlay toggles", a
assert.equal(stage.dataset.visualState, undefined);
assert.equal(overlay.hasAttribute("hidden"), true);
const restoreButton = document.querySelector(
"[data-moderation-action='hide']",
);
assert.ok(
restoreButton,
"Restore default moderation button should be present after override",
);
assert.equal(
restoreButton.textContent.trim(),
RESTORE_BUTTON_LABEL,
"Restore button should expose the new label",
);
restoreButton.click();
assert.ok(hideEvent, "clicking restore should emit moderation hide event");
assert.equal(hideEvent.detail.video, video);
delete video.moderation.viewerOverride;
video.moderation.blurThumbnail = true;
+59
View File
@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
const FIXTURE_URL = "/docs/moderation/fixtures/index.html";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const SENTINEL_KEY = "__moderationFixturesInit__";
const RESTORE_BUTTON_LABEL = "Restore default moderation";
function setupStorageReset(page) {
return page.addInitScript(({ storageKey, sentinelKey }) => {
@@ -67,10 +68,14 @@ test.describe("moderation fixtures", () => {
const overrideCard = page.locator('[data-test-id="show-anyway"]');
const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = overrideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(showAnywayButton).toBeVisible();
await expect(restoreButtonQuery).toHaveCount(0);
await showAnywayButton.click();
@@ -80,12 +85,19 @@ test.describe("moderation fixtures", () => {
);
await expect(overrideCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
const restoreButton = overrideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(restoreButton).toBeVisible();
await expect(overrideCard.getByRole("button", { name: "Show anyway" })).toHaveCount(0);
await page.reload({ waitUntil: "networkidle" });
const reloadedCard = page.locator('[data-test-id="show-anyway"]');
const reloadedThumbnail = reloadedCard.locator('img[data-video-thumbnail]');
const reloadedRestoreButton = reloadedCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(reloadedCard).toHaveAttribute(
"data-moderation-override",
@@ -93,9 +105,25 @@ test.describe("moderation fixtures", () => {
);
await expect(reloadedCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
await expect(reloadedThumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
await expect(reloadedRestoreButton).toBeVisible();
await expect(
reloadedCard.getByRole("button", { name: "Show anyway" })
).toHaveCount(0);
await reloadedRestoreButton.click();
await expect(reloadedCard).not.toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
await expect(reloadedCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(reloadedThumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(
reloadedCard.getByRole("button", { name: RESTORE_BUTTON_LABEL })
).toHaveCount(0);
await expect(
reloadedCard.getByRole("button", { name: "Show anyway" })
).toBeVisible();
});
test("trusted report hide fixture supports show anyway override", async ({ page }) => {
@@ -104,12 +132,16 @@ test.describe("moderation fixtures", () => {
const hideCard = page.locator('[data-test-id="trusted-report-hide"]');
const badge = hideCard.locator('[data-moderation-badge="true"]');
const showAnywayButton = hideCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = hideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(hideCard).toHaveAttribute("data-moderation-hidden", "true");
await expect(hideCard).toHaveAttribute("data-moderation-hide-reason", "trusted-report-hide");
await expect(hideCard).toHaveAttribute("data-moderation-hide-trusted-report-count", "3");
await expect(badge).toContainText("Hidden · 3 trusted spam reports");
await expect(showAnywayButton).toBeVisible();
await expect(restoreButtonQuery).toHaveCount(0);
await showAnywayButton.click();
@@ -118,6 +150,17 @@ test.describe("moderation fixtures", () => {
await expect(hideCard.locator('[data-moderation-badge="true"]')).toContainText(
"Showing despite 3 trusted spam reports",
);
const restoreButton = hideCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(restoreButton).toBeVisible();
await restoreButton.click();
await expect(hideCard).toHaveAttribute("data-moderation-hidden", "true");
await expect(hideCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
await expect(hideCard.getByRole("button", { name: "Show anyway" })).toBeVisible();
});
test("trusted mute hide fixture annotates mute reason and can be overridden", async ({
@@ -129,6 +172,9 @@ test.describe("moderation fixtures", () => {
const badge = muteCard.locator('[data-moderation-badge="true"]');
const thumbnail = muteCard.locator('img[data-video-thumbnail]');
const showAnywayButton = muteCard.getByRole("button", { name: "Show anyway" });
const restoreButtonQuery = muteCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
await expect(muteCard).toHaveAttribute("data-moderation-hide-reason", "trusted-mute-hide");
@@ -138,6 +184,7 @@ test.describe("moderation fixtures", () => {
await expect(badge).toContainText("Hidden · 1 trusted mute");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(showAnywayButton).toBeVisible();
await expect(restoreButtonQuery).toHaveCount(0);
await showAnywayButton.click();
@@ -147,5 +194,17 @@ test.describe("moderation fixtures", () => {
"Showing despite 1 trusted mute",
);
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
const restoreButton = muteCard.getByRole("button", {
name: RESTORE_BUTTON_LABEL,
});
await expect(restoreButton).toBeVisible();
await restoreButton.click();
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(muteCard.getByRole("button", { name: "Show anyway" })).toBeVisible();
});
});