diff --git a/js/app.js b/js/app.js index b22fcbc0..358b8ca6 100644 --- a/js/app.js +++ b/js/app.js @@ -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: { diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index d7c968c2..ff8218b6 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -9,6 +9,7 @@ export { createDedupeByRootStage, createBlacklistFilterStage, createWatchHistorySuppressionStage, + createModerationStage, } from "./stages.js"; export { createChronologicalSorter } from "./sorters.js"; export { diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 934a8e54..804d1ece 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -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; + }; +} diff --git a/js/services/moderationService.js b/js/services/moderationService.js new file mode 100644 index 00000000..061b5178 --- /dev/null +++ b/js/services/moderationService.js @@ -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; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 9e76b154..ddfab67d 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -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; } diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 262b814b..55c614d5 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -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) => { diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 7cf7135f..7d82566e 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -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 ` + + `; + }).join(""); + + return ` +
+