Merge pull request #744 from PR0M3TH3AN/codex/expand-tests-for-video-card-metadata

Add trusted hide coverage and refresh moderation docs
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 20:10:18 -04:00
commit a8cea0b147
5 files changed
+288 -43

No files matched your search

+10 -3
View File
@@ -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:
+148 -30
View File
@@ -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;
+6 -5
View File
@@ -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.
+83
View File
@@ -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"));
},
);
+41 -5
View File
@@ -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",
);
});
});