diff --git a/docs/moderation/README.md b/docs/moderation/README.md index d4d5aeea..076ce19f 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -22,7 +22,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc - [`ModerationService`](../../js/services/moderationService.js) orchestrates ingest and scoring. Review the service for the trusted-report math and helper entry points. - [`createModerationStage`](../../js/feedEngine/stages.js) wires the service into the feed engine where moderation summaries decorate timeline items. - [`bitvidApp.decorateVideoModeration`](../../js/app.js) connects stage output to the UI layer alongside feature-flag plumbing. -- [`VideoCard.refreshModerationUi`](../../js/ui/components/VideoCard.js) applies badges, blur states, and "show anyway" toggles. +- [`VideoCard.refreshModerationUi`](../../js/ui/components/VideoCard.js) applies badges, blur states, hide metadata (`data-moderation-hidden`), and the "show anyway" toggles. ## Where to extend Thread new moderation behaviors through the same service → stage → app → UI flow above. Extending the existing layers keeps overrides, feature flags, and QA hooks consistent—avoid spinning up parallel moderation modules unless the architecture document explicitly calls for it. @@ -30,14 +30,21 @@ Thread new moderation behaviors through the same service → stage → app → U ## Defaults (policy) - Blur video thumbnails if **≥ 3** F1 friends report `nudity`. - Disable autoplay preview if **≥ 2** F1 friends report `nudity`. -- Hide videos when **≥ 1** trusted contact mutes the author (`TRUSTED_MUTE_HIDE_THRESHOLD`). -- Hide videos when **≥ 3** trusted contacts file spam reports (`TRUSTED_SPAM_HIDE_THRESHOLD`). +- Hide videos when **≥ 1** trusted contact mutes the author (`TRUSTED_MUTE_HIDE_THRESHOLD`). Cards render with `data-moderation-hidden="true"`, the badge reads `Hidden · 1 trusted mute`, and a "Show anyway" button becomes available. +- Hide videos when **≥ 3** trusted contacts file spam reports (`TRUSTED_SPAM_HIDE_THRESHOLD`). The badge copy escalates to `Hidden · 3 trusted spam reports` 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. - Trust seeds from `DEFAULT_TRUST_SEED_NPUBS` count as baseline F1 contacts (toggle via `FEATURE_TRUST_SEEDS`). > You can override all defaults in **Settings → Safety & Moderation**. +### Hide thresholds & feature flags + +- `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. + ## Community blacklist federation Operators can delegate hard-hide decisions to trusted curators without giving them full admin powers. The flow is: diff --git a/docs/moderation/fixtures/index.html b/docs/moderation/fixtures/index.html index 2693163c..578b8f11 100644 --- a/docs/moderation/fixtures/index.html +++ b/docs/moderation/fixtures/index.html @@ -126,16 +126,43 @@ ], }, { - key: "trusted-mute", - heading: "Trusted mute (F1 mute list)", + key: "trusted-report-hide", + heading: "Trusted report hide (≥3 spam reports)", description: - "Card should explain that a trusted contact muted this author without enabling overrides.", + "Card should render hidden with spam copy until the viewer clicks \"Show anyway\".", + enableOverride: true, + thresholds: { reportHide: 3 }, video: { id: "4".repeat(64), - title: "Fixture: Trusted Mute", + title: "Fixture: Trusted Report Hide", pubkey: "aa".repeat(32), thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", }, + moderation: { + blockAutoplay: true, + blurThumbnail: true, + trustedCount: 3, + reportType: "spam", + }, + reporters: [ + { name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 }, + { name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 }, + { name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 }, + ], + }, + { + key: "trusted-mute-hide", + heading: "Trusted mute hide (F1 mute list threshold)", + description: + "Card should default to hidden when a trusted contact mutes the author and allow an override.", + enableOverride: true, + thresholds: { muteHide: 1 }, + video: { + id: "5".repeat(64), + title: "Fixture: Trusted Mute Hide", + pubkey: "bb".repeat(32), + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + }, moderation: { blockAutoplay: false, blurThumbnail: false, @@ -284,14 +311,20 @@ const sanitizedReporters = normalizeFixtureReporters(activeReporters); const thresholds = fixture.thresholds && typeof fixture.thresholds === "object" - ? { blur: 3, autoplay: 2, ...fixture.thresholds } - : { blur: 3, autoplay: 2 }; + ? { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1, ...fixture.thresholds } + : { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1 }; const autoplayThreshold = Number.isFinite(thresholds.autoplay) ? Math.max(0, Math.floor(thresholds.autoplay)) : 2; const blurThreshold = Number.isFinite(thresholds.blur) ? Math.max(0, Math.floor(thresholds.blur)) : 3; + const reportHideThreshold = Number.isFinite(thresholds.reportHide) + ? Math.max(0, Math.floor(thresholds.reportHide)) + : 3; + const muteHideThreshold = Number.isFinite(thresholds.muteHide) + ? Math.max(0, Math.floor(thresholds.muteHide)) + : 1; const initialBlock = fixture.moderation.blockAutoplay === true; const initialBlur = fixture.moderation.blurThumbnail === true; const trustedCount = sanitizedReporters.length; @@ -307,16 +340,57 @@ latest: reporter.latest, })); + const muterEntries = Array.isArray(fixture.muters) ? fixture.muters : []; + const muterPubkeys = muterEntries + .map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : "")) + .filter(Boolean); + const muterDisplayNames = muterEntries + .map((muter) => (typeof muter?.name === "string" ? muter.name : "")) + .filter(Boolean); + const trustedMuted = fixture.moderation.trustedMuted === true; + const muteCount = Number.isFinite(fixture.moderation.trustedMuteCount) + ? Math.max(0, Math.floor(fixture.moderation.trustedMuteCount)) + : muterPubkeys.length; + + const hideFromMute = trustedMuted && muteCount >= muteHideThreshold; + const hideFromReports = trustedCount >= reportHideThreshold; + let hidden = fixture.moderation.hidden === true; + let hideReason = + typeof fixture.moderation.hideReason === "string" + ? fixture.moderation.hideReason + : ""; + const hideBypass = + typeof fixture.moderation.hideBypass === "string" + ? fixture.moderation.hideBypass + : ""; + + if (hideFromMute) { + hidden = true; + hideReason = "trusted-mute-hide"; + } else if (hideFromReports) { + hidden = true; + hideReason = "trusted-report-hide"; + } else if (!hidden) { + hideReason = ""; + } + + const hideCounts = hidden + ? { + trustedMuteCount: muteCount, + trustedReportCount: trustedCount, + } + : null; + const base = { reportType: fixture.moderation.reportType, trustedCount, reporterDisplayNames, reporterPubkeys, trustedReporters: trustedReporterEntries, - original: { - blockAutoplay: initialBlock, - blurThumbnail: initialBlur, - }, + trustedMuted, + trustedMuters: muterPubkeys, + trustedMuteCount: muteCount, + trustedMuterDisplayNames: muterDisplayNames, }; const summary = buildSummary(fixture, videoId, sanitizedReporters); @@ -324,24 +398,33 @@ base.summary = summary; } - if (fixture.moderation.trustedMuted) { - base.trustedMuted = true; - const muterPubkeys = Array.isArray(fixture.muters) - ? fixture.muters - .map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : "")) - .filter(Boolean) - : []; - base.trustedMuters = muterPubkeys; - const muteCount = Number.isFinite(fixture.moderation.trustedMuteCount) - ? Math.max(0, Math.floor(fixture.moderation.trustedMuteCount)) - : muterPubkeys.length; - base.trustedMuteCount = muteCount; - base.trustedMuterDisplayNames = Array.isArray(fixture.muters) - ? fixture.muters - .map((muter) => (typeof muter?.name === "string" ? muter.name : "")) - .filter(Boolean) - : []; - } + const computedBlock = initialBlock && trustedCount >= autoplayThreshold; + const computedBlur = initialBlur && trustedCount >= blurThreshold; + + const originalState = { + blockAutoplay: computedBlock, + blurThumbnail: computedBlur, + hidden, + hideReason: hidden ? hideReason : "", + hideCounts: hideCounts + ? { + trustedMuteCount: Math.max(0, Number(hideCounts.trustedMuteCount) || 0), + trustedReportCount: Math.max(0, Number(hideCounts.trustedReportCount) || 0), + } + : null, + hideBypass, + hideTriggered: hidden, + }; + + base.original = { + blockAutoplay: originalState.blockAutoplay, + blurThumbnail: originalState.blurThumbnail, + hidden: originalState.hidden, + hideReason: originalState.hideReason, + hideCounts: originalState.hideCounts, + hideBypass: originalState.hideBypass, + hideTriggered: originalState.hideTriggered, + }; if (overrideEntry?.showAnyway) { base.viewerOverride = { @@ -352,9 +435,44 @@ }; base.blockAutoplay = false; base.blurThumbnail = false; + base.hidden = false; + if (base.hideReason) { + delete base.hideReason; + } + if (base.hideCounts) { + delete base.hideCounts; + } + if (base.hideBypass) { + delete base.hideBypass; + } } else { - base.blockAutoplay = initialBlock && trustedCount >= autoplayThreshold; - base.blurThumbnail = initialBlur && trustedCount >= blurThreshold; + base.blockAutoplay = originalState.blockAutoplay; + base.blurThumbnail = originalState.blurThumbnail; + if (originalState.hidden) { + base.hidden = true; + if (originalState.hideReason) { + base.hideReason = originalState.hideReason; + } + if (originalState.hideCounts) { + base.hideCounts = { ...originalState.hideCounts }; + } + if (originalState.hideBypass) { + base.hideBypass = originalState.hideBypass; + } + } else { + if (base.hidden) { + delete base.hidden; + } + if (base.hideReason) { + delete base.hideReason; + } + if (base.hideCounts) { + delete base.hideCounts; + } + if (base.hideBypass) { + delete base.hideBypass; + } + } } return base; diff --git a/docs/moderation/testing.md b/docs/moderation/testing.md index eaa2cc74..08b6790d 100644 --- a/docs/moderation/testing.md +++ b/docs/moderation/testing.md @@ -6,9 +6,10 @@ - [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2. - Automated coverage: `tests/visual/moderation.spec.ts` blur and autoplay scenarios (run via `npm run test:visual`). - [ ] 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 and records only local pref. - - Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence scenario (run via `npm run test:visual`). +- [ ] “Show anyway” works for blurred and hidden cards 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. - [ ] Admin lists (30000) take effect only when subscribed. @@ -40,7 +41,7 @@ Expected: * After 1 report → no blur; autoplay allowed. * After 2 reports → autoplay blocked. -* After 3 reports → thumbnail blurred; reason chip shown. +* After 3 reports → thumbnail blurred; reason chip shown. At the spam threshold (`TRUSTED_SPAM_HIDE_THRESHOLD`) the card hides entirely and the badge copy switches to "Hidden · 3 trusted spam reports" until the viewer overrides it. **Exercises:** [`ModerationService.ingestReportEvent()`](../../js/services/moderationService.js) and [`ModerationService.getTrustedReportSummary()`](../../js/services/moderationService.js) wire through [`createModerationStage()`](../../js/feedEngine/stages.js) so [`bitvidApp.decorateVideoModeration()`](../../js/app.js) can hand the summary to [`VideoCard.refreshModerationUi()`](../../js/ui/components/VideoCard.js). @@ -55,7 +56,7 @@ Expected: } ``` -Expected: author’s items are downranked/hidden for this viewer. +Expected: author’s items are downranked/hidden for this viewer. When the mute threshold (`TRUSTED_MUTE_HIDE_THRESHOLD`) is met, cards render with "Hidden · 1 trusted mute" and require a "Show anyway" override to display content. **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. @@ -85,6 +86,6 @@ Expected: when viewer subscribes to this list, author is hard-hidden. ## Automated checks (suggested) * `npm run test:unit` — covers `tests/moderation/trusted-report-count.test.mjs` for deduping and mute handling. -* `npm run test:visual` — runs Playwright fixtures in `tests/visual/moderation.spec.ts` against `/docs/moderation/fixtures/index.html`. +* `npm run test:visual` — runs Playwright fixtures in `tests/visual/moderation.spec.ts` against `/docs/moderation/fixtures/index.html`, covering blur thresholds, autoplay blocks, trusted hide badges, and the override UX. * Integration test: emit events over a local relay; assert UI state. * Regression test: ensure Home feed ignores reputation gating. diff --git a/tests/moderation/video-card.test.mjs b/tests/moderation/video-card.test.mjs index 80f6cf39..6f2933f7 100644 --- a/tests/moderation/video-card.test.mjs +++ b/tests/moderation/video-card.test.mjs @@ -175,3 +175,86 @@ test("VideoCard renders moderation badges and respects viewer override", async ( assert.ok(!card.hiddenSummaryEl || card.hiddenSummaryEl.parentElement !== card.getRoot()); assert.ok(card.moderationBadgeEl.textContent.includes("Showing despite")); }); + +test( + "VideoCard hides content when hide metadata is present and override unmasks it", + async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + const videoId = "b".repeat(64); + + const video = { + id: videoId, + title: "Hidden Clip", + pubkey: "c".repeat(64), + moderation: { + blockAutoplay: true, + blurThumbnail: true, + reportType: "spam", + trustedCount: 3, + trustedReporters: [ + { pubkey: "d".repeat(64), latest: 1_700_000_001 }, + { pubkey: "e".repeat(64), latest: 1_700_000_002 }, + { pubkey: "f".repeat(64), latest: 1_700_000_003 }, + ], + }, + }; + + t.after(() => { + clearModerationOverride(video.id, { persist: false }); + }); + + app.videosMap.set(video.id, video); + app.currentVideo = video; + app.decorateVideoModeration(video); + + const card = new VideoCard({ + document, + video, + formatters: { + formatTimeAgo: () => "moments ago", + }, + helpers: { + isMagnetSupported: () => true, + }, + }); + + document.body.appendChild(card.getRoot()); + + const contextBeforeOverride = card.getModerationContext(); + assert.equal(contextBeforeOverride.activeHidden, true); + assert.equal(contextBeforeOverride.effectiveHideReason, "trusted-report-hide"); + assert.equal(contextBeforeOverride.allowOverride, true); + + assert.equal(card.getRoot().dataset.moderationHidden, "true"); + assert.equal(card.getRoot().dataset.moderationHideReason, "trusted-report-hide"); + assert.equal(card.getRoot().dataset.moderationHideTrustedReportCount, "3"); + assert.ok(card.hiddenSummaryEl); + assert.equal(card.anchorEl.hasAttribute("hidden"), true); + assert.equal(card.contentEl.hasAttribute("hidden"), true); + assert.ok(card.moderationActionButton); + assert.ok(card.moderationBadgeEl.textContent.includes("Hidden")); + + card.onModerationOverride = ({ video: overrideVideo, card: overrideCard }) => + app.handleModerationOverride({ video: overrideVideo, card: overrideCard }); + + card.moderationActionButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterOverride = card.getModerationContext(); + assert.equal(contextAfterOverride.overrideActive, true); + assert.equal(contextAfterOverride.activeHidden, false); + assert.equal(contextAfterOverride.effectiveHideReason, "trusted-report-hide"); + + assert.equal(card.getRoot().dataset.moderationHidden, undefined); + assert.equal(card.getRoot().dataset.moderationOverride, "show-anyway"); + assert.equal(card.getRoot().dataset.moderationHideReason, undefined); + assert.equal(card.getRoot().dataset.moderationHideTrustedReportCount, undefined); + assert.equal(card.anchorEl.hasAttribute("hidden"), false); + assert.equal(card.contentEl.hasAttribute("hidden"), false); + assert.ok(!card.hiddenSummaryEl || card.hiddenSummaryEl.parentElement !== card.getRoot()); + assert.ok(card.moderationBadgeEl.textContent.includes("Showing despite")); + }, +); diff --git a/tests/visual/moderation.spec.ts b/tests/visual/moderation.spec.ts index db1337cc..80420caa 100644 --- a/tests/visual/moderation.spec.ts +++ b/tests/visual/moderation.spec.ts @@ -98,15 +98,51 @@ test.describe("moderation fixtures", () => { ).toHaveCount(0); }); - test("trusted mute fixture annotates mute reason", async ({ page }) => { + test("trusted report hide fixture supports show anyway override", async ({ page }) => { await page.goto(FIXTURE_URL, { waitUntil: "networkidle" }); - const muteCard = page.locator('[data-test-id="trusted-mute"]'); + 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" }); + + 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 showAnywayButton.click(); + + await expect(hideCard).not.toHaveAttribute("data-moderation-hidden", "true"); + await expect(hideCard).toHaveAttribute("data-moderation-override", "show-anyway"); + await expect(hideCard.locator('[data-moderation-badge="true"]')).toContainText( + "Showing despite 3 trusted spam reports", + ); + }); + + test("trusted mute hide fixture annotates mute reason and can be overridden", async ({ + page, + }) => { + await page.goto(FIXTURE_URL, { waitUntil: "networkidle" }); + + const muteCard = page.locator('[data-test-id="trusted-mute-hide"]'); + const badge = muteCard.locator('[data-moderation-badge="true"]'); + const showAnywayButton = muteCard.getByRole("button", { name: "Show anyway" }); + + await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true"); + await expect(muteCard).toHaveAttribute("data-moderation-hide-reason", "trusted-mute-hide"); + await expect(muteCard).toHaveAttribute("data-moderation-hide-trusted-mute-count", "1"); await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute", "true"); await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1"); - await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway"); + await expect(badge).toContainText("Hidden · 1 trusted mute"); + await expect(showAnywayButton).toBeVisible(); - const badge = muteCard.locator('[data-moderation-badge="true"]'); - await expect(badge).toContainText("Muted by a trusted contact"); + await showAnywayButton.click(); + + await expect(muteCard).not.toHaveAttribute("data-moderation-hidden", "true"); + await expect(muteCard).toHaveAttribute("data-moderation-override", "show-anyway"); + await expect(muteCard.locator('[data-moderation-badge="true"]')).toContainText( + "Showing despite 1 trusted mute", + ); }); });