mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
Add channel header moderation badge
This commit is contained in:
1 parent
2bc77c829d
commit
5cc0e136bd
4 files changed
+561
-16
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user