Files
bitvid/docs/moderation/fixtures/index.html
T

507 lines
17 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Moderation Fixtures • bitvid</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/tailwind.generated.css" />
<style>
body {
background-color: #0f172a;
color: #f8fafc;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
section {
margin-bottom: 2.5rem;
}
section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
section p {
color: #cbd5f5;
margin-bottom: 1rem;
}
</style>
</head>
<body data-ds="new">
<main id="moderationFixtureRoot">
<h1>Moderation Test Fixtures</h1>
<p>
These scenarios mirror the QA vectors in <code>docs/moderation/testing.md</code> so
automated checks can assert blur thresholds, autoplay blocking, and local
override persistence.
</p>
</main>
<script type="module">
import { VideoCard } from "/js/ui/components/VideoCard.js";
import moderationService from "/js/services/moderationService.js";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const fixtures = [
{
key: "blur-threshold",
heading: "Blur threshold (≥3 trusted nudity reports)",
description:
"Card should be blurred and autoplay disabled until the viewer overrides it.",
thresholds: { blur: 3, autoplay: 2 },
video: {
id: "1".repeat(64),
title: "Fixture: Blurred Preview",
pubkey: "a".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
},
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 },
],
blockToggle: {
label: "Simulate viewer blocking Mira F1",
pubkey: "c".repeat(64),
},
},
{
key: "autoplay-threshold",
heading: "Autoplay threshold (≥2 trusted nudity reports)",
description:
"Card should block autoplay but stay unblurred so users can inspect the thumbnail.",
video: {
id: "2".repeat(64),
title: "Fixture: Autoplay Blocked",
pubkey: "e".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: false,
trustedCount: 2,
reportType: "nudity",
},
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 },
],
},
{
key: "show-anyway",
heading: "\"Show anyway\" override (persists locally)",
description:
"Card starts blurred and blocked; activating \"Show anyway\" should persist across reloads.",
enableOverride: true,
video: {
id: "3".repeat(64),
title: "Fixture: Override Persistence",
pubkey: "f".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
},
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",
heading: "Trusted mute (F1 mute list)",
description:
"Card should explain that a trusted contact muted this author without enabling overrides.",
video: {
id: "4".repeat(64),
title: "Fixture: Trusted Mute",
pubkey: "aa".repeat(32),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: false,
blurThumbnail: false,
trustedMuted: true,
trustedMuteCount: 1,
},
muters: [{ name: "Jess F1", pubkey: "b".repeat(64) }],
},
];
const blockedReportersByVideo = new Map();
function getBlockedSet(videoId) {
if (typeof videoId !== "string" || !videoId) {
return null;
}
return blockedReportersByVideo.get(videoId) || null;
}
function setReporterBlocked(videoId, pubkey, blocked) {
if (typeof videoId !== "string" || !videoId || typeof pubkey !== "string" || !pubkey) {
return;
}
let entry = blockedReportersByVideo.get(videoId);
if (blocked) {
if (!entry) {
entry = new Set();
blockedReportersByVideo.set(videoId, entry);
}
entry.add(pubkey);
return;
}
if (!entry) {
return;
}
entry.delete(pubkey);
if (!entry.size) {
blockedReportersByVideo.delete(videoId);
}
}
function isReporterBlocked(videoId, pubkey) {
const entry = getBlockedSet(videoId);
if (!entry) {
return false;
}
return entry.has(pubkey);
}
function getActiveReporters(fixture, videoId) {
const reporters = Array.isArray(fixture?.reporters) ? fixture.reporters : [];
if (!reporters.length) {
return [];
}
const entry = getBlockedSet(videoId);
if (!entry || !entry.size) {
return reporters.slice();
}
return reporters.filter((reporter) => {
const key = typeof reporter?.pubkey === "string" ? reporter.pubkey : "";
if (!key) {
return false;
}
return !entry.has(key);
});
}
function normalizeFixtureReporters(reporters) {
if (!Array.isArray(reporters)) {
return [];
}
return reporters
.map((reporter) => {
if (!reporter || typeof reporter !== "object") {
return null;
}
const pubkey = typeof reporter.pubkey === "string" ? reporter.pubkey : "";
if (!pubkey) {
return null;
}
const latest = Number.isFinite(reporter.latest) ? Math.floor(reporter.latest) : 0;
const name = typeof reporter.name === "string" ? reporter.name : "";
return { name, pubkey, latest };
})
.filter(Boolean);
}
window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY;
function loadOverrides() {
try {
const raw = window.localStorage?.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function persistOverrides(next) {
try {
window.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore persistence issues in test fixtures */
}
}
function buildSummary(fixture, videoId, sanitizedReporters = null) {
if (!fixture?.moderation?.reportType) {
return null;
}
const reporters =
Array.isArray(sanitizedReporters) && sanitizedReporters
? sanitizedReporters
: normalizeFixtureReporters(getActiveReporters(fixture, videoId));
const latest = reporters.reduce((max, reporter) => Math.max(max, reporter.latest), 0);
return {
eventId: fixture.video.id,
totalTrusted: reporters.length,
types: {
[fixture.moderation.reportType]: {
trusted: reporters.length,
total: reporters.length,
latest,
},
},
updatedAt: Date.now(),
};
}
function buildModerationState(fixture, overrides, videoId) {
const overrideEntry = overrides?.[videoId];
const activeReporters = getActiveReporters(fixture, videoId);
const sanitizedReporters = normalizeFixtureReporters(activeReporters);
const thresholds =
fixture.thresholds && typeof fixture.thresholds === "object"
? { blur: 3, autoplay: 2, ...fixture.thresholds }
: { blur: 3, autoplay: 2 };
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 initialBlock = fixture.moderation.blockAutoplay === true;
const initialBlur = fixture.moderation.blurThumbnail === true;
const trustedCount = sanitizedReporters.length;
const reporterDisplayNames = sanitizedReporters
.map((reporter) => reporter.name)
.filter((value) => typeof value === "string" && value);
const reporterPubkeys = sanitizedReporters.map((reporter) => reporter.pubkey);
const trustedReporterEntries = sanitizedReporters.map((reporter) => ({
pubkey: reporter.pubkey,
latest: reporter.latest,
}));
const base = {
reportType: fixture.moderation.reportType,
trustedCount,
reporterDisplayNames,
reporterPubkeys,
trustedReporters: trustedReporterEntries,
original: {
blockAutoplay: initialBlock,
blurThumbnail: initialBlur,
},
};
const summary = buildSummary(fixture, videoId, sanitizedReporters);
if (summary) {
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)
: [];
}
if (overrideEntry?.showAnyway) {
base.viewerOverride = {
showAnyway: true,
updatedAt: Number.isFinite(overrideEntry.updatedAt)
? Math.floor(overrideEntry.updatedAt)
: Date.now(),
};
base.blockAutoplay = false;
base.blurThumbnail = false;
} else {
base.blockAutoplay = initialBlock && trustedCount >= autoplayThreshold;
base.blurThumbnail = initialBlur && trustedCount >= blurThreshold;
}
return base;
}
function setOverride(overrides, videoId, entry) {
const next = { ...overrides };
next[videoId] = {
...entry,
updatedAt: Number.isFinite(entry?.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now(),
};
persistOverrides(next);
return next;
}
function createCard(fixture, overrides) {
const video = {
...fixture.video,
};
video.moderation = buildModerationState(fixture, overrides, video.id);
const card = new VideoCard({
document,
video,
shareUrl: "#",
formatters: {
formatTimeAgo: () => "just now",
formatNumber: (value) => {
if (typeof value === "number") {
return value.toLocaleString("en-US");
}
return String(value ?? "");
},
},
helpers: {
isMagnetSupported: () => true,
escapeHtml: (value) => String(value ?? ""),
toLocaleString: (value, locales, options) => {
if (value && typeof value.toLocaleString === "function") {
return value.toLocaleString(locales || "en-US", options);
}
return String(value ?? "");
},
},
assets: {
fallbackThumbnailSrc: "/assets/jpg/video-thumbnail-fallback.jpg",
},
designSystem: {
getMode: () => "new",
isNew: () => true,
},
});
const refreshModeration = () => {
card.video.moderation = buildModerationState(fixture, overrides, video.id);
if (typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
};
if (fixture.enableOverride) {
card.onModerationOverride = () => {
overrides = setOverride(overrides, video.id, { showAnyway: true });
refreshModeration();
return true;
};
}
const root = card.getRoot();
root.dataset.testId = fixture.key;
return { card, root, refreshModeration };
}
function renderFixtures() {
const root = document.getElementById("moderationFixtureRoot");
if (!root) {
return;
}
let overrides = loadOverrides();
fixtures.forEach((fixture) => {
const section = document.createElement("section");
const heading = document.createElement("h2");
heading.textContent = fixture.heading;
section.appendChild(heading);
if (fixture.description) {
const description = document.createElement("p");
description.textContent = fixture.description;
section.appendChild(description);
}
const { root: cardRoot, refreshModeration } = createCard(fixture, overrides);
section.appendChild(cardRoot);
if (fixture.blockToggle && fixture.blockToggle.pubkey) {
const toggleWrapper = document.createElement("div");
toggleWrapper.style.marginTop = "0.75rem";
toggleWrapper.style.fontSize = "0.875rem";
toggleWrapper.style.color = "#cbd5f5";
const label = document.createElement("label");
label.style.display = "inline-flex";
label.style.alignItems = "center";
label.style.gap = "0.5rem";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = isReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey);
input.addEventListener("change", () => {
const checked = input.checked;
setReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey, checked);
const action = checked ? "block" : "unblock";
try {
const detail = { action, targetPubkey: fixture.blockToggle.pubkey };
const maybePromise = moderationService?.queueUserBlockRefresh?.(detail);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch(() => {});
}
} catch {
/* ignore fixture errors */
}
refreshModeration();
});
const text = document.createElement("span");
text.textContent =
typeof fixture.blockToggle.label === "string"
? fixture.blockToggle.label
: "Viewer blocks reporter";
label.appendChild(input);
label.appendChild(text);
toggleWrapper.appendChild(label);
section.appendChild(toggleWrapper);
}
root.appendChild(section);
});
}
renderFixtures();
</script>
</body>
</html>