Merge pull request #909 from PR0M3TH3AN/codex/add-moderation-helper-for-video-feeds

Apply moderation stage to channel profile videos
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-25 21:25:12 -04:00
commit fb33f79a65
2 files changed
+239 -9

No files matched your search

+149 -6
View File
@@ -16,6 +16,13 @@ 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 { createModerationStage } from "./feedEngine/stages.js";
import {
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
DEFAULT_BLUR_THRESHOLD,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
} from "./constants.js";
import {
applyModerationContextDatasets,
normalizeVideoModerationContext,
@@ -134,6 +141,112 @@ function collectChannelVideos(pubkey, app = getApp()) {
return matches;
}
async function applyChannelModerationToVideos(videos = [], app = getApp()) {
if (!Array.isArray(videos) || videos.length === 0) {
return Array.isArray(videos) ? videos : [];
}
const resolvedApp = app || getApp();
const resolveThreshold = (value, fallback) => {
if (value === Number.POSITIVE_INFINITY) {
return Number.POSITIVE_INFINITY;
}
if (Number.isFinite(value)) {
return Math.max(0, Math.floor(value));
}
return fallback;
};
const thresholds =
typeof resolvedApp?.getActiveModerationThresholds === "function"
? resolvedApp.getActiveModerationThresholds()
: null;
const stage = createModerationStage({
getService: () => {
try {
if (
resolvedApp?.nostrService &&
typeof resolvedApp.nostrService.getModerationService === "function"
) {
return resolvedApp.nostrService.getModerationService();
}
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to resolve channel moderation service",
error,
);
}
return null;
},
autoplayThreshold: resolveThreshold(
thresholds?.autoplayBlockThreshold,
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
),
blurThreshold: resolveThreshold(
thresholds?.blurThreshold,
DEFAULT_BLUR_THRESHOLD,
),
trustedMuteHideThreshold: resolveThreshold(
thresholds?.trustedMuteHideThreshold,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
),
trustedReportHideThreshold: resolveThreshold(
thresholds?.trustedSpamHideThreshold,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
),
});
let stageResult;
try {
const items = videos.map((video) => ({ video, metadata: {} }));
stageResult = await stage(items, { feedName: "channel-profile" });
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to apply moderation stage to channel videos",
error,
);
return videos;
}
if (!Array.isArray(stageResult)) {
return videos;
}
const decoratedVideos = [];
for (const item of stageResult) {
if (!item || typeof item !== "object") {
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
continue;
}
const metadataModeration =
item.metadata &&
typeof item.metadata === "object" &&
item.metadata.moderation &&
typeof item.metadata.moderation === "object"
? item.metadata.moderation
: null;
if (metadataModeration) {
if (!video.moderation || typeof video.moderation !== "object") {
video.moderation = {};
}
Object.assign(video.moderation, metadataModeration);
}
decoratedVideos.push(decorateChannelVideo(video, resolvedApp) || video);
}
return decoratedVideos;
}
function buildChannelModerationFallback(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
@@ -3819,7 +3932,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) {
return videos;
}
export function renderChannelVideosFromList({
export async function renderChannelVideosFromList({
videos = [],
container,
app,
@@ -3839,6 +3952,34 @@ export function renderChannelVideosFromList({
return false;
}
let preparedVideos = videos;
try {
preparedVideos = await applyChannelModerationToVideos(videos, app);
} catch (error) {
devLogger.warn(
"[ChannelProfile] Failed to prepare channel videos for moderation",
error,
);
preparedVideos = videos;
}
if (loadToken !== currentVideoLoadToken) {
return false;
}
const moderationApplied =
Array.isArray(preparedVideos) && preparedVideos !== videos;
const renderableVideos = Array.isArray(preparedVideos) ? preparedVideos : videos;
if (!renderableVideos.length) {
if (allowEmptyMessage) {
container.dataset.hasChannelVideos = "false";
container.innerHTML = `<p class="text-muted-strong">No videos to display.</p>`;
return true;
}
return false;
}
container.dataset.hasChannelVideos = "true";
container.innerHTML = "";
@@ -3950,13 +4091,15 @@ export function renderChannelVideosFromList({
const allowNsfw = ALLOW_NSFW_CONTENT === true;
for (const originalVideo of videos) {
for (const originalVideo of renderableVideos) {
if (!originalVideo || !originalVideo.id || !originalVideo.title) {
continue;
}
const decoratedVideo = decorateChannelVideo(originalVideo, app) || originalVideo;
const video = decoratedVideo;
const video =
moderationApplied
? originalVideo
: decorateChannelVideo(originalVideo, app) || originalVideo;
const normalizedVideoPubkey = normalizePubkey(video.pubkey);
const canEdit =
@@ -4549,7 +4692,7 @@ async function loadUserVideos(pubkey) {
app
});
if (cachedVideos.length) {
const rendered = renderChannelVideosFromList({
const rendered = await renderChannelVideosFromList({
videos: cachedVideos,
container,
app,
@@ -4632,7 +4775,7 @@ async function loadUserVideos(pubkey) {
}
const videos = buildRenderableChannelVideos({ events, app });
const rendered = renderChannelVideosFromList({
const rendered = await renderChannelVideosFromList({
videos,
container,
app,
+90 -3
View File
@@ -129,7 +129,7 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) =>
const container = document.getElementById("channelVideoList");
const video = createVideoFixture();
const rendered = renderChannelVideosFromList({
const rendered = await renderChannelVideosFromList({
videos: [video],
container,
app,
@@ -143,6 +143,93 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) =>
assert.equal(stored.moderation.decoratedByTest, true);
});
test("renderChannelVideosFromList applies moderation blur without existing metadata", 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: Number.POSITIVE_INFINITY,
trustedMuteHideThreshold: Number.POSITIVE_INFINITY,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
const stubModerationService = {
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return null;
},
getAccessControlStatus() {
return { hex: "", whitelisted: false, blacklisted: false };
},
getTrustedReportSummary() {
return { types: { nudity: { trusted: 2 } } };
},
trustedReportCount() {
return 2;
},
getTrustedReporters() {
return [];
},
isAuthorMutedByTrusted() {
return false;
},
};
app.nostrService = {
getModerationService() {
return stubModerationService;
},
};
const container = document.getElementById("channelVideoList");
const channelPubkey = "e".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
const video = createVideoFixture({ pubkey: channelPubkey });
delete video.moderation;
const rendered = await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
assert.equal(rendered, true, "channel render succeeded with moderation stage");
const stored = app.videosMap.get(video.id);
assert.ok(stored, "video stored in app map after moderation");
assert.equal(stored.moderation?.blurThumbnail, true, "moderation blur applied");
const thumbnail = container.querySelector('[data-video-thumbnail="true"]');
assert.ok(thumbnail, "video card thumbnail rendered");
assert.equal(thumbnail.dataset.thumbnailState, "blurred");
const bannerEl = document.getElementById("channelBanner");
assert.equal(bannerEl.dataset.visualState, "blurred");
});
test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
@@ -216,7 +303,7 @@ test("moderation override clears channel blur via event wiring", async (t) => {
moderation: moderationState,
});
renderChannelVideosFromList({
await renderChannelVideosFromList({
videos: [video],
container,
app,
@@ -290,7 +377,7 @@ test("channel header moderation badge reflects blur state and override actions",
};
const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState });
renderChannelVideosFromList({
await renderChannelVideosFromList({
videos: [video],
container,
app,