mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
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:
2 files changed
+239
-9
No files matched your search
+149
-6
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user