mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #633 from PR0M3TH3AN/codex/add-moderation-service-and-reporting-features
Add moderation service and reporting UI flow
This commit is contained in:
7 files changed
+1446
-3
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
createDedupeByRootStage,
|
||||
createBlacklistFilterStage,
|
||||
createWatchHistorySuppressionStage,
|
||||
createModerationStage,
|
||||
} from "./stages.js";
|
||||
export { createChronologicalSorter } from "./sorters.js";
|
||||
export {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user