Add channel header moderation badge

This commit is contained in:
thePR0M3TH3AN committed 2025-10-25 20:54:12 -04:00
1 parent 2bc77c829d
commit 5cc0e136bd
4 files changed
+561 -16

No files matched your search

+15
View File
@@ -1438,6 +1438,21 @@
transform: scale(1.05);
}
.channel-moderation-badge-anchor {
@apply flex flex-col gap-sm;
pointer-events: none;
width: 100%;
}
.channel-moderation-badge-anchor:empty {
display: none;
}
.channel-moderation-badge-anchor > [data-moderation-badge="true"] {
pointer-events: auto;
max-width: min(100%, 28rem);
}
#playerModal .video-modal__video {
position: relative;
}
+409 -7
View File
@@ -20,6 +20,7 @@ import {
applyModerationContextDatasets,
normalizeVideoModerationContext,
} from "./ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "./ui/moderationCopy.js";
import {
calculateZapShares,
describeShareType,
@@ -55,6 +56,25 @@ import { setModalState as setGlobalModalState } from "./state/appState.js";
const getApp = () => getApplication();
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge";
let currentChannelModerationSourceVideo = null;
const channelModerationBadgeState = {
container: null,
badgeEl: null,
textEl: null,
iconSvg: null,
iconWrapper: null,
overrideButton: null,
hideButton: null,
boundOverride: null,
boundHide: null,
badgeId: "",
app: null,
};
let currentChannelHex = null;
let currentChannelNpub = null;
let currentChannelLightningAddress = "";
@@ -198,6 +218,354 @@ function buildChannelModerationFallback(pubkey) {
return fallback;
}
function getChannelModerationState(context) {
if (!context) {
return "";
}
if (context.overrideActive) {
return "override";
}
if (context.activeHidden && !context.overrideActive) {
return "hidden";
}
if (context.trustedMuted) {
return "trusted-mute";
}
return "blocked";
}
function shouldShowChannelHideButton(context) {
if (!context || !context.trustedMuted) {
return false;
}
if (context.activeHidden && !context.overrideActive) {
return false;
}
return true;
}
function getChannelModerationBadgeIconShape(state) {
if (state === "override") {
return {
d: "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z",
fillRule: "evenodd",
clipRule: "evenodd",
};
}
return {
d: "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z",
fillRule: "evenodd",
clipRule: "evenodd",
};
}
function updateChannelModerationBadgeIcon(svg, state) {
if (!svg) {
return;
}
svg.dataset.iconState = state;
const path = svg.firstElementChild;
if (!path) {
return;
}
const { d, fillRule, clipRule } = getChannelModerationBadgeIconShape(state);
path.setAttribute("d", d);
if (fillRule) {
path.setAttribute("fill-rule", fillRule);
} else {
path.removeAttribute("fill-rule");
}
if (clipRule) {
path.setAttribute("clip-rule", clipRule);
} else {
path.removeAttribute("clip-rule");
}
}
function createChannelModerationBadgeIcon(doc, state) {
const wrapper = doc.createElement("span");
wrapper.className = "moderation-badge__icon";
wrapper.setAttribute("aria-hidden", "true");
const svg = doc.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 20 20");
svg.setAttribute("focusable", "false");
svg.setAttribute("aria-hidden", "true");
svg.classList.add("moderation-badge__icon-mark");
const path = doc.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("fill", "currentColor");
svg.appendChild(path);
wrapper.appendChild(svg);
updateChannelModerationBadgeIcon(svg, state);
return { wrapper, svg };
}
function getChannelModerationBadgeContainer(doc = typeof document !== "undefined" ? document : null) {
if (!doc || typeof doc.getElementById !== "function") {
return null;
}
channelModerationBadgeState.container = doc.getElementById("channelModerationBadge") || null;
return channelModerationBadgeState.container;
}
function ensureChannelModerationBadgeResources(doc) {
const container = getChannelModerationBadgeContainer(doc);
if (!container) {
return null;
}
if (!channelModerationBadgeState.badgeEl) {
const badge = doc.createElement("div");
badge.className = "moderation-badge flex flex-wrap items-center gap-sm";
badge.dataset.moderationBadge = "true";
const { wrapper, svg } = createChannelModerationBadgeIcon(doc, "blocked");
badge.appendChild(wrapper);
const text = doc.createElement("span");
text.className = "moderation-badge__text whitespace-nowrap";
badge.appendChild(text);
channelModerationBadgeState.badgeEl = badge;
channelModerationBadgeState.textEl = text;
channelModerationBadgeState.iconWrapper = wrapper;
channelModerationBadgeState.iconSvg = svg;
}
if (typeof channelModerationBadgeState.boundOverride !== "function") {
channelModerationBadgeState.boundOverride = (event) =>
handleChannelModerationOverride(event);
}
if (typeof channelModerationBadgeState.boundHide !== "function") {
channelModerationBadgeState.boundHide = (event) =>
handleChannelModerationHide(event);
}
if (!channelModerationBadgeState.overrideButton) {
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.addEventListener("click", channelModerationBadgeState.boundOverride);
channelModerationBadgeState.overrideButton = button;
}
if (!channelModerationBadgeState.hideButton) {
const hideButton = doc.createElement("button");
hideButton.type = "button";
hideButton.className = "moderation-badge__action flex-shrink-0";
hideButton.dataset.moderationAction = "hide";
hideButton.textContent = "Hide";
hideButton.addEventListener("click", channelModerationBadgeState.boundHide);
channelModerationBadgeState.hideButton = hideButton;
}
return channelModerationBadgeState;
}
function teardownChannelModerationBadge() {
const ref = channelModerationBadgeState;
currentChannelModerationSourceVideo = null;
if (ref.badgeEl && ref.badgeEl.parentNode) {
ref.badgeEl.parentNode.removeChild(ref.badgeEl);
}
if (ref.container) {
ref.container.removeAttribute("data-moderation-active");
}
ref.app = null;
}
function renderChannelModerationBadge({
context = null,
video = null,
app = null,
doc = typeof document !== "undefined" ? document : null,
} = {}) {
const container = getChannelModerationBadgeContainer(doc);
if (!container) {
teardownChannelModerationBadge();
return;
}
const normalizedContext = context || null;
const shouldShow = Boolean(normalizedContext?.shouldShow);
currentChannelModerationSourceVideo =
video && typeof video === "object" && typeof video.id === "string"
? video
: null;
if (!shouldShow) {
teardownChannelModerationBadge();
return;
}
const ref = ensureChannelModerationBadgeResources(doc);
if (!ref || !ref.badgeEl || !ref.textEl) {
teardownChannelModerationBadge();
return;
}
const badge = ref.badgeEl;
const state = normalizedContext.overrideActive
? "override"
: getChannelModerationState(normalizedContext);
badge.dataset.variant = normalizedContext.overrideActive ? "neutral" : "warning";
badge.dataset.moderationState = state;
if (normalizedContext.effectiveHideReason) {
badge.dataset.moderationHideReason = normalizedContext.effectiveHideReason;
} else if (badge.dataset.moderationHideReason) {
delete badge.dataset.moderationHideReason;
}
updateChannelModerationBadgeIcon(ref.iconSvg, state);
const textContent =
buildModerationBadgeText(normalizedContext, { variant: "card" }) ||
"Moderated due to reports";
ref.textEl.textContent = textContent;
if (CHANNEL_MODERATION_BADGE_ID) {
badge.id = CHANNEL_MODERATION_BADGE_ID;
}
if (badge.parentNode !== container) {
container.appendChild(badge);
}
container.dataset.moderationActive = "true";
const hasVideoTarget = Boolean(currentChannelModerationSourceVideo?.id);
if (
hasVideoTarget &&
!normalizedContext.overrideActive &&
normalizedContext.allowOverride &&
ref.overrideButton
) {
if (ref.overrideButton.parentNode !== badge) {
badge.appendChild(ref.overrideButton);
}
ref.overrideButton.disabled = false;
ref.overrideButton.removeAttribute("aria-busy");
} else if (ref.overrideButton && ref.overrideButton.parentNode === badge) {
badge.removeChild(ref.overrideButton);
}
if (hasVideoTarget && shouldShowChannelHideButton(normalizedContext) && ref.hideButton) {
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 (ref.overrideButton) {
if (badge.id) {
ref.overrideButton.setAttribute("aria-describedby", badge.id);
} else {
ref.overrideButton.removeAttribute("aria-describedby");
}
}
if (ref.hideButton) {
if (badge.id) {
ref.hideButton.setAttribute("aria-describedby", badge.id);
} else {
ref.hideButton.removeAttribute("aria-describedby");
}
}
ref.badgeId = badge.id || "";
ref.app = app || getApp();
}
function handleChannelModerationOverride(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.overrideButton;
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
}
let handled = false;
try {
handled = app?.handleModerationOverride?.({ video: targetVideo }) === true;
} catch (error) {
devLogger.warn("[ChannelProfile] Failed to handle channel moderation override", error);
}
if (handled) {
updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "" });
} else if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
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 = app?.handleModerationHide?.({ video: targetVideo }) === true;
} 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 resolveChannelModeration(pubkey, app = getApp()) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
@@ -206,6 +574,7 @@ function resolveChannelModeration(pubkey, app = getApp()) {
const matches = collectChannelVideos(normalized, app);
let fallbackModeration = null;
let fallbackVideo = null;
for (const candidate of matches) {
const decorated = decorateChannelVideo(candidate, app);
@@ -221,23 +590,29 @@ function resolveChannelModeration(pubkey, app = getApp()) {
}
if (moderation.viewerOverride?.showAnyway === true) {
return moderation;
return { moderation, video: decorated };
}
if (moderation.hidden === true || moderation.blurThumbnail === true) {
return moderation;
return { moderation, video: decorated };
}
if (!fallbackModeration) {
fallbackModeration = moderation;
fallbackVideo = decorated;
}
}
if (fallbackModeration) {
return fallbackModeration;
return { moderation: fallbackModeration, video: fallbackVideo };
}
return buildChannelModerationFallback(normalized);
const fallback = buildChannelModerationFallback(normalized);
if (fallback) {
return { moderation: fallback, video: null };
}
return null;
}
export function applyChannelVisualBlur({
@@ -247,7 +622,8 @@ export function applyChannelVisualBlur({
app = null,
} = {}) {
const resolvedApp = app || getApp();
const moderation = resolveChannelModeration(pubkey, resolvedApp) || {};
const resolution = resolveChannelModeration(pubkey, resolvedApp);
const moderation = resolution?.moderation || {};
const context = normalizeVideoModerationContext(moderation);
const resolvedBanner = bannerEl ||
@@ -258,14 +634,21 @@ export function applyChannelVisualBlur({
(typeof document !== "undefined"
? document.getElementById("channelAvatar")
: null);
const doc = resolvedBanner?.ownerDocument ||
resolvedAvatar?.ownerDocument ||
(typeof document !== "undefined" ? document : null);
const badgeContainer = doc?.getElementById?.("channelModerationBadge") || null;
const avatarElements = [resolvedAvatar, resolvedBanner].filter(Boolean);
applyModerationContextDatasets(context, {
root: resolvedBanner || null,
roots: badgeContainer ? [badgeContainer] : undefined,
avatar: resolvedAvatar || null,
avatars: avatarElements.length > 1 ? avatarElements : undefined,
});
return { context, moderation, video: resolution?.video || null };
}
function updateChannelModerationVisuals({
@@ -281,12 +664,21 @@ function updateChannelModerationVisuals({
const resolvedApp = app || getApp();
const targetPubkey = pubkey || currentChannelHex || "";
if (!targetPubkey) {
applyChannelVisualBlur({
const result = applyChannelVisualBlur({
bannerEl,
avatarEl,
pubkey: "",
app: resolvedApp,
});
renderChannelModerationBadge({
context: result?.context,
video: null,
app: resolvedApp,
doc:
bannerEl?.ownerDocument ||
avatarEl?.ownerDocument ||
(typeof document !== "undefined" ? document : null),
});
return;
}
@@ -295,12 +687,22 @@ function updateChannelModerationVisuals({
const resolvedAvatar =
avatarEl || document.getElementById("channelAvatar");
applyChannelVisualBlur({
const result = applyChannelVisualBlur({
bannerEl: resolvedBanner,
avatarEl: resolvedAvatar,
pubkey: targetPubkey,
app: resolvedApp,
});
renderChannelModerationBadge({
context: result?.context,
video: result?.video,
app: resolvedApp,
doc:
resolvedBanner?.ownerDocument ||
resolvedAvatar?.ownerDocument ||
(typeof document !== "undefined" ? document : null),
});
}
let channelModerationEventsBound = false;
+122 -1
View File
@@ -2,6 +2,8 @@ import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import "../test-helpers/setup-localstorage.mjs";
import {
renderChannelVideosFromList,
applyChannelVisualBlur,
@@ -10,11 +12,15 @@ import {
} from "../../js/channelProfile.js";
import { nostrClient } from "../../js/nostrClientFacade.js";
import moderationService from "../../js/services/moderationService.js";
import { setApplication } from "../../js/applicationContext.js";
import { normalizeVideoModerationContext } from "../../js/ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "../../js/ui/moderationCopy.js";
import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs";
function setupDom(t) {
const dom = new JSDOM(
"<!DOCTYPE html><html><body>" +
'<div id="channelModerationBadge"></div>' +
'<div id="channelBanner"></div>' +
'<img id="channelAvatar" />' +
'<div id="channelVideoList"></div>' +
@@ -107,6 +113,7 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) =>
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.decorateVideoModeration = (video) => video;
const originalDecorate = app.decorateVideoModeration;
let decoratedCount = 0;
@@ -203,7 +210,11 @@ test("moderation override clears channel blur via event wiring", async (t) => {
types: { nudity: { trusted: 2 } },
},
};
const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState });
const video = createVideoFixture({
id: "a".repeat(64),
pubkey: channelPubkey,
moderation: moderationState,
});
renderChannelVideosFromList({
videos: [video],
@@ -223,3 +234,113 @@ test("moderation override clears channel blur via event wiring", async (t) => {
assert.equal(bannerEl.dataset.visualState, undefined);
assert.equal(avatarEl.dataset.visualState, undefined);
});
test("channel header moderation badge reflects blur state and override actions", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
(typeof value === "string" ? value.trim().toLowerCase() : "");
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.getActiveModerationThresholds = () => ({
blurThreshold: 1,
autoplayBlockThreshold: 1,
trustedMuteHideThreshold: 1,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
let overrideCalls = 0;
app.handleModerationOverride = () => {
overrideCalls += 1;
return true;
};
setApplication(app);
t.after(() => {
setApplication(null);
});
const channelPubkey = "d".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
__ensureChannelModerationEventsForTests();
const container = document.getElementById("channelVideoList");
const moderationState = {
blurThumbnail: true,
trustedCount: 2,
reportType: "nudity",
summary: { types: { nudity: { trusted: 2 } } },
original: { blurThumbnail: true, blockAutoplay: false },
};
const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState });
renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
applyChannelVisualBlur({ app, pubkey: channelPubkey });
const storedVideo = app.videosMap.get(video.id);
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
const badgeContainer = document.getElementById("channelModerationBadge");
const badge = badgeContainer.querySelector('[data-moderation-badge="true"]');
assert.ok(badge, "badge renders in channel header");
const textEl = badge.querySelector(".moderation-badge__text");
const context = normalizeVideoModerationContext(storedVideo.moderation);
const expectedText = buildModerationBadgeText(context, { variant: "card" });
assert.equal(textEl.textContent, expectedText);
const overrideButton = badge.querySelector('[data-moderation-action="override"]');
assert.ok(overrideButton, "override control is available");
overrideButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(overrideCalls, 1);
const updatedVideo = app.videosMap.get(video.id);
if (!updatedVideo.moderation || typeof updatedVideo.moderation !== "object") {
updatedVideo.moderation = {};
}
updatedVideo.moderation.viewerOverride = { showAnyway: true };
updatedVideo.moderation.blurThumbnail = false;
updatedVideo.moderation.blockAutoplay = false;
const originalState = updatedVideo.moderation.original && typeof updatedVideo.moderation.original === "object"
? { ...updatedVideo.moderation.original }
: {};
if (typeof originalState.blurThumbnail === "undefined") {
originalState.blurThumbnail = true;
}
if (typeof originalState.blockAutoplay === "undefined") {
originalState.blockAutoplay = true;
}
updatedVideo.moderation.original = originalState;
app.videosMap.set(updatedVideo.id, updatedVideo);
const overrideContext = normalizeVideoModerationContext(updatedVideo.moderation);
const overrideText = buildModerationBadgeText(overrideContext, { variant: "card" });
assert.equal(overrideContext.overrideActive, true);
assert.ok(overrideText.startsWith("Showing despite"));
});
+15 -8
View File
@@ -16,20 +16,27 @@
<div
class="absolute bottom-4 left-4 right-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex items-center">
<div class="flex flex-col gap-3">
<div
class="h-16 w-16 overflow-hidden rounded-full border-2 border-border-translucent-bright max-sm:h-[3.2rem] max-sm:w-[3.2rem]"
>
<img
id="channelAvatar"
id="channelModerationBadge"
class="channel-moderation-badge-anchor"
aria-live="polite"
></div>
<div class="flex items-center">
<div
class="h-16 w-16 overflow-hidden rounded-full border-2 border-border-translucent-bright max-sm:h-[3.2rem] max-sm:w-[3.2rem]"
>
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="h-full w-full object-cover"
/>
</div>
<div class="ml-4 text-text">
<h2 id="channelName" class="text-xl font-bold max-sm:text-base">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80 max-sm:text-[0.7rem]">npub...</p>
<div class="ml-4 text-text">
<h2 id="channelName" class="text-xl font-bold max-sm:text-base">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80 max-sm:text-[0.7rem]">npub...</p>
</div>
</div>
</div>
<div class="flex flex-col items-end gap-3 self-start sm:self-end">