Decorate channel videos with moderation helpers

This commit is contained in:
thePR0M3TH3AN committed 2025-10-25 18:52:38 -04:00
1 parent 1e19b2466e
commit a0c6812e1b
3 files changed
+618 -69

No files matched your search

+40 -19
View File
@@ -2028,7 +2028,27 @@ class Application {
}
computeSimilarContentCandidates({ activeVideo = this.currentVideo, maxItems = 5 } = {}) {
const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null;
const decorateCandidate = (video) => {
if (!video || typeof video !== "object") {
return video;
}
if (typeof this.decorateVideoModeration === "function") {
try {
const decorated = this.decorateVideoModeration(video);
if (decorated && typeof decorated === "object") {
return decorated;
}
} catch (error) {
devLogger.warn(
"[Application] Failed to decorate similar content candidate",
error,
);
}
}
return video;
};
const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null;
if (!target) {
return [];
}
@@ -2095,35 +2115,36 @@ class Application {
const results = [];
for (const candidate of candidateSource) {
if (!candidate || typeof candidate !== "object") {
const decoratedCandidate = decorateCandidate(candidate);
if (!decoratedCandidate || typeof decoratedCandidate !== "object") {
continue;
}
if (candidate === target) {
if (decoratedCandidate === target) {
continue;
}
const candidateId = typeof candidate.id === "string" ? candidate.id : "";
const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : "";
if (candidateId && candidateId === activeId) {
continue;
}
if (candidate.deleted === true) {
if (decoratedCandidate.deleted === true) {
continue;
}
if (candidate.isPrivate === true) {
if (decoratedCandidate.isPrivate === true) {
continue;
}
if (candidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) {
if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) {
continue;
}
const candidatePubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : "";
const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : "";
if (candidatePubkey && this.isAuthorBlocked(candidatePubkey)) {
continue;
}
const candidateTagsSource = Array.isArray(candidate.displayTags) && candidate.displayTags.length
? candidate.displayTags
: collectVideoTags(candidate);
const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length
? decoratedCandidate.displayTags
: collectVideoTags(decoratedCandidate);
if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) {
continue;
}
@@ -2176,26 +2197,26 @@ class Application {
: [];
}
let postedAt = this.getKnownVideoPostedAt(candidate);
if (!Number.isFinite(postedAt) && Number.isFinite(candidate.rootCreatedAt)) {
postedAt = Math.floor(candidate.rootCreatedAt);
let postedAt = this.getKnownVideoPostedAt(decoratedCandidate);
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) {
postedAt = Math.floor(decoratedCandidate.rootCreatedAt);
}
if (!Number.isFinite(postedAt) && Number.isFinite(candidate.created_at)) {
postedAt = Math.floor(candidate.created_at);
if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) {
postedAt = Math.floor(decoratedCandidate.created_at);
}
if (!Number.isFinite(postedAt)) {
postedAt = null;
}
let shareUrl = "";
if (typeof candidate.shareUrl === "string" && candidate.shareUrl.trim()) {
shareUrl = candidate.shareUrl.trim();
if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) {
shareUrl = decoratedCandidate.shareUrl.trim();
} else if (candidateId) {
shareUrl = this.buildShareUrlFromEventId(candidateId) || "";
}
results.push({
video: candidate,
video: decoratedCandidate,
pointerInfo: pointerInfo || null,
shareUrl,
postedAt,
+353 -50
View File
@@ -16,6 +16,10 @@ import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderer
import { ALLOW_NSFW_CONTENT } from "./config.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import moderationService from "./services/moderationService.js";
import {
applyModerationContextDatasets,
normalizeVideoModerationContext,
} from "./ui/moderationUiHelpers.js";
import {
calculateZapShares,
describeShareType,
@@ -64,72 +68,336 @@ function normalizeHex(value) {
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : "";
}
function shouldBlurChannelMedia(pubkey) {
function decorateChannelVideo(video, app = getApp()) {
if (!video || typeof video !== "object") {
return null;
}
if (typeof app?.decorateVideoModeration === "function") {
try {
const decorated = app.decorateVideoModeration(video);
if (decorated && typeof decorated === "object") {
return decorated;
}
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to decorate channel video moderation",
error,
);
}
}
return video;
}
function collectChannelVideos(pubkey, app = getApp()) {
if (!app || !(app.videosMap instanceof Map)) {
return [];
}
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
return [];
}
const matches = [];
app.videosMap.forEach((video) => {
if (!video || typeof video !== "object") {
return;
}
const videoPubkey = normalizeHex(video.pubkey);
if (videoPubkey === normalized) {
matches.push(video);
}
});
return matches;
}
function buildChannelModerationFallback(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return null;
}
let viewerMuted = false;
try {
if (moderationService.isAuthorMutedByViewer(normalized) === true) {
return true;
viewerMuted = moderationService.isAuthorMutedByViewer(normalized) === true;
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to resolve viewer mute state for channel",
error,
);
}
let trustedMuted = false;
let trustedMuters = [];
try {
if (typeof moderationService.isAuthorMutedByTrusted === "function") {
trustedMuted =
moderationService.isAuthorMutedByTrusted(normalized) === true;
}
if (trustedMuted) {
if (typeof moderationService.getTrustedMutersForAuthor === "function") {
const muters =
moderationService.getTrustedMutersForAuthor(normalized) || [];
if (Array.isArray(muters)) {
trustedMuters = muters;
}
}
}
} catch (error) {
devLogger.warn("[ChannelProfile] Failed to resolve viewer mute state", error);
devLogger.warn(
"[ChannelProfile] Failed to resolve trusted mute state for channel",
error,
);
trustedMuted = false;
trustedMuters = [];
}
const app = getApp();
const videos =
app?.videosMap instanceof Map ? Array.from(app.videosMap.values()) : [];
if (!viewerMuted && !trustedMuted) {
return null;
}
for (const video of videos) {
if (!video || typeof video !== "object") {
continue;
}
const videoPubkey =
typeof video.pubkey === "string" ? video.pubkey.trim().toLowerCase() : "";
if (videoPubkey !== normalized) {
const trustedMuteCount = Array.isArray(trustedMuters)
? trustedMuters.length
: 0;
const fallback = {
trustedMuted,
trustedMuters: Array.isArray(trustedMuters) ? trustedMuters.slice() : [],
trustedMuteCount,
};
if (viewerMuted) {
fallback.blurThumbnail = true;
fallback.blurReason = "viewer-mute";
fallback.hidden = true;
fallback.hideReason = "viewer-mute";
fallback.hideCounts = {
trustedMuteCount,
trustedReportCount: 0,
};
} else if (trustedMuted) {
fallback.blurThumbnail = true;
fallback.blurReason = "trusted-mute";
}
fallback.original = {
blockAutoplay: fallback.blockAutoplay === true,
blurThumbnail: fallback.blurThumbnail === true,
hidden: fallback.hidden === true,
hideReason: fallback.hideReason || "",
hideCounts: fallback.hideCounts ? { ...fallback.hideCounts } : undefined,
hideBypass: fallback.hideBypass || "",
hideTriggered:
fallback.hidden === true || Boolean(fallback.hideReason) || false,
blurReason: fallback.blurReason || "",
};
return fallback;
}
function resolveChannelModeration(pubkey, app = getApp()) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return null;
}
const matches = collectChannelVideos(normalized, app);
let fallbackModeration = null;
for (const candidate of matches) {
const decorated = decorateChannelVideo(candidate, app);
if (!decorated || typeof decorated !== "object") {
continue;
}
const moderation =
video.moderation && typeof video.moderation === "object"
? video.moderation
decorated.moderation && typeof decorated.moderation === "object"
? decorated.moderation
: null;
if (!moderation) {
continue;
}
if (moderation.hidden === true) {
return true;
if (moderation.viewerOverride?.showAnyway === true) {
return moderation;
}
if (moderation.blurThumbnail === true) {
const reason =
typeof moderation.blurReason === "string"
? moderation.blurReason.trim().toLowerCase()
: "";
if (reason.startsWith("trusted-")) {
return true;
}
if (moderation.hidden === true || moderation.blurThumbnail === true) {
return moderation;
}
if (!fallbackModeration) {
fallbackModeration = moderation;
}
}
return false;
if (fallbackModeration) {
return fallbackModeration;
}
return buildChannelModerationFallback(normalized);
}
function applyChannelVisualBlur({ bannerEl = null, avatarEl = null, pubkey = "" } = {}) {
const shouldBlur = shouldBlurChannelMedia(pubkey);
const applyState = (element) => {
if (!element) {
return;
}
if (shouldBlur) {
element.dataset.visualState = "blurred";
} else if (element.dataset.visualState) {
delete element.dataset.visualState;
}
};
export function applyChannelVisualBlur({
bannerEl = null,
avatarEl = null,
pubkey = "",
app = null,
} = {}) {
const resolvedApp = app || getApp();
const moderation = resolveChannelModeration(pubkey, resolvedApp) || {};
const context = normalizeVideoModerationContext(moderation);
applyState(bannerEl);
applyState(avatarEl);
const resolvedBanner = bannerEl ||
(typeof document !== "undefined"
? document.getElementById("channelBanner")
: null);
const resolvedAvatar = avatarEl ||
(typeof document !== "undefined"
? document.getElementById("channelAvatar")
: null);
const avatarElements = [resolvedAvatar, resolvedBanner].filter(Boolean);
applyModerationContextDatasets(context, {
root: resolvedBanner || null,
avatar: resolvedAvatar || null,
avatars: avatarElements.length > 1 ? avatarElements : undefined,
});
}
function updateChannelModerationVisuals({
pubkey = "",
app = null,
bannerEl = null,
avatarEl = null,
} = {}) {
if (typeof document === "undefined") {
return;
}
const resolvedApp = app || getApp();
const targetPubkey = pubkey || currentChannelHex || "";
if (!targetPubkey) {
applyChannelVisualBlur({
bannerEl,
avatarEl,
pubkey: "",
app: resolvedApp,
});
return;
}
const resolvedBanner =
bannerEl || document.getElementById("channelBanner");
const resolvedAvatar =
avatarEl || document.getElementById("channelAvatar");
applyChannelVisualBlur({
bannerEl: resolvedBanner,
avatarEl: resolvedAvatar,
pubkey: targetPubkey,
app: resolvedApp,
});
}
let channelModerationEventsBound = false;
const channelModerationUnsubscribes = [];
function handleTrustedMuteSignal() {
if (!currentChannelHex) {
return;
}
updateChannelModerationVisuals({ pubkey: currentChannelHex });
}
function handleModerationSummarySignal(detail = {}) {
if (!detail || typeof detail !== "object") {
return;
}
const eventId = typeof detail.eventId === "string" ? detail.eventId : "";
if (!eventId) {
return;
}
const app = getApp();
const video = app?.videosMap instanceof Map ? app.videosMap.get(eventId) : null;
if (!video || typeof video !== "object") {
return;
}
const videoPubkey = normalizeHex(video.pubkey);
if (!videoPubkey) {
return;
}
const channelPubkey = normalizeHex(currentChannelHex);
if (!channelPubkey || videoPubkey !== channelPubkey) {
return;
}
decorateChannelVideo(video, app);
updateChannelModerationVisuals({
pubkey: channelPubkey,
app,
});
}
function handleVideoModerationEvent(event) {
const detail = event?.detail;
const video = detail && typeof detail === "object" ? detail.video : null;
if (!video || typeof video !== "object") {
return;
}
const videoPubkey = normalizeHex(video.pubkey);
const channelPubkey = normalizeHex(currentChannelHex);
if (!videoPubkey || !channelPubkey || videoPubkey !== channelPubkey) {
return;
}
const app = getApp();
decorateChannelVideo(video, app);
if (app?.videosMap instanceof Map && typeof video.id === "string") {
app.videosMap.set(video.id, video);
}
updateChannelModerationVisuals({
pubkey: channelPubkey,
app,
});
}
function ensureChannelModerationEvents() {
if (channelModerationEventsBound) {
return;
}
channelModerationEventsBound = true;
if (typeof document !== "undefined") {
document.addEventListener("video:moderation-override", handleVideoModerationEvent);
document.addEventListener("video:moderation-hide", handleVideoModerationEvent);
}
if (moderationService && typeof moderationService.on === "function") {
const trustedUnsub = moderationService.on(
"trusted-mutes",
handleTrustedMuteSignal,
);
if (typeof trustedUnsub === "function") {
channelModerationUnsubscribes.push(trustedUnsub);
}
const summaryUnsub = moderationService.on(
"summary",
handleModerationSummarySignal,
);
if (typeof summaryUnsub === "function") {
channelModerationUnsubscribes.push(summaryUnsub);
}
}
}
const summarizeZapTracker = (tracker) =>
@@ -2461,6 +2729,10 @@ export async function initChannelProfileView() {
const app = getApp();
ensureChannelModerationEvents();
updateChannelModerationVisuals({ app, pubkey: currentChannelHex });
setupChannelShareButton();
setupChannelMoreMenu();
@@ -2512,6 +2784,28 @@ export async function initChannelProfileView() {
await Promise.allSettled(pendingTasks);
}
export function __setChannelProfileTestState({ pubkey, npub } = {}) {
if (typeof pubkey !== "undefined") {
if (typeof pubkey === "string" && pubkey) {
currentChannelHex = pubkey;
} else {
currentChannelHex = null;
}
}
if (typeof npub !== "undefined") {
if (typeof npub === "string" && npub) {
currentChannelNpub = npub;
} else {
currentChannelNpub = null;
}
}
}
export function __ensureChannelModerationEventsForTests() {
ensureChannelModerationEvents();
}
function setupZapButton({ force = false } = {}) {
const zapButton = getChannelZapButton();
if (!zapButton) {
@@ -3123,7 +3417,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) {
return videos;
}
function renderChannelVideosFromList({
export function renderChannelVideosFromList({
videos = [],
container,
app,
@@ -3254,11 +3548,14 @@ function renderChannelVideosFromList({
const allowNsfw = ALLOW_NSFW_CONTENT === true;
videos.forEach((video) => {
if (!video || !video.id || !video.title) {
return;
for (const originalVideo of videos) {
if (!originalVideo || !originalVideo.id || !originalVideo.title) {
continue;
}
const decoratedVideo = decorateChannelVideo(originalVideo, app) || originalVideo;
const video = decoratedVideo;
const normalizedVideoPubkey = normalizePubkey(video.pubkey);
const canEdit =
Boolean(normalizedVideoPubkey) &&
@@ -3266,11 +3563,11 @@ function renderChannelVideosFromList({
normalizedVideoPubkey === normalizedViewerPubkey;
if (video.isPrivate && !canEdit) {
return;
continue;
}
if (!allowNsfw && video?.isNsfw === true && !canEdit) {
return;
continue;
}
app?.videosMap?.set(video.id, video);
@@ -3447,7 +3744,7 @@ function renderChannelVideosFromList({
if (cardEl) {
fragment.appendChild(cardEl);
}
});
}
if (renderIndex === 0) {
if (allowEmptyMessage) {
@@ -3473,6 +3770,8 @@ function renderChannelVideosFromList({
app?.attachMoreMenuHandlers?.(container);
updateChannelModerationVisuals({ app });
return true;
}
@@ -3561,7 +3860,11 @@ function applyChannelProfileMetadata({
}
const blurPubkey = pubkey || currentChannelHex || "";
applyChannelVisualBlur({ bannerEl, avatarEl, pubkey: blurPubkey });
updateChannelModerationVisuals({
bannerEl,
avatarEl,
pubkey: blurPubkey,
});
const nameEl = document.getElementById("channelName");
if (nameEl) {
@@ -0,0 +1,225 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
renderChannelVideosFromList,
applyChannelVisualBlur,
__setChannelProfileTestState,
__ensureChannelModerationEventsForTests,
} from "../../js/channelProfile.js";
import { nostrClient } from "../../js/nostrClientFacade.js";
import moderationService from "../../js/services/moderationService.js";
import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs";
function setupDom(t) {
const dom = new JSDOM(
"<!DOCTYPE html><html><body>" +
'<div id="channelBanner"></div>' +
'<img id="channelAvatar" />' +
'<div id="channelVideoList"></div>' +
"</body></html>",
{ url: "https://example.invalid/channel" },
);
const { window } = dom;
const { document } = window;
const previousGlobals = {
window: globalThis.window,
document: globalThis.document,
HTMLElement: globalThis.HTMLElement,
CustomEvent: globalThis.CustomEvent,
Event: globalThis.Event,
Node: globalThis.Node,
navigator: globalThis.navigator,
location: globalThis.location,
MutationObserver: globalThis.MutationObserver,
IntersectionObserver: globalThis.IntersectionObserver,
};
globalThis.window = window;
globalThis.document = document;
globalThis.HTMLElement = window.HTMLElement;
globalThis.CustomEvent = window.CustomEvent;
globalThis.Event = window.Event;
globalThis.Node = window.Node;
globalThis.navigator = window.navigator;
globalThis.location = window.location;
globalThis.MutationObserver = window.MutationObserver;
globalThis.IntersectionObserver =
window.IntersectionObserver ||
class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
};
t.after(() => {
Object.entries(previousGlobals).forEach(([key, value]) => {
if (typeof value === "undefined") {
delete globalThis[key];
} else {
globalThis[key] = value;
}
});
});
return { document, window };
}
function createVideoFixture(overrides = {}) {
return {
id: overrides.id || "v".repeat(64),
title: overrides.title || "Fixture Video",
pubkey: overrides.pubkey || "a".repeat(64),
created_at: overrides.created_at || Math.floor(Date.now() / 1000),
url: overrides.url || "https://example.invalid/video.mp4",
moderation: overrides.moderation || {},
...overrides,
};
}
test("renderChannelVideosFromList decorates videos before storing", 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 = () => {};
const originalDecorate = app.decorateVideoModeration;
let decoratedCount = 0;
app.decorateVideoModeration = function decorated(video) {
decoratedCount += 1;
if (!video.moderation || typeof video.moderation !== "object") {
video.moderation = {};
}
video.moderation.decoratedByTest = true;
return originalDecorate.call(this, video);
};
const container = document.getElementById("channelVideoList");
const video = createVideoFixture();
const rendered = renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
assert.equal(rendered, true);
assert.equal(decoratedCount, 1);
const stored = app.videosMap.get(video.id);
assert.ok(stored, "decorated video stored in videosMap");
assert.equal(stored.moderation.decoratedByTest, true);
});
test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
const pubkey = "b".repeat(64);
const originalMute = moderationService.isAuthorMutedByViewer;
moderationService.isAuthorMutedByViewer = () => true;
t.after(() => {
moderationService.isAuthorMutedByViewer = originalMute;
});
applyChannelVisualBlur({
bannerEl,
avatarEl,
pubkey,
app: { videosMap: new Map() },
});
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
});
test("moderation override clears channel blur via event wiring", 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,
});
const channelPubkey = "c".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
__ensureChannelModerationEventsForTests();
const container = document.getElementById("channelVideoList");
const moderationState = {
trustedCount: 2,
summary: {
types: { nudity: { trusted: 2 } },
},
};
const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState });
renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
const cardStub = { refreshModerationUi() {} };
app.handleModerationOverride({ video, card: cardStub });
assert.equal(bannerEl.dataset.visualState, undefined);
assert.equal(avatarEl.dataset.visualState, undefined);
});