Add moderation service and reporting flow

This commit is contained in:
thePR0M3TH3AN committed 2025-10-17 15:33:09 -04:00
1 parent 8b08dc676a
commit 071a8a3671
7 files changed
+1446 -3

No files matched your search

+11
View File
@@ -27,6 +27,7 @@ import {
createActiveNostrSource,
createBlacklistFilterStage,
createDedupeByRootStage,
createModerationStage,
createChronologicalSorter,
createSubscriptionAuthorsSource,
registerWatchHistoryFeed,
@@ -3701,6 +3702,10 @@ class Application {
}
autoplayModalVideo() {
if (this.currentVideo?.moderation?.blockAutoplay) {
this.log("[moderation] Skipping autoplay due to trusted reports.");
return;
}
if (!this.modalVideo) return;
this.modalVideo.play().catch((err) => {
this.log("Autoplay failed:", err);
@@ -3863,6 +3868,9 @@ class Application {
createDedupeByRootStage({
dedupe: (videos) => this.dedupeVideosByRoot(videos),
}),
createModerationStage({
getService: () => this.nostrService.getModerationService(),
}),
],
sorter: createChronologicalSorter(),
});
@@ -3896,6 +3904,9 @@ class Application {
createDedupeByRootStage({
dedupe: (videos) => this.dedupeVideosByRoot(videos),
}),
createModerationStage({
getService: () => this.nostrService.getModerationService(),
}),
],
sorter: createChronologicalSorter(),
hooks: {
+1
View File
@@ -9,6 +9,7 @@ export {
createDedupeByRootStage,
createBlacklistFilterStage,
createWatchHistorySuppressionStage,
createModerationStage,
} from "./stages.js";
export { createChronologicalSorter } from "./sorters.js";
export {
+137
View File
@@ -2,6 +2,7 @@
import { getApplication } from "../applicationContext.js";
import nostrService from "../services/nostrService.js";
import moderationService from "../services/moderationService.js";
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
import { isPlainObject, toSet } from "./utils.js";
@@ -187,3 +188,139 @@ export function createWatchHistorySuppressionStage({
return results;
};
}
export function createModerationStage({
stageName = "moderation",
autoplayThreshold = 2,
blurThreshold = 3,
reportType = "nudity",
service = null,
getService,
} = {}) {
const normalizedAutoplayThreshold = Number.isFinite(autoplayThreshold)
? Math.max(0, Math.floor(autoplayThreshold))
: 2;
const normalizedBlurThreshold = Number.isFinite(blurThreshold)
? Math.max(0, Math.floor(blurThreshold))
: 3;
const normalizedReportType = typeof reportType === "string" ? reportType.trim().toLowerCase() : "nudity";
return async function moderationStage(items = [], context = {}) {
let resolvedService = null;
try {
resolvedService =
typeof getService === "function"
? getService({ context })
: service || nostrService.getModerationService?.() || moderationService;
} catch (error) {
context?.log?.(`[${stageName}] Failed to resolve moderation service`, error);
resolvedService = service || moderationService;
}
if (!resolvedService) {
return items;
}
try {
if (typeof resolvedService.refreshViewerFromClient === "function") {
await resolvedService.refreshViewerFromClient();
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to refresh viewer context`, error);
}
const activeIds = new Set();
for (const item of items) {
const videoId = typeof item?.video?.id === "string" ? item.video.id : "";
if (videoId) {
activeIds.add(videoId);
}
}
try {
if (typeof resolvedService.setActiveEventIds === "function") {
await resolvedService.setActiveEventIds(activeIds);
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to prime report subscriptions`, error);
}
const results = [];
for (const item of items) {
if (!item || typeof item !== "object") {
results.push(item);
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoId = typeof video.id === "string" ? video.id : "";
let summary = null;
let trustedCount = 0;
if (videoId) {
try {
if (typeof resolvedService.getTrustedReportSummary === "function") {
summary = resolvedService.getTrustedReportSummary(videoId);
}
if (typeof resolvedService.trustedReportCount === "function") {
trustedCount = resolvedService.trustedReportCount(videoId, normalizedReportType) || 0;
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to fetch moderation summary`, error);
summary = null;
trustedCount = 0;
}
}
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
const blurThumbnail = trustedCount >= normalizedBlurThreshold;
if (!item.metadata || typeof item.metadata !== "object") {
item.metadata = {};
}
const metadataModeration =
item.metadata.moderation && typeof item.metadata.moderation === "object"
? { ...item.metadata.moderation }
: {};
metadataModeration.blockAutoplay = blockAutoplay;
metadataModeration.blurThumbnail = blurThumbnail;
metadataModeration.summary = summary;
item.metadata.moderation = metadataModeration;
if (!video.moderation || typeof video.moderation !== "object") {
video.moderation = {};
}
video.moderation.blockAutoplay = blockAutoplay;
video.moderation.blurThumbnail = blurThumbnail;
if (summary) {
video.moderation.summary = summary;
} else if (video.moderation.summary) {
delete video.moderation.summary;
}
if (blockAutoplay || blurThumbnail) {
context?.addWhy?.({
stage: stageName,
type: "moderation",
reason: blurThumbnail ? "blur" : "autoplay-block",
videoId: videoId || null,
reportType: normalizedReportType,
trustedCount,
});
}
results.push(item);
}
return results;
};
}
+784
View File
@@ -0,0 +1,784 @@
import { nostrClient } from "../nostr.js";
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
import logger from "../utils/logger.js";
class SimpleEventEmitter {
constructor(logHandler = null) {
this.listeners = new Map();
this.logHandler = typeof logHandler === "function" ? logHandler : null;
}
on(eventName, handler) {
if (typeof eventName !== "string" || typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logHandler) {
try {
this.logHandler(`moderationService listener for "${eventName}" threw`, error);
} catch (logError) {
logger.user.warn("[moderationService] listener logger threw", logError);
}
}
}
}
}
}
function normalizeLogger(candidate) {
if (typeof candidate === "function") {
return candidate;
}
if (candidate && typeof candidate.log === "function") {
return (...args) => candidate.log(...args);
}
if (
candidate &&
typeof candidate.dev === "object" &&
typeof candidate.dev.log === "function"
) {
return (...args) => candidate.dev.log(...args);
}
return () => {};
}
function normalizeHex(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed && /^[0-9a-f]{40,64}$/i.test(trimmed) ? trimmed : "";
}
function normalizeEventId(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed && /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed : "";
}
function normalizeReportType(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed ? trimmed : "";
}
function cloneSummary(summary) {
if (!summary || typeof summary !== "object") {
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
}
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
const clonedTypes = {};
for (const [key, value] of Object.entries(types)) {
if (!value || typeof value !== "object") {
continue;
}
clonedTypes[key] = {
trusted: Number.isFinite(value.trusted) ? value.trusted : 0,
total: Number.isFinite(value.total) ? value.total : 0,
latest: Number.isFinite(value.latest) ? value.latest : 0,
};
}
return {
eventId: typeof summary.eventId === "string" ? summary.eventId : "",
totalTrusted: Number.isFinite(summary.totalTrusted) ? summary.totalTrusted : 0,
types: clonedTypes,
updatedAt: Number.isFinite(summary.updatedAt) ? summary.updatedAt : 0,
};
}
function resolveRelayList(client, { write = false } = {}) {
if (!client) {
return [];
}
const source = write ? client.writeRelays : client.relays;
const fallback = client.relays;
const raw = Array.isArray(source) && source.length ? source : fallback;
if (!Array.isArray(raw)) {
return [];
}
const urls = [];
const seen = new Set();
for (const entry of raw) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
urls.push(trimmed);
}
return urls;
}
function findReportedEventId(event) {
if (!event || !Array.isArray(event.tags)) {
return "";
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] !== "e") {
continue;
}
const candidate = normalizeEventId(tag[1]);
if (candidate) {
return candidate;
}
}
return "";
}
function extractReportType(event, targetEventId = "") {
if (!event || !Array.isArray(event.tags)) {
return "";
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const [marker, value] = tag;
if (marker === "report" || marker === "type") {
const normalized = normalizeReportType(value);
if (normalized) {
return normalized;
}
}
}
if (targetEventId) {
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "e") {
continue;
}
if (normalizeEventId(tag[1]) !== targetEventId) {
continue;
}
const typeCandidate = normalizeReportType(tag[2] || "");
if (typeCandidate) {
return typeCandidate;
}
}
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "t") {
continue;
}
const normalized = normalizeReportType(tag[1] || "");
if (normalized) {
return normalized;
}
}
return "";
}
function ensureNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export class ModerationService {
constructor({ nostrClient: client = null, logger: log = null } = {}) {
this.nostrClient = client;
this.log = normalizeLogger(log);
this.viewerPubkey = "";
this.trustedContacts = new Set();
this.contactSubscription = null;
this.contactListPromise = null;
this.reportEvents = new Map();
this.reportSummaries = new Map();
this.activeSubscriptions = new Map();
this.activeEventIds = new Set();
this.emitter = new SimpleEventEmitter((message, error) => {
try {
this.log(message, error);
} catch (logError) {
logger.user.warn("[moderationService] logger threw", logError);
}
});
}
setLogger(newLogger) {
this.log = normalizeLogger(newLogger);
}
setNostrClient(client) {
if (client && client !== this.nostrClient) {
this.nostrClient = client;
}
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
emit(eventName, detail) {
this.emitter.emit(eventName, detail);
}
async ensurePool() {
if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") {
throw new Error("nostr-client-unavailable");
}
return this.nostrClient.ensurePool();
}
async refreshViewerFromClient() {
const clientPubkey = normalizeHex(this.nostrClient?.pubkey);
if (clientPubkey === this.viewerPubkey) {
if (this.contactListPromise) {
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact hydration failed", error);
}
}
return this.viewerPubkey;
}
return this.setViewerPubkey(clientPubkey);
}
async setViewerPubkey(pubkey) {
const normalized = normalizeHex(pubkey);
if (normalized === this.viewerPubkey && this.contactListPromise) {
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact hydration failed", error);
}
return this.viewerPubkey;
}
this.viewerPubkey = normalized;
this.trustedContacts.clear();
this.recomputeAllSummaries();
if (this.contactSubscription && typeof this.contactSubscription.unsub === "function") {
try {
this.contactSubscription.unsub();
} catch (error) {
this.log("[moderationService] failed to teardown contact subscription", error);
}
}
this.contactSubscription = null;
if (!normalized) {
return this.viewerPubkey;
}
this.contactListPromise = this.fetchTrustedContacts(normalized)
.catch((error) => {
this.log("[moderationService] failed to load contact list", error);
return null;
})
.finally(() => {
this.contactListPromise = null;
});
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact promise rejected", error);
}
this.subscribeToContactList(normalized).catch((error) => {
this.log("[moderationService] failed to subscribe to contact list", error);
});
return this.viewerPubkey;
}
async fetchTrustedContacts(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
this.trustedContacts.clear();
this.emit("contacts", { size: 0 });
return;
}
try {
await this.ensurePool();
} catch (error) {
this.log("[moderationService] ensurePool failed while fetching contacts", error);
return;
}
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
this.trustedContacts.clear();
this.emit("contacts", { size: 0 });
return;
}
const filter = { kinds: [3], authors: [normalized], limit: 1 };
let events = [];
try {
events = await this.nostrClient.pool.list(relays, [filter]);
} catch (error) {
this.log("[moderationService] failed to list contact events", error);
events = [];
}
if (!Array.isArray(events) || !events.length) {
this.trustedContacts.clear();
this.emit("contacts", { size: 0 });
this.recomputeAllSummaries();
return;
}
let latest = events[0];
for (const event of events) {
if (!event || typeof event.created_at !== "number") {
continue;
}
if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) {
latest = event;
}
}
this.applyContactEvent(latest);
}
applyContactEvent(event) {
if (!event || !Array.isArray(event.tags)) {
this.trustedContacts.clear();
this.emit("contacts", { size: 0 });
this.recomputeAllSummaries();
return;
}
const nextSet = new Set();
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "p") {
continue;
}
const candidate = normalizeHex(tag[1]);
if (candidate) {
nextSet.add(candidate);
}
}
this.trustedContacts = nextSet;
this.emit("contacts", { size: nextSet.size });
this.recomputeAllSummaries();
}
async subscribeToContactList(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
try {
await this.ensurePool();
} catch (error) {
this.log("[moderationService] ensurePool failed while subscribing to contacts", error);
return;
}
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
return;
}
const filter = { kinds: [3], authors: [normalized], limit: 1 };
try {
const sub = this.nostrClient.pool.sub(relays, [filter]);
sub.on("event", (event) => {
this.applyContactEvent(event);
});
sub.on("eose", () => {});
this.contactSubscription = {
unsub: typeof sub.unsub === "function" ? () => sub.unsub() : null,
};
} catch (error) {
this.log("[moderationService] failed to create contact subscription", error);
}
}
async setActiveEventIds(ids = []) {
const nextIds = new Set();
if (Array.isArray(ids) || ids instanceof Set) {
for (const value of ids) {
const normalized = normalizeEventId(value);
if (normalized) {
nextIds.add(normalized);
}
}
}
for (const existing of Array.from(this.activeSubscriptions.keys())) {
if (!nextIds.has(existing)) {
this.teardownReportSubscription(existing);
}
}
const tasks = [];
for (const id of nextIds) {
if (this.activeSubscriptions.has(id)) {
const entry = this.activeSubscriptions.get(id);
if (entry?.promise) {
tasks.push(entry.promise.catch(() => {}));
}
continue;
}
const promise = this.subscribeToReports(id);
tasks.push(promise.catch((error) => {
this.log(`(moderationService) failed to subscribe to reports for ${id}`, error);
}));
}
this.activeEventIds = nextIds;
if (tasks.length) {
await Promise.all(tasks);
}
}
teardownReportSubscription(eventId) {
const normalized = normalizeEventId(eventId);
const entry = this.activeSubscriptions.get(normalized);
if (!entry) {
return;
}
if (typeof entry.unsub === "function") {
try {
entry.unsub();
} catch (error) {
this.log("[moderationService] failed to unsubscribe from report stream", error);
}
}
this.activeSubscriptions.delete(normalized);
}
async subscribeToReports(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return;
}
const record = { unsub: null, promise: null };
this.activeSubscriptions.set(normalized, record);
record.promise = (async () => {
await this.ensurePool();
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
return;
}
const filter = { kinds: [1984], "#e": [normalized], limit: 500 };
let events = [];
try {
events = await this.nostrClient.pool.list(relays, [filter]);
} catch (error) {
this.log(`(moderationService) failed to backfill reports for ${normalized}`, error);
events = [];
}
if (Array.isArray(events)) {
for (const event of events) {
this.ingestReportEvent(event);
}
}
try {
const sub = this.nostrClient.pool.sub(relays, [filter]);
sub.on("event", (event) => {
this.ingestReportEvent(event);
});
sub.on("eose", () => {});
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
} catch (error) {
this.log(`(moderationService) failed to subscribe to reports for ${normalized}`, error);
}
})();
try {
await record.promise;
} catch (error) {
this.log("[moderationService] report subscription promise rejected", error);
} finally {
record.promise = null;
}
}
ingestReportEvent(event) {
if (!event || event.kind !== 1984) {
return;
}
const reporter = normalizeHex(event.pubkey);
if (!reporter) {
return;
}
const targetEventId = findReportedEventId(event);
const normalizedEventId = normalizeEventId(targetEventId);
if (!normalizedEventId) {
return;
}
const reportType = extractReportType(event, normalizedEventId);
if (!reportType) {
return;
}
let eventReports = this.reportEvents.get(normalizedEventId);
if (!eventReports) {
eventReports = new Map();
this.reportEvents.set(normalizedEventId, eventReports);
}
let reporterEntry = eventReports.get(reporter);
if (!reporterEntry) {
reporterEntry = new Map();
eventReports.set(reporter, reporterEntry);
}
const existing = reporterEntry.get(reportType);
const createdAt = ensureNumber(event.created_at);
if (!existing || createdAt >= ensureNumber(existing.created_at)) {
reporterEntry.set(reportType, {
created_at: createdAt,
id: typeof event.id === "string" ? event.id : "",
});
}
this.recomputeSummaryForEvent(normalizedEventId);
}
recomputeAllSummaries() {
for (const eventId of this.reportEvents.keys()) {
this.recomputeSummaryForEvent(eventId);
}
}
recomputeSummaryForEvent(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return;
}
const eventReports = this.reportEvents.get(normalized);
if (!eventReports || !eventReports.size) {
this.reportSummaries.delete(normalized);
this.emit("summary", { eventId: normalized, summary: null });
return;
}
const typeStats = new Map();
let totalTrusted = 0;
for (const [reporter, typeMap] of eventReports.entries()) {
const isTrusted = this.trustedContacts.has(reporter);
for (const [type, detail] of typeMap.entries()) {
let stats = typeStats.get(type);
if (!stats) {
stats = { trusted: 0, total: 0, latest: 0 };
typeStats.set(type, stats);
}
stats.total += 1;
const createdAt = ensureNumber(detail?.created_at);
if (createdAt > stats.latest) {
stats.latest = createdAt;
}
if (isTrusted) {
stats.trusted += 1;
totalTrusted += 1;
}
}
}
const types = {};
for (const [type, stats] of typeStats.entries()) {
types[type] = {
trusted: stats.trusted,
total: stats.total,
latest: stats.latest,
};
}
const summary = {
eventId: normalized,
totalTrusted,
types,
updatedAt: Date.now(),
};
this.reportSummaries.set(normalized, summary);
this.emit("summary", { eventId: normalized, summary: cloneSummary(summary) });
}
getTrustedReportSummary(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
}
const summary = this.reportSummaries.get(normalized);
if (!summary) {
return { eventId: normalized, totalTrusted: 0, types: {}, updatedAt: 0 };
}
return cloneSummary(summary);
}
trustedReportCount(eventId, type) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
return 0;
}
const summary = this.reportSummaries.get(normalizedEventId);
if (!summary) {
return 0;
}
const normalizedType = normalizeReportType(type);
const entry = normalizedType ? summary.types[normalizedType] : null;
if (!entry) {
return 0;
}
return Number.isFinite(entry.trusted) ? entry.trusted : 0;
}
async submitReport({ eventId, type, targetPubkey = "", content = "" } = {}) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
const error = new Error("invalid-event-id");
error.code = "invalid-event-id";
throw error;
}
const normalizedType = normalizeReportType(type);
if (!normalizedType) {
const error = new Error("invalid-type");
error.code = "invalid-type";
throw error;
}
const reporterPubkey = normalizeHex(this.nostrClient?.pubkey);
if (!reporterPubkey) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension) {
const error = new Error("nostr-extension-missing");
error.code = "nostr-extension-missing";
throw error;
}
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
"sign_event",
"get_public_key",
]);
if (!permissionResult?.ok) {
const error = new Error("extension-permission-denied");
error.code = "extension-permission-denied";
error.details = permissionResult?.error || null;
throw error;
}
}
const tags = [
["e", normalizedEventId, "", normalizedType],
["report", normalizedType],
["t", normalizedType],
];
const normalizedTarget = normalizeHex(targetPubkey);
if (normalizedTarget) {
tags.push(["p", normalizedTarget]);
}
const event = {
kind: 1984,
created_at: Math.floor(Date.now() / 1000),
tags,
content: typeof content === "string" ? content : "",
pubkey: reporterPubkey,
};
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
const wrapped = new Error("signature-failed");
wrapped.code = "signature-failed";
wrapped.details = error;
throw wrapped;
}
await this.ensurePool();
const relays = resolveRelayList(this.nostrClient, { write: true });
if (!relays.length) {
const error = new Error("no-relays-configured");
error.code = "no-relays-configured";
throw error;
}
let results = [];
try {
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
assertAnyRelayAccepted(results, { context: "report" });
} catch (error) {
const wrapped = new Error("report-publish-failed");
wrapped.code = "report-publish-failed";
wrapped.details = error;
throw wrapped;
}
this.ingestReportEvent(signedEvent);
return { ok: true, event: signedEvent, results };
}
}
const moderationService = new ModerationService({
nostrClient,
logger,
});
export default moderationService;
+37
View File
@@ -5,6 +5,7 @@ import {
import { accessControl } from "../accessControl.js";
import { ALLOW_NSFW_CONTENT } from "../config.js";
import { userLogger } from "../utils/logger.js";
import moderationService from "./moderationService.js";
import {
getVideosMap as getStoredVideosMap,
setVideosMap as setStoredVideosMap,
@@ -113,6 +114,14 @@ export class NostrService {
}
});
this.videosMap = null;
this.moderationService = moderationService || null;
try {
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
this.moderationService.setNostrClient(this.nostrClient);
}
} catch (error) {
userLogger.warn("[nostrService] Failed to attach moderation service", error);
}
}
log(...args) {
@@ -131,6 +140,29 @@ export class NostrService {
this.emitter.emit(eventName, detail);
}
getModerationService() {
if (!this.moderationService) {
return null;
}
try {
if (
typeof this.moderationService.setNostrClient === "function" &&
this.moderationService.nostrClient !== this.nostrClient
) {
this.moderationService.setNostrClient(this.nostrClient);
}
if (typeof this.moderationService.refreshViewerFromClient === "function") {
this.moderationService.refreshViewerFromClient();
}
} catch (error) {
userLogger.warn("[nostrService] Failed to synchronize moderation service", error);
}
return this.moderationService;
}
ensureVideosMap() {
if (this.videosMap instanceof Map) {
return this.videosMap;
@@ -518,6 +550,11 @@ export class NostrService {
}
isViewerVideoAuthor(video) {
try {
this.getModerationService();
} catch (error) {
userLogger.warn("[nostrService] Failed to refresh moderation context", error);
}
if (!video || typeof video !== "object") {
return false;
}
+56 -1
View File
@@ -328,6 +328,7 @@ export class VideoCard {
this.applyPointerDataset();
this.applyOwnerDataset();
this.applySourceDatasets();
this.applyModerationDatasets();
const anchor = this.createElement("a", {
classNames: [
@@ -516,7 +517,9 @@ export class VideoCard {
this.thumbnailEl = img;
if (this.shouldMaskNsfwForOwner) {
const shouldBlurForModeration = this.video?.moderation?.blurThumbnail === true;
if (this.shouldMaskNsfwForOwner || shouldBlurForModeration) {
img.dataset.thumbnailState = "blurred";
}
@@ -1317,6 +1320,58 @@ export class VideoCard {
updateVideoCardSourceVisibility(this.root);
}
applyModerationDatasets() {
if (!this.root) {
return;
}
const moderation =
this.video?.moderation && typeof this.video.moderation === "object"
? this.video.moderation
: null;
const blockAutoplay = moderation?.blockAutoplay === true;
const blurThumbnail = moderation?.blurThumbnail === true;
const summary =
moderation && typeof moderation.summary === "object"
? moderation.summary
: null;
if (blockAutoplay) {
this.root.dataset.autoplayPolicy = "blocked";
} else if (this.root.dataset.autoplayPolicy === "blocked") {
delete this.root.dataset.autoplayPolicy;
}
if (this.thumbnailEl && !this.shouldMaskNsfwForOwner) {
if (blurThumbnail) {
this.thumbnailEl.dataset.thumbnailState = "blurred";
} else if (this.thumbnailEl.dataset.thumbnailState === "blurred") {
delete this.thumbnailEl.dataset.thumbnailState;
}
}
let nudityTrustedCount = 0;
if (summary && summary.types && typeof summary.types === "object") {
const nudityEntry = summary.types.nudity || summary.types["nudity"];
if (nudityEntry && Number.isFinite(nudityEntry.trusted)) {
nudityTrustedCount = nudityEntry.trusted;
}
}
if (nudityTrustedCount > 0) {
this.root.dataset.moderationReportType = "nudity";
this.root.dataset.moderationReportCount = String(nudityTrustedCount);
} else {
if (this.root.dataset.moderationReportType) {
delete this.root.dataset.moderationReportType;
}
if (this.root.dataset.moderationReportCount) {
delete this.root.dataset.moderationReportCount;
}
}
}
applyPlaybackDatasets() {
const elements = [this.anchorEl, this.titleEl].filter(Boolean);
elements.forEach((el) => {
+420 -2
View File
@@ -1,9 +1,25 @@
import { normalizeDesignSystemContext } from "../designSystem.js";
import { userLogger } from "../utils/logger.js";
import logger, { userLogger } from "../utils/logger.js";
import moderationService from "../services/moderationService.js";
import {
createVideoMoreMenuPanel,
} from "./components/videoMenuRenderers.js";
import createPopover from "./overlay/popoverEngine.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./components/staticModalAccessibility.js";
const REPORT_CATEGORIES = Object.freeze([
{ value: "nudity", label: "Nudity or sexual content" },
{ value: "spam", label: "Spam or misleading" },
{ value: "illegal", label: "Illegal or dangerous content" },
{ value: "impersonation", label: "Impersonation or scam" },
{ value: "malware", label: "Malware or phishing" },
{ value: "profanity", label: "Profanity or hate speech" },
{ value: "other", label: "Other" },
]);
export default class MoreMenuController {
constructor(options = {}) {
@@ -25,6 +41,7 @@ export default class MoreMenuController {
this.clipboard = clipboard;
this.isDevMode = Boolean(isDevMode);
this.designSystem = normalizeDesignSystemContext(designSystem);
this.moderationService = options?.moderationService || moderationService;
this.callbacks = {
getCurrentVideo: callbacks.getCurrentVideo || (() => null),
@@ -62,6 +79,17 @@ export default class MoreMenuController {
this.boundVideoListBlacklistHandler = null;
this.popoversByTrigger = new Map();
this.activePopover = null;
this.reportModal = null;
this.reportModalPrepared = false;
this.reportForm = null;
this.reportStatusEl = null;
this.reportSubmitButton = null;
this.reportCancelButton = null;
this.reportCategoryInputs = [];
this.reportCleanupHandlers = [];
this.activeReportContext = null;
this.isReportSubmitting = false;
}
setVideoModal(videoModal) {
@@ -167,6 +195,7 @@ export default class MoreMenuController {
destroy() {
this.detachVideoListView();
this.setVideoModal(null);
this.destroyReportModal();
this.closeAllMoreMenus({ skipView: true });
@@ -188,6 +217,364 @@ export default class MoreMenuController {
this.moreMenuGlobalHandlerBound = true;
}
buildReportModalMarkup() {
const options = REPORT_CATEGORIES.map((category, index) => {
const inputId = `reportCategory-${category.value}`;
const checkedAttr = index === 0 ? " checked" : "";
const requiredAttr = index === 0 ? " required" : "";
return `
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-border/60 bg-panel/80 p-3 text-sm" for="${inputId}">
<input
id="${inputId}"
type="radio"
name="report-category"
value="${category.value}"
class="h-4 w-4"
${checkedAttr}${requiredAttr}
/>
<span>${category.label}</span>
</label>
`;
}).join("");
return `
<div class="bv-modal-backdrop" data-report-dismiss></div>
<div class="modal-sheet w-full max-w-md space-y-4" role="document">
<form data-report-form class="modal-body space-y-4">
<header class="space-y-2">
<h2 id="reportModalTitle" class="text-lg font-semibold text-text">Report video</h2>
<p id="reportModalDescription" class="text-sm text-muted">
Choose the category that best describes the issue. Reports from people you follow help improve recommendations.
</p>
</header>
<fieldset class="space-y-2">
<legend class="text-sm font-semibold text-text">Category</legend>
<div class="space-y-2" role="radiogroup" aria-labelledby="reportModalTitle">
${options}
</div>
</fieldset>
<p class="hidden text-sm" data-report-status role="status"></p>
<div class="flex justify-end gap-3">
<button type="button" class="btn-ghost focus-ring" data-report-cancel>Cancel</button>
<button type="submit" class="btn focus-ring" data-report-submit>Submit report</button>
</div>
</form>
</div>
`;
}
ensureReportModal() {
if (this.reportModal && this.reportModalPrepared) {
return this.reportModal;
}
const doc = this.document;
if (!doc) {
return null;
}
const container = doc.getElementById("modalContainer") || doc.body || null;
if (!container) {
return null;
}
let modal = container.querySelector("#reportModal");
if (!modal) {
modal = doc.createElement("div");
modal.id = "reportModal";
modal.className = "bv-modal hidden items-center justify-center";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "reportModalTitle");
modal.setAttribute("aria-describedby", "reportModalDescription");
modal.innerHTML = this.buildReportModalMarkup();
container.appendChild(modal);
}
this.reportModal = modal;
this.reportForm = modal.querySelector("[data-report-form]");
this.reportStatusEl = modal.querySelector("[data-report-status]");
this.reportSubmitButton = modal.querySelector("[data-report-submit]");
this.reportCancelButton = modal.querySelector("[data-report-cancel]");
this.reportCategoryInputs = Array.from(
modal.querySelectorAll("input[name='report-category']"),
);
if (!this.reportModalPrepared) {
if (this.reportForm) {
const submitHandler = (event) => this.handleReportSubmit(event);
this.reportForm.addEventListener("submit", submitHandler);
this.reportCleanupHandlers.push(() => {
this.reportForm?.removeEventListener("submit", submitHandler);
});
}
if (this.reportCancelButton) {
const cancelHandler = (event) => {
event?.preventDefault?.();
this.closeReportModal();
};
this.reportCancelButton.addEventListener("click", cancelHandler);
this.reportCleanupHandlers.push(() => {
this.reportCancelButton?.removeEventListener("click", cancelHandler);
});
}
const backdrop = modal.querySelector("[data-report-dismiss]");
if (backdrop) {
const backdropHandler = (event) => {
event?.preventDefault?.();
this.closeReportModal();
};
backdrop.addEventListener("click", backdropHandler);
this.reportCleanupHandlers.push(() => {
backdrop.removeEventListener("click", backdropHandler);
});
}
prepareStaticModal({ root: modal, document: doc });
this.reportModalPrepared = true;
}
return modal;
}
openReportModal({ eventId, authorPubkey = "", trigger = null } = {}) {
const modal = this.ensureReportModal();
if (!modal) {
this.callbacks.showError(
"Reporting is unavailable right now. Please try again later.",
);
return false;
}
this.activeReportContext = {
eventId: typeof eventId === "string" ? eventId : "",
authorPubkey: typeof authorPubkey === "string" ? authorPubkey : "",
};
this.isReportSubmitting = false;
this.setReportModalBusy(false);
this.showReportStatus("");
if (Array.isArray(this.reportCategoryInputs) && this.reportCategoryInputs.length) {
this.reportCategoryInputs.forEach((input, index) => {
if (!input) {
return;
}
try {
input.checked = index === 0;
input.disabled = false;
} catch (_) {
/* noop */
}
});
}
const opened = openStaticModal(modal, {
triggerElement:
trigger && typeof trigger.focus === "function" ? trigger : null,
document: this.document,
});
if (opened && this.reportCategoryInputs.length > 0) {
const firstInput = this.reportCategoryInputs[0];
try {
firstInput.focus();
} catch (_) {
/* noop */
}
}
try {
const service = this.moderationService || moderationService;
if (service && typeof service.refreshViewerFromClient === "function") {
service.refreshViewerFromClient();
}
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh moderation context before reporting:",
error,
);
}
}
return opened;
}
closeReportModal() {
if (!this.reportModal) {
return false;
}
const result = closeStaticModal(this.reportModal, { document: this.document });
this.activeReportContext = null;
this.isReportSubmitting = false;
this.showReportStatus("");
return result;
}
setReportModalBusy(isBusy) {
const disabled = Boolean(isBusy);
this.isReportSubmitting = disabled;
if (Array.isArray(this.reportCategoryInputs)) {
this.reportCategoryInputs.forEach((input) => {
if (input && typeof input.disabled === "boolean") {
input.disabled = disabled;
}
});
}
if (this.reportSubmitButton && typeof this.reportSubmitButton.disabled === "boolean") {
this.reportSubmitButton.disabled = disabled;
}
if (this.reportCancelButton && typeof this.reportCancelButton.disabled === "boolean") {
this.reportCancelButton.disabled = disabled;
}
}
showReportStatus(message, { variant = "info" } = {}) {
if (!this.reportStatusEl) {
return;
}
const trimmed = typeof message === "string" ? message.trim() : "";
if (!trimmed) {
this.reportStatusEl.classList.add("hidden");
this.reportStatusEl.textContent = "";
if (this.reportStatusEl.dataset.state) {
delete this.reportStatusEl.dataset.state;
}
return;
}
this.reportStatusEl.classList.remove("hidden");
this.reportStatusEl.textContent = trimmed;
this.reportStatusEl.dataset.state = variant;
}
showReportError(message) {
this.showReportStatus(message, { variant: "error" });
}
async handleReportSubmit(event) {
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
if (this.isReportSubmitting) {
return;
}
const context = this.activeReportContext || {};
const eventId = typeof context.eventId === "string" ? context.eventId : "";
if (!eventId) {
this.showReportError("Unable to determine which video to report.");
return;
}
let category = "";
if (Array.isArray(this.reportCategoryInputs)) {
const selected = this.reportCategoryInputs.find((input) => input && input.checked);
if (selected && typeof selected.value === "string") {
category = selected.value.trim().toLowerCase();
}
}
if (!category) {
this.showReportError("Please choose a category before submitting.");
return;
}
const viewerPubkey = this.callbacks.getCurrentUserPubkey?.();
if (!viewerPubkey) {
this.closeReportModal();
this.callbacks.showError("Connect a Nostr account before reporting videos.");
return;
}
this.setReportModalBusy(true);
this.showReportStatus("Sending report…", { variant: "info" });
const service = this.moderationService || moderationService;
try {
if (!service || typeof service.submitReport !== "function") {
const svcError = new Error("service-unavailable");
svcError.code = "service-unavailable";
throw svcError;
}
await service.submitReport({
eventId,
type: category,
targetPubkey: context.authorPubkey || "",
});
this.callbacks.showSuccess(
"Report submitted. Thank you for keeping bitvid safe.",
);
this.closeReportModal();
} catch (error) {
logger.user.error("[MoreMenu] Failed to publish report:", error);
const code = error?.code || "unknown";
let message = "Unable to send your report. Please try again.";
if (code === "nostr-extension-missing") {
message = "A Nostr extension is required to report videos.";
} else if (code === "extension-permission-denied") {
message = "Approve the permission request in your Nostr extension and try again.";
} else if (code === "viewer-not-logged-in") {
message = "Connect a Nostr account before reporting videos.";
} else if (code === "service-unavailable") {
message = "Reporting is unavailable right now. Please try again later.";
}
this.showReportError(message);
} finally {
this.setReportModalBusy(false);
}
}
destroyReportModal() {
if (Array.isArray(this.reportCleanupHandlers) && this.reportCleanupHandlers.length) {
for (const cleanup of this.reportCleanupHandlers) {
try {
if (typeof cleanup === "function") {
cleanup();
}
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to teardown report modal listener:",
error,
);
}
}
}
}
this.reportCleanupHandlers = [];
this.reportModalPrepared = false;
this.reportForm = null;
this.reportStatusEl = null;
this.reportSubmitButton = null;
this.reportCancelButton = null;
this.reportCategoryInputs = [];
this.activeReportContext = null;
this.isReportSubmitting = false;
if (this.reportModal) {
try {
closeStaticModal(this.reportModal, { document: this.document });
} catch (_) {
/* noop */
}
}
this.reportModal = null;
}
getHTMLElementConstructor(documentRef) {
return (
documentRef?.defaultView?.HTMLElement ||
@@ -814,7 +1201,38 @@ export default class MoreMenuController {
break;
}
case "report": {
this.callbacks.showSuccess("Reporting coming soon.");
const viewerPubkey = this.callbacks.getCurrentUserPubkey?.();
if (!viewerPubkey) {
this.callbacks.showError(
"Connect a Nostr account before reporting videos.",
);
break;
}
const eventId =
dataset.eventId ||
(context === "modal" ? currentVideo?.id : "") ||
currentVideo?.id || "";
if (!eventId) {
this.callbacks.showError(
"Unable to determine which video to report.",
);
break;
}
const authorPubkey =
dataset.author ||
(context === "modal" && currentVideo?.pubkey
? currentVideo.pubkey
: "") ||
currentVideo?.pubkey || "";
this.openReportModal({
eventId,
authorPubkey,
trigger: dataset.triggerElement || null,
});
break;
}
default: