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. - [`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. - [`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. - [`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 ## 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. 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) ## Defaults (policy)
- Blur video thumbnails if **≥ 3** F1 friends report `nudity`. - Blur video thumbnails if **≥ 3** F1 friends report `nudity`.
- Disable autoplay preview if **≥ 2** 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 **≥ 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`). - 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). - Downrank author when any F1 has them in mute list (10000).
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content. - 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`). - 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**. > 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 ## Community blacklist federation
Operators can delegate hard-hide decisions to trusted curators without giving them full admin powers. The flow is: 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", key: "trusted-report-hide",
heading: "Trusted mute (F1 mute list)", heading: "Trusted report hide (≥3 spam reports)",
description: 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: { video: {
id: "4".repeat(64), id: "4".repeat(64),
title: "Fixture: Trusted Mute", title: "Fixture: Trusted Report Hide",
pubkey: "aa".repeat(32), pubkey: "aa".repeat(32),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", 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: { moderation: {
blockAutoplay: false, blockAutoplay: false,
blurThumbnail: false, blurThumbnail: false,
@@ -284,14 +311,20 @@
const sanitizedReporters = normalizeFixtureReporters(activeReporters); const sanitizedReporters = normalizeFixtureReporters(activeReporters);
const thresholds = const thresholds =
fixture.thresholds && typeof fixture.thresholds === "object" fixture.thresholds && typeof fixture.thresholds === "object"
? { blur: 3, autoplay: 2, ...fixture.thresholds } ? { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1, ...fixture.thresholds }
: { blur: 3, autoplay: 2 }; : { blur: 3, autoplay: 2, reportHide: 3, muteHide: 1 };
const autoplayThreshold = Number.isFinite(thresholds.autoplay) const autoplayThreshold = Number.isFinite(thresholds.autoplay)
? Math.max(0, Math.floor(thresholds.autoplay)) ? Math.max(0, Math.floor(thresholds.autoplay))
: 2; : 2;
const blurThreshold = Number.isFinite(thresholds.blur) const blurThreshold = Number.isFinite(thresholds.blur)
? Math.max(0, Math.floor(thresholds.blur)) ? Math.max(0, Math.floor(thresholds.blur))
: 3; : 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 initialBlock = fixture.moderation.blockAutoplay === true;
const initialBlur = fixture.moderation.blurThumbnail === true; const initialBlur = fixture.moderation.blurThumbnail === true;
const trustedCount = sanitizedReporters.length; const trustedCount = sanitizedReporters.length;
@@ -307,16 +340,57 @@
latest: reporter.latest, 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 = { const base = {
reportType: fixture.moderation.reportType, reportType: fixture.moderation.reportType,
trustedCount, trustedCount,
reporterDisplayNames, reporterDisplayNames,
reporterPubkeys, reporterPubkeys,
trustedReporters: trustedReporterEntries, trustedReporters: trustedReporterEntries,
original: { trustedMuted,
blockAutoplay: initialBlock, trustedMuters: muterPubkeys,
blurThumbnail: initialBlur, trustedMuteCount: muteCount,
}, trustedMuterDisplayNames: muterDisplayNames,
}; };
const summary = buildSummary(fixture, videoId, sanitizedReporters); const summary = buildSummary(fixture, videoId, sanitizedReporters);
@@ -324,24 +398,33 @@
base.summary = summary; base.summary = summary;
} }
if (fixture.moderation.trustedMuted) { const computedBlock = initialBlock && trustedCount >= autoplayThreshold;
base.trustedMuted = true; const computedBlur = initialBlur && trustedCount >= blurThreshold;
const muterPubkeys = Array.isArray(fixture.muters)
? fixture.muters const originalState = {
.map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : "")) blockAutoplay: computedBlock,
.filter(Boolean) blurThumbnail: computedBlur,
: []; hidden,
base.trustedMuters = muterPubkeys; hideReason: hidden ? hideReason : "",
const muteCount = Number.isFinite(fixture.moderation.trustedMuteCount) hideCounts: hideCounts
? Math.max(0, Math.floor(fixture.moderation.trustedMuteCount)) ? {
: muterPubkeys.length; trustedMuteCount: Math.max(0, Number(hideCounts.trustedMuteCount) || 0),
base.trustedMuteCount = muteCount; trustedReportCount: Math.max(0, Number(hideCounts.trustedReportCount) || 0),
base.trustedMuterDisplayNames = Array.isArray(fixture.muters) }
? fixture.muters : null,
.map((muter) => (typeof muter?.name === "string" ? muter.name : "")) hideBypass,
.filter(Boolean) 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) { if (overrideEntry?.showAnyway) {
base.viewerOverride = { base.viewerOverride = {
@@ -352,9 +435,44 @@
}; };
base.blockAutoplay = false; base.blockAutoplay = false;
base.blurThumbnail = 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 { } else {
base.blockAutoplay = initialBlock && trustedCount >= autoplayThreshold; base.blockAutoplay = originalState.blockAutoplay;
base.blurThumbnail = initialBlur && trustedCount >= blurThreshold; 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; return base;
+6 -5
View File
@@ -6,9 +6,10 @@
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2. - [ ] 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`). - 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. - [ ] 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. - 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. - [ ] “Show anyway” works for blurred and hidden cards and records only local pref.
- Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence scenario (run via `npm run test:visual`). - 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. - [ ] Mute list (10000) downranks/hides consistently.
- Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh. - Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh.
- [ ] Admin lists (30000) take effect only when subscribed. - [ ] Admin lists (30000) take effect only when subscribed.
@@ -40,7 +41,7 @@ Expected:
* After 1 report → no blur; autoplay allowed. * After 1 report → no blur; autoplay allowed.
* After 2 reports → autoplay blocked. * 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). **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. **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) ## Automated checks (suggested)
* `npm run test:unit` — covers `tests/moderation/trusted-report-count.test.mjs` for deduping and mute handling. * `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. * Integration test: emit events over a local relay; assert UI state.
* Regression test: ensure Home feed ignores reputation gating. * 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.hiddenSummaryEl || card.hiddenSummaryEl.parentElement !== card.getRoot());
assert.ok(card.moderationBadgeEl.textContent.includes("Showing despite")); 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); ).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" }); 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", "true");
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1"); 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 showAnywayButton.click();
await expect(badge).toContainText("Muted by a trusted contact");
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",
);
}); });
}); });