mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Replaces the per-video live report subscription (one pool.sub per visible video × ~20 relays, re-fired on every reconnect — the ~1000 REQ/s storm) with ONE batched SubscriptionManager subscription over the whole active id set, updated in place as the set changes. Backfill fetches history only for newly-added ids (one batched list()). Unchanged sets are a no-op (dedup guard). MODERATION_REPORT_SUBS_ENABLED flipped back on. Trusted-mute subs were already batched-by-author, so the relay cap + contact-loop fix already tamed them. Legacy per-id path retained only for SubscriptionManager-less environments (unit tests / mock clients). All 39 moderation tests pass; new cheat-resistant batching test added; nip07-heat shows reports no longer storm (steady idle). Co-Authored-By: Claude Opus 4.8 <[email protected]>
2291 lines
66 KiB
JavaScript
2291 lines
66 KiB
JavaScript
/**
|
||
* @fileoverview
|
||
* Moderation Service - Decentralized Trust & Safety.
|
||
*
|
||
* Implements a "Web of Trust" model where content visibility and safety actions (blur, hide)
|
||
* are determined by aggregating reports (NIP-56) and mutes (NIP-51) from the user's
|
||
* trusted social graph (follows).
|
||
*
|
||
* Flow:
|
||
* 1. setViewerPubkey(pubkey) loads the user's Kind 3 contacts -> trustedContacts.
|
||
* 2. subscribeToReports(eventId) listens for Kind 1984 reports.
|
||
* 3. ingestReportEvent(event) stores reports in memory.
|
||
* 4. recomputeSummaryForEvent(eventId) checks if reporter is in trustedContacts.
|
||
* 5. If trusted reports > threshold, emits 'summary' event to update UI.
|
||
*/
|
||
|
||
import {
|
||
getActiveSigner,
|
||
nostrClient,
|
||
requestDefaultExtensionPermissions,
|
||
} from "../nostrClientFacade.js";
|
||
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
|
||
import { accessControl } from "../accessControl.js";
|
||
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
|
||
import { buildReportEvent, KIND_MUTE_LIST } from "../nostrEventSchemas.js";
|
||
import logger from "../utils/logger.js";
|
||
import { DEBOUNCE_DELAY_MS } from "../constants.js";
|
||
import {
|
||
normalizeUserLogger,
|
||
SimpleEventEmitter,
|
||
normalizeLogger,
|
||
normalizeHex,
|
||
normalizeEventId,
|
||
normalizeReportType,
|
||
normalizeMuteCategory,
|
||
extractMuteCategoryFromTag,
|
||
cloneSummary,
|
||
encodeToNpub,
|
||
normalizeToHex,
|
||
createEmptyAdminSnapshot,
|
||
resolveRelayList,
|
||
findReportedEventId,
|
||
extractReportType,
|
||
ensureNumber,
|
||
resolveStorage,
|
||
AUTOPLAY_TRUST_THRESHOLD,
|
||
BLUR_TRUST_THRESHOLD,
|
||
TRUSTED_MUTE_WINDOW_SECONDS,
|
||
decodeToHex
|
||
} from "./moderationUtils.js";
|
||
|
||
/**
|
||
* ModerationService
|
||
*
|
||
* Central authority for Trust & Safety.
|
||
* Implements a "Web of Trust" model where content visibility and safety actions (blur, hide)
|
||
* are determined by aggregating reports (NIP-56) and mutes (NIP-51) from the user's
|
||
* trusted social graph (follows).
|
||
*/
|
||
|
||
// Live kind-1984 report subscriptions. Previously one sub PER VIDEO fanned
|
||
// O(videos × relays) and stormed relays (~1000 REQ/s). Now batched into a single
|
||
// SubscriptionManager subscription over the whole active id set, so this is back
|
||
// on. (Set false to fall back to the disabled state for debugging.)
|
||
const MODERATION_REPORT_SUBS_ENABLED = true;
|
||
|
||
// Max event ids per kind-1984 filter chunk. nostr REQ allows multiple filters in
|
||
// one subscription, so a large active set becomes a few chunks in ONE sub —
|
||
// still O(1) subscriptions, not O(ids).
|
||
const REPORT_BATCH_SIZE = 200;
|
||
|
||
function setsEqual(a, b) {
|
||
if (a === b) return true;
|
||
if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false;
|
||
for (const v of a) {
|
||
if (!b.has(v)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Build kind-1984 report filters for a set of event ids, chunked so no single
|
||
// filter carries an unbounded "#e" array.
|
||
function buildReportFilters(ids) {
|
||
const list = Array.isArray(ids) ? ids : Array.from(ids || []);
|
||
const filters = [];
|
||
for (let i = 0; i < list.length; i += REPORT_BATCH_SIZE) {
|
||
filters.push({
|
||
kinds: [1984],
|
||
"#e": list.slice(i, i + REPORT_BATCH_SIZE),
|
||
limit: 500,
|
||
});
|
||
}
|
||
return filters;
|
||
}
|
||
|
||
export class ModerationService {
|
||
/**
|
||
* @param {object} options
|
||
* @param {object} [options.nostrClient] Nostr client.
|
||
* @param {object} [options.logger] System logger.
|
||
* @param {object} [options.userBlocks] User block manager.
|
||
* @param {object} [options.accessControl] Admin access control.
|
||
* @param {object} [options.userLogger] User-facing logger.
|
||
* @param {function} [options.requestExtensionPermissions] NIP-07 helper.
|
||
*/
|
||
constructor({
|
||
nostrClient: client = null,
|
||
logger: log = null,
|
||
userBlocks: userBlockManager = null,
|
||
accessControl: accessControlService = null,
|
||
userLogger: userChannel = null,
|
||
requestExtensionPermissions: permissionRequester = null,
|
||
} = {}) {
|
||
this.nostrClient = client;
|
||
this.log = normalizeLogger(log);
|
||
this.userLogger = normalizeUserLogger(userChannel);
|
||
this.requestExtensionPermissions = permissionRequester || requestDefaultExtensionPermissions;
|
||
|
||
this.viewerPubkey = "";
|
||
this.viewerIsSessionActor = false;
|
||
this.trustedContacts = new Set();
|
||
this.trustedSeedContacts = new Set();
|
||
this.viewerContacts = new Set();
|
||
this.userBlocks = null;
|
||
this.accessControl = accessControlService;
|
||
|
||
this.viewerMuteList = new Set();
|
||
this.viewerMuteEventId = "";
|
||
this.viewerMuteUpdatedAt = 0;
|
||
this.viewerMutePromise = null;
|
||
|
||
this.contactSubscription = null;
|
||
this.contactListPromise = null;
|
||
this.lastContactEventId = null;
|
||
|
||
this.reportEvents = new Map();
|
||
this.reportSummaries = new Map();
|
||
this.activeSubscriptions = new Map();
|
||
this.activeEventIds = new Set();
|
||
|
||
this.trustedMuteLists = new Map();
|
||
this.trustedMutedAuthors = new Map();
|
||
this.trustedMuteSubscriptions = new Map(); // Kept for legacy/individual manual subs if needed, but primary logic is now batched
|
||
this.trustedSeedOnly = false;
|
||
|
||
this.batchedMuteSubscriptions = [];
|
||
this.muteRefreshTimer = null;
|
||
|
||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||
try {
|
||
this.log(message, error);
|
||
} catch (logError) {
|
||
logger.user.warn("[moderationService] logger threw", logError);
|
||
}
|
||
});
|
||
|
||
this.userBlockUnsubscribe = null;
|
||
this.userBlockRefreshQueue = Promise.resolve();
|
||
|
||
this.setUserBlocks(userBlockManager);
|
||
}
|
||
|
||
setUserLogger(channel) {
|
||
this.userLogger = normalizeUserLogger(channel);
|
||
}
|
||
|
||
mergeSeedsIntoSet(base = null) {
|
||
const merged = new Set();
|
||
|
||
if (base instanceof Set || Array.isArray(base)) {
|
||
for (const value of base) {
|
||
const normalized = normalizeHex(value);
|
||
if (normalized) {
|
||
merged.add(normalized);
|
||
}
|
||
}
|
||
} else if (base && typeof base[Symbol.iterator] === "function") {
|
||
for (const value of base) {
|
||
const normalized = normalizeHex(value);
|
||
if (normalized) {
|
||
merged.add(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.trustedSeedContacts instanceof Set) {
|
||
const adminSnapshot = this.getAdminListSnapshot();
|
||
const blacklistHex = adminSnapshot.blacklistHex;
|
||
for (const seed of this.trustedSeedContacts) {
|
||
const normalized = normalizeHex(seed);
|
||
if (normalized && (!blacklistHex || !blacklistHex.has(normalized))) {
|
||
merged.add(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
computeTrustedSeedOnly() {
|
||
const trustedContacts =
|
||
this.trustedContacts instanceof Set ? this.trustedContacts : new Set();
|
||
const trustedSeedContacts =
|
||
this.trustedSeedContacts instanceof Set ? this.trustedSeedContacts : new Set();
|
||
|
||
if (!trustedSeedContacts.size || trustedContacts.size !== trustedSeedContacts.size) {
|
||
return false;
|
||
}
|
||
|
||
for (const value of trustedContacts) {
|
||
if (!trustedSeedContacts.has(value)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
updateTrustedSeedOnlyStatus() {
|
||
const nextValue = this.computeTrustedSeedOnly();
|
||
if (nextValue === this.trustedSeedOnly) {
|
||
return;
|
||
}
|
||
|
||
this.trustedSeedOnly = nextValue;
|
||
this.emit("trusted-seed-only", { seedOnly: nextValue });
|
||
}
|
||
|
||
rebuildTrustedContacts(nextContacts = new Set(), { previous = null } = {}) {
|
||
// Snapshot the trusted set BEFORE we rebuild so we can detect whether it
|
||
// actually changed. Emitting "contacts" unconditionally caused a tight loop:
|
||
// profileModalController listens for "contacts" -> populateFriendsList ->
|
||
// ensureViewerContactsLoaded -> (empty contacts) -> fetchTrustedContacts ->
|
||
// clearTrustedMuteTracking -> rebuildTrustedContacts(empty) -> emit again,
|
||
// spinning the CPU and flooding relays with kind-3 REQs on every login.
|
||
const priorTrusted =
|
||
this.trustedContacts instanceof Set
|
||
? new Set(this.trustedContacts)
|
||
: new Set();
|
||
const sanitized = new Set();
|
||
|
||
if (nextContacts instanceof Set || Array.isArray(nextContacts)) {
|
||
for (const value of nextContacts) {
|
||
const normalized = normalizeHex(value);
|
||
if (normalized) {
|
||
sanitized.add(normalized);
|
||
}
|
||
}
|
||
} else if (nextContacts && typeof nextContacts[Symbol.iterator] === "function") {
|
||
for (const value of nextContacts) {
|
||
const normalized = normalizeHex(value);
|
||
if (normalized) {
|
||
sanitized.add(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
this.viewerContacts = sanitized;
|
||
|
||
const previousContacts =
|
||
previous instanceof Set
|
||
? new Set(previous)
|
||
: this.trustedContacts instanceof Set
|
||
? new Set(this.trustedContacts)
|
||
: new Set();
|
||
|
||
const seededNext = this.mergeSeedsIntoSet(sanitized);
|
||
this.trustedContacts = seededNext;
|
||
|
||
this.updateTrustedSeedOnlyStatus();
|
||
|
||
// Only notify/recompute when the trusted set actually changed. This breaks
|
||
// the emit -> fetch -> emit loop above while still firing on real changes
|
||
// (first population, follows/unfollows, seed updates).
|
||
let changed = priorTrusted.size !== seededNext.size;
|
||
if (!changed) {
|
||
for (const value of seededNext) {
|
||
if (!priorTrusted.has(value)) {
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
this.emit("contacts", { size: seededNext.size });
|
||
this.recomputeAllSummaries();
|
||
}
|
||
this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext);
|
||
}
|
||
|
||
getViewerContactsSnapshot({ includeViewer = false } = {}) {
|
||
const contacts =
|
||
this.viewerContacts instanceof Set ? this.viewerContacts : new Set();
|
||
const viewer = normalizeHex(this.viewerPubkey);
|
||
const list = [];
|
||
|
||
for (const value of contacts) {
|
||
const normalized = normalizeHex(value);
|
||
if (!normalized) {
|
||
continue;
|
||
}
|
||
if (!includeViewer && viewer && normalized === viewer) {
|
||
continue;
|
||
}
|
||
list.push({
|
||
pubkey: normalized,
|
||
npub: encodeToNpub(normalized) || "",
|
||
});
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
async ensureViewerContactsLoaded(pubkey = null) {
|
||
const target = normalizeHex(pubkey);
|
||
const viewer = normalizeHex(this.viewerPubkey);
|
||
|
||
if (target && target !== viewer) {
|
||
try {
|
||
await this.setViewerPubkey(target);
|
||
} catch (error) {
|
||
this.log(
|
||
"[moderationService] failed to set viewer pubkey while ensuring contacts",
|
||
error,
|
||
);
|
||
}
|
||
return this.getViewerContactsSnapshot();
|
||
}
|
||
|
||
if (this.contactListPromise) {
|
||
try {
|
||
await this.contactListPromise;
|
||
} catch (error) {
|
||
this.log(
|
||
"[moderationService] contact hydration failed while ensuring contacts",
|
||
error,
|
||
);
|
||
}
|
||
} else if (viewer) {
|
||
const hasContacts =
|
||
this.viewerContacts instanceof Set && this.viewerContacts.size > 0;
|
||
if (!hasContacts) {
|
||
try {
|
||
await this.fetchTrustedContacts(viewer);
|
||
} catch (error) {
|
||
this.log(
|
||
"[moderationService] failed to fetch viewer contacts while ensuring contacts",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return this.getViewerContactsSnapshot();
|
||
}
|
||
|
||
setTrustedSeeds(seeds = []) {
|
||
this.log("[moderationService] setTrustedSeeds called", { count: seeds?.length || seeds?.size || 0 });
|
||
const sanitizedSeeds = new Set();
|
||
const adminSnapshot = this.getAdminListSnapshot();
|
||
|
||
if (seeds instanceof Set || Array.isArray(seeds)) {
|
||
for (const candidate of seeds) {
|
||
const normalized = normalizeToHex(candidate);
|
||
const status = this.getAccessControlStatus(candidate, adminSnapshot);
|
||
if (normalized && !status.blacklisted) {
|
||
sanitizedSeeds.add(normalized);
|
||
}
|
||
}
|
||
} else if (seeds && typeof seeds[Symbol.iterator] === "function") {
|
||
for (const candidate of seeds) {
|
||
const normalized = normalizeToHex(candidate);
|
||
const status = this.getAccessControlStatus(candidate, adminSnapshot);
|
||
if (normalized && !status.blacklisted) {
|
||
sanitizedSeeds.add(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
const previousContacts =
|
||
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
|
||
|
||
this.trustedSeedContacts = sanitizedSeeds;
|
||
this.rebuildTrustedContacts(this.viewerContacts, { previous: previousContacts });
|
||
}
|
||
|
||
isTrustedSeedOnly() {
|
||
return this.trustedSeedOnly === true;
|
||
}
|
||
|
||
logThresholdTransitions({
|
||
eventId = "",
|
||
reportType = "",
|
||
previousTrusted = 0,
|
||
nextTrusted = 0,
|
||
} = {}) {
|
||
const normalizedEventId = normalizeEventId(eventId);
|
||
const normalizedType = normalizeReportType(reportType);
|
||
if (!normalizedEventId || !normalizedType) {
|
||
return;
|
||
}
|
||
|
||
const previous = Number.isFinite(previousTrusted) ? previousTrusted : 0;
|
||
const next = Number.isFinite(nextTrusted) ? nextTrusted : 0;
|
||
|
||
const transitions = [];
|
||
|
||
if (AUTOPLAY_TRUST_THRESHOLD > 0) {
|
||
const previousMet = previous >= AUTOPLAY_TRUST_THRESHOLD;
|
||
const nextMet = next >= AUTOPLAY_TRUST_THRESHOLD;
|
||
if (previousMet !== nextMet) {
|
||
transitions.push({
|
||
action: nextMet ? "autoplay-block-enabled" : "autoplay-block-cleared",
|
||
});
|
||
}
|
||
}
|
||
|
||
if (BLUR_TRUST_THRESHOLD > 0) {
|
||
const previousMet = previous >= BLUR_TRUST_THRESHOLD;
|
||
const nextMet = next >= BLUR_TRUST_THRESHOLD;
|
||
if (previousMet !== nextMet) {
|
||
transitions.push({
|
||
action: nextMet ? "blur-enabled" : "blur-cleared",
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!transitions.length) {
|
||
return;
|
||
}
|
||
|
||
const channel = this.userLogger || logger.user;
|
||
for (const entry of transitions) {
|
||
try {
|
||
channel.info("[moderationService] moderation threshold crossed", {
|
||
eventId: normalizedEventId,
|
||
reportType: normalizedType,
|
||
trustedCount: next,
|
||
action: entry.action,
|
||
});
|
||
} catch (error) {
|
||
logger.user.warn("[moderationService] failed to log threshold transition", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
getTrustedMuteWindowCutoff(nowSeconds = Date.now() / 1000) {
|
||
const normalizedNow = ensureNumber(nowSeconds) || Date.now() / 1000;
|
||
return normalizedNow - TRUSTED_MUTE_WINDOW_SECONDS;
|
||
}
|
||
|
||
pruneTrustedMuteAggregates(targetAuthors = null, { nowSeconds = Date.now() / 1000 } = {}) {
|
||
const cutoff = this.getTrustedMuteWindowCutoff(nowSeconds);
|
||
const targets =
|
||
targetAuthors instanceof Set || Array.isArray(targetAuthors)
|
||
? Array.from(targetAuthors)
|
||
: targetAuthors && typeof targetAuthors[Symbol.iterator] === "function"
|
||
? Array.from(targetAuthors)
|
||
: null;
|
||
|
||
const entries = targets
|
||
? targets.map((author) => [author, this.trustedMutedAuthors.get(author)])
|
||
: Array.from(this.trustedMutedAuthors.entries());
|
||
|
||
for (const [author, aggregate] of entries) {
|
||
if (!aggregate || !(aggregate.muters instanceof Map)) {
|
||
continue;
|
||
}
|
||
|
||
// Throttle pruning: only run once per minute per author
|
||
if (aggregate.lastPrunedAt && nowSeconds - aggregate.lastPrunedAt < 60) continue;
|
||
aggregate.lastPrunedAt = nowSeconds;
|
||
|
||
const expiredOwners = new Set();
|
||
for (const [owner, updatedAt] of aggregate.muters.entries()) {
|
||
if (ensureNumber(updatedAt) < cutoff) {
|
||
aggregate.muters.delete(owner);
|
||
expiredOwners.add(owner);
|
||
}
|
||
}
|
||
if (aggregate.categories instanceof Map && expiredOwners.size) {
|
||
for (const [category, muters] of aggregate.categories.entries()) {
|
||
for (const owner of expiredOwners) {
|
||
muters.delete(owner);
|
||
}
|
||
if (!muters.size) {
|
||
aggregate.categories.delete(category);
|
||
}
|
||
}
|
||
}
|
||
aggregate.count = aggregate.muters.size;
|
||
if (!aggregate.muters.size) {
|
||
this.trustedMutedAuthors.delete(author);
|
||
}
|
||
}
|
||
}
|
||
|
||
getActiveTrustedMutersForAuthor(author) {
|
||
const normalized = normalizeHex(author);
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
|
||
const entry = this.trustedMutedAuthors.get(normalized);
|
||
if (!entry || !(entry.muters instanceof Map)) {
|
||
return [];
|
||
}
|
||
|
||
this.pruneTrustedMuteAggregates([normalized]);
|
||
const refreshed = this.trustedMutedAuthors.get(normalized);
|
||
if (!refreshed || !(refreshed.muters instanceof Map)) {
|
||
return [];
|
||
}
|
||
|
||
return Array.from(refreshed.muters.keys());
|
||
}
|
||
|
||
getTrustedMuteCountsForAuthor(author) {
|
||
const normalized = normalizeHex(author);
|
||
if (!normalized) {
|
||
return { total: 0, categories: {} };
|
||
}
|
||
|
||
const entry = this.trustedMutedAuthors.get(normalized);
|
||
if (!entry || !(entry.muters instanceof Map)) {
|
||
return { total: 0, categories: {} };
|
||
}
|
||
|
||
this.pruneTrustedMuteAggregates([normalized]);
|
||
const refreshed = this.trustedMutedAuthors.get(normalized);
|
||
if (!refreshed || !(refreshed.muters instanceof Map)) {
|
||
return { total: 0, categories: {} };
|
||
}
|
||
|
||
const categories = {};
|
||
if (refreshed.categories instanceof Map) {
|
||
for (const [category, muters] of refreshed.categories.entries()) {
|
||
if (typeof category !== "string" || !category.trim()) {
|
||
continue;
|
||
}
|
||
categories[category] = muters instanceof Map ? muters.size : 0;
|
||
}
|
||
}
|
||
|
||
return {
|
||
total: refreshed.muters.size,
|
||
categories,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Batch retrieval of moderation info for multiple authors.
|
||
* Optimized to prune aggregates in one pass.
|
||
*
|
||
* @param {string[]} authors - List of author pubkeys.
|
||
* @returns {Map<string, object>} Map of author -> { trustedMuted, trustedMuters, trustedMuteCountTotal, trustedMuteCountsByCategory }
|
||
*/
|
||
getModerationInfoForAuthors(authors) {
|
||
const results = new Map();
|
||
const uniqueAuthors = new Set();
|
||
|
||
if (Array.isArray(authors)) {
|
||
for (const author of authors) {
|
||
const normalized = normalizeHex(author);
|
||
if (normalized) {
|
||
uniqueAuthors.add(normalized);
|
||
}
|
||
}
|
||
} else if (authors instanceof Set) {
|
||
for (const author of authors) {
|
||
const normalized = normalizeHex(author);
|
||
if (normalized) {
|
||
uniqueAuthors.add(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!uniqueAuthors.size) {
|
||
return results;
|
||
}
|
||
|
||
this.pruneTrustedMuteAggregates(Array.from(uniqueAuthors));
|
||
|
||
for (const author of uniqueAuthors) {
|
||
const entry = this.trustedMutedAuthors.get(author);
|
||
let trustedMuted = false;
|
||
let trustedMuters = [];
|
||
let trustedMuteCountTotal = 0;
|
||
let trustedMuteCountsByCategory = null;
|
||
|
||
if (entry && entry.muters instanceof Map && entry.muters.size > 0) {
|
||
trustedMuted = true;
|
||
trustedMuters = Array.from(entry.muters.keys());
|
||
trustedMuteCountTotal = entry.muters.size;
|
||
|
||
if (entry.categories instanceof Map) {
|
||
trustedMuteCountsByCategory = {};
|
||
for (const [category, muters] of entry.categories.entries()) {
|
||
if (typeof category === "string" && category.trim()) {
|
||
trustedMuteCountsByCategory[category] = muters.size;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
results.set(author, {
|
||
trustedMuted,
|
||
trustedMuters,
|
||
trustedMuteCountTotal,
|
||
trustedMuteCountsByCategory,
|
||
});
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
setLogger(newLogger) {
|
||
this.log = normalizeLogger(newLogger);
|
||
}
|
||
|
||
setNostrClient(client) {
|
||
if (client && client !== this.nostrClient) {
|
||
this.nostrClient = client;
|
||
}
|
||
}
|
||
|
||
setUserBlocks(manager) {
|
||
if (manager === this.userBlocks) {
|
||
return;
|
||
}
|
||
|
||
if (typeof this.userBlockUnsubscribe === "function") {
|
||
try {
|
||
this.userBlockUnsubscribe();
|
||
} catch (error) {
|
||
this.log("[moderationService] failed to teardown user block listener", error);
|
||
}
|
||
}
|
||
|
||
this.userBlockUnsubscribe = null;
|
||
this.userBlocks = manager || null;
|
||
|
||
if (!this.userBlocks || typeof this.userBlocks.on !== "function") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const unsubscribe = this.userBlocks.on(
|
||
USER_BLOCK_EVENTS.CHANGE,
|
||
(detail) => {
|
||
this.queueUserBlockRefresh(detail);
|
||
},
|
||
);
|
||
|
||
if (typeof unsubscribe === "function") {
|
||
this.userBlockUnsubscribe = () => {
|
||
try {
|
||
unsubscribe();
|
||
} catch (error) {
|
||
this.log(
|
||
"[moderationService] failed to remove user block listener",
|
||
error,
|
||
);
|
||
}
|
||
};
|
||
}
|
||
} catch (error) {
|
||
this.log("[moderationService] failed to subscribe to user block events", error);
|
||
}
|
||
}
|
||
|
||
setAccessControl(control) {
|
||
if (control && control !== this.accessControl) {
|
||
this.accessControl = control;
|
||
}
|
||
}
|
||
|
||
on(eventName, handler) {
|
||
return this.emitter.on(eventName, handler);
|
||
}
|
||
|
||
emit(eventName, detail) {
|
||
this.emitter.emit(eventName, detail);
|
||
}
|
||
|
||
queueUserBlockRefresh(detail = {}) {
|
||
const previous = this.userBlockRefreshQueue || Promise.resolve();
|
||
const action = typeof detail?.action === "string" ? detail.action : "";
|
||
const targetPubkey = detail?.targetPubkey || detail?.pubkey || "";
|
||
const normalizedTarget = normalizeHex(targetPubkey);
|
||
|
||
const next = previous
|
||
.catch(() => {})
|
||
.then(async () => {
|
||
this.recomputeAllSummaries();
|
||
if (this.activeEventIds && this.activeEventIds.size) {
|
||
await this.refreshActiveReportSubscriptions();
|
||
}
|
||
this.emit("user-blocks", {
|
||
action,
|
||
targetPubkey: normalizedTarget,
|
||
});
|
||
});
|
||
|
||
this.userBlockRefreshQueue = next.catch((error) => {
|
||
this.log("[moderationService] failed to refresh after user block update", error);
|
||
throw error;
|
||
});
|
||
|
||
return this.userBlockRefreshQueue;
|
||
}
|
||
|
||
clearTrustedMuteTracking({ previousContacts = null } = {}) {
|
||
void previousContacts;
|
||
|
||
// Clear legacy individual subscriptions
|
||
for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) {
|
||
if (entry && typeof entry.unsub === "function") {
|
||
try {
|
||
entry.unsub();
|
||
} catch (error) {
|
||
this.log(`(moderationService) failed to teardown mute subscription for ${pubkey}`, error);
|
||
}
|
||
}
|
||
}
|
||
this.trustedMuteSubscriptions.clear();
|
||
|
||
// Clear batched subscriptions
|
||
this.teardownBatchedMuteSubscriptions();
|
||
|
||
if (this.muteRefreshTimer) {
|
||
clearTimeout(this.muteRefreshTimer);
|
||
this.muteRefreshTimer = null;
|
||
}
|
||
|
||
for (const [author, aggregate] of this.trustedMutedAuthors.entries()) {
|
||
if (aggregate && aggregate.muters instanceof Map) {
|
||
aggregate.muters.clear();
|
||
}
|
||
}
|
||
|
||
this.trustedMuteLists.clear();
|
||
this.trustedMutedAuthors.clear();
|
||
this.viewerMuteList.clear();
|
||
this.viewerMuteEventId = "";
|
||
this.viewerMuteUpdatedAt = 0;
|
||
|
||
this.emit("trusted-mutes", { total: 0 });
|
||
|
||
const emptyPrevious = new Set();
|
||
this.rebuildTrustedContacts(new Set(), { previous: emptyPrevious });
|
||
}
|
||
|
||
teardownBatchedMuteSubscriptions() {
|
||
if (Array.isArray(this.batchedMuteSubscriptions)) {
|
||
for (const sub of this.batchedMuteSubscriptions) {
|
||
if (sub && typeof sub.unsub === "function") {
|
||
try {
|
||
sub.unsub();
|
||
} catch (error) {
|
||
this.log("[moderationService] failed to unsub batched subscription", error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
this.batchedMuteSubscriptions = [];
|
||
}
|
||
|
||
isTrustedMuteOwner(pubkey) {
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
if (normalized === this.viewerPubkey) {
|
||
return true;
|
||
}
|
||
return this.trustedContacts.has(normalized);
|
||
}
|
||
|
||
reconcileTrustedMuteSubscriptions(previousSet, nextSet) {
|
||
this.scheduleTrustedMuteSubscriptionRefresh();
|
||
}
|
||
|
||
scheduleTrustedMuteSubscriptionRefresh() {
|
||
if (this.muteRefreshTimer) {
|
||
clearTimeout(this.muteRefreshTimer);
|
||
}
|
||
|
||
this.muteRefreshTimer = setTimeout(() => {
|
||
this.refreshTrustedMuteSubscriptions().catch((error) => {
|
||
this.log("[moderationService] refreshTrustedMuteSubscriptions failed", error);
|
||
});
|
||
}, DEBOUNCE_DELAY_MS);
|
||
}
|
||
|
||
async refreshTrustedMuteSubscriptions() {
|
||
this.muteRefreshTimer = null;
|
||
|
||
const targets = new Set();
|
||
if (this.trustedContacts instanceof Set) {
|
||
for (const contact of this.trustedContacts) {
|
||
targets.add(contact);
|
||
}
|
||
}
|
||
if (this.viewerPubkey) {
|
||
targets.add(this.viewerPubkey);
|
||
}
|
||
|
||
if (targets.size === 0) {
|
||
this.teardownBatchedMuteSubscriptions();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.ensurePool();
|
||
} catch (error) {
|
||
this.log("[moderationService] ensurePool failed during mute refresh", error);
|
||
return;
|
||
}
|
||
|
||
const relays = resolveRelayList(this.nostrClient);
|
||
if (!relays.length) {
|
||
this.log("[moderationService] no relays available for mute refresh");
|
||
return;
|
||
}
|
||
|
||
this.teardownBatchedMuteSubscriptions();
|
||
|
||
const authors = Array.from(targets);
|
||
const BATCH_SIZE = 400;
|
||
const chunks = [];
|
||
|
||
for (let i = 0; i < authors.length; i += BATCH_SIZE) {
|
||
chunks.push(authors.slice(i, i + BATCH_SIZE));
|
||
}
|
||
|
||
this.log(`[moderationService] Refreshing mute subscriptions for ${authors.length} authors in ${chunks.length} batches`);
|
||
|
||
for (const chunk of chunks) {
|
||
const filter = { kinds: [KIND_MUTE_LIST], authors: chunk };
|
||
try {
|
||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||
sub.on("event", (event) => {
|
||
this.ingestTrustedMuteEvent(event);
|
||
});
|
||
sub.on("eose", () => {
|
||
// Optional: handle EOSE if needed, e.g., for loading indicators
|
||
});
|
||
this.batchedMuteSubscriptions.push(sub);
|
||
} catch (error) {
|
||
this.log("[moderationService] failed to create batched subscription", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async ensureUserBlocksLoaded(pubkey) {
|
||
if (!this.userBlocks || typeof this.userBlocks.ensureLoaded !== "function") {
|
||
return;
|
||
}
|
||
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.userBlocks.ensureLoaded(normalized);
|
||
} catch (error) {
|
||
this.log("[moderationService] failed to load user block list", error);
|
||
}
|
||
}
|
||
|
||
async subscribeToTrustedMuteList(pubkey) {
|
||
// Deprecated: Logic moved to batched subscription refresh
|
||
return this.scheduleTrustedMuteSubscriptionRefresh();
|
||
}
|
||
|
||
teardownTrustedMuteSubscription(pubkey) {
|
||
// Deprecated: Logic moved to batched subscription refresh
|
||
return this.scheduleTrustedMuteSubscriptionRefresh();
|
||
}
|
||
|
||
replaceTrustedMuteList(ownerPubkey, mutedAuthors, { createdAt = 0, eventId = "" } = {}) {
|
||
const owner = normalizeHex(ownerPubkey);
|
||
if (!owner) {
|
||
return;
|
||
}
|
||
|
||
const sanitizedAuthors = new Map();
|
||
const addAuthor = (candidate, category = "") => {
|
||
const normalized = normalizeToHex(candidate);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
const normalizedCategory = normalizeMuteCategory(category);
|
||
sanitizedAuthors.set(normalized, normalizedCategory);
|
||
};
|
||
|
||
if (mutedAuthors instanceof Map) {
|
||
for (const [candidate, category] of mutedAuthors.entries()) {
|
||
addAuthor(candidate, category);
|
||
}
|
||
} else if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) {
|
||
for (const candidate of mutedAuthors) {
|
||
if (!candidate) {
|
||
continue;
|
||
}
|
||
if (typeof candidate === "object") {
|
||
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
|
||
continue;
|
||
}
|
||
addAuthor(candidate);
|
||
}
|
||
} else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") {
|
||
for (const candidate of mutedAuthors) {
|
||
if (!candidate) {
|
||
continue;
|
||
}
|
||
if (typeof candidate === "object") {
|
||
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
|
||
continue;
|
||
}
|
||
addAuthor(candidate);
|
||
}
|
||
}
|
||
|
||
const touchedAuthors = new Set();
|
||
const previous = this.trustedMuteLists.get(owner);
|
||
if (previous && previous.authors instanceof Map) {
|
||
for (const [author, category] of previous.authors.entries()) {
|
||
const aggregate = this.trustedMutedAuthors.get(author);
|
||
if (!aggregate || !(aggregate.muters instanceof Map)) {
|
||
continue;
|
||
}
|
||
aggregate.muters.delete(owner);
|
||
if (aggregate.categories instanceof Map) {
|
||
if (category) {
|
||
const categoryMuters = aggregate.categories.get(category);
|
||
if (categoryMuters instanceof Map) {
|
||
categoryMuters.delete(owner);
|
||
if (!categoryMuters.size) {
|
||
aggregate.categories.delete(category);
|
||
}
|
||
}
|
||
} else {
|
||
for (const [existingCategory, muters] of aggregate.categories.entries()) {
|
||
muters.delete(owner);
|
||
if (!muters.size) {
|
||
aggregate.categories.delete(existingCategory);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
touchedAuthors.add(author);
|
||
}
|
||
}
|
||
|
||
const normalizedEventId = normalizeEventId(eventId);
|
||
const normalizedCreatedAt = ensureNumber(createdAt);
|
||
|
||
if (owner === this.viewerPubkey) {
|
||
this.viewerMuteList = new Set(sanitizedAuthors);
|
||
this.viewerMuteEventId = normalizedEventId || "";
|
||
this.viewerMuteUpdatedAt = normalizedCreatedAt;
|
||
}
|
||
|
||
if (!sanitizedAuthors.size) {
|
||
this.trustedMuteLists.delete(owner);
|
||
} else {
|
||
this.trustedMuteLists.set(owner, {
|
||
authors: sanitizedAuthors,
|
||
updatedAt: normalizedCreatedAt,
|
||
eventId: normalizedEventId,
|
||
});
|
||
|
||
for (const [author, category] of sanitizedAuthors.entries()) {
|
||
let aggregate = this.trustedMutedAuthors.get(author);
|
||
if (!aggregate) {
|
||
aggregate = { muters: new Map(), categories: new Map(), count: 0 };
|
||
this.trustedMutedAuthors.set(author, aggregate);
|
||
}
|
||
aggregate.muters.set(owner, normalizedCreatedAt);
|
||
if (category) {
|
||
let categoryMuters = aggregate.categories.get(category);
|
||
if (!categoryMuters) {
|
||
categoryMuters = new Map();
|
||
aggregate.categories.set(category, categoryMuters);
|
||
}
|
||
categoryMuters.set(owner, normalizedCreatedAt);
|
||
}
|
||
touchedAuthors.add(author);
|
||
}
|
||
}
|
||
|
||
this.pruneTrustedMuteAggregates(touchedAuthors);
|
||
this.emit("trusted-mutes", { total: this.trustedMutedAuthors.size, owner });
|
||
}
|
||
|
||
applyTrustedMuteEvent(ownerPubkey, event) {
|
||
const owner = normalizeHex(ownerPubkey);
|
||
if (!owner || !this.isTrustedMuteOwner(owner)) {
|
||
return;
|
||
}
|
||
|
||
if (!event || event.kind !== KIND_MUTE_LIST) {
|
||
this.replaceTrustedMuteList(owner, new Set(), { createdAt: 0, eventId: "" });
|
||
return;
|
||
}
|
||
|
||
const createdAt = ensureNumber(event.created_at);
|
||
const eventId = typeof event.id === "string" ? event.id : "";
|
||
|
||
const existing = this.trustedMuteLists.get(owner);
|
||
if (existing) {
|
||
const existingEventId = typeof existing.eventId === "string" ? existing.eventId : "";
|
||
if (existingEventId && normalizeEventId(existingEventId) === normalizeEventId(eventId)) {
|
||
return;
|
||
}
|
||
if (ensureNumber(existing.updatedAt) > createdAt && normalizeEventId(eventId)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const mutedAuthors = new Map();
|
||
if (Array.isArray(event.tags)) {
|
||
for (const tag of event.tags) {
|
||
if (!Array.isArray(tag) || tag.length < 2) {
|
||
continue;
|
||
}
|
||
if (tag[0] !== "p") {
|
||
continue;
|
||
}
|
||
const normalized = normalizeToHex(tag[1]);
|
||
if (normalized) {
|
||
const category = extractMuteCategoryFromTag(tag);
|
||
mutedAuthors.set(normalized, category);
|
||
}
|
||
}
|
||
}
|
||
|
||
this.replaceTrustedMuteList(owner, mutedAuthors, { createdAt, eventId });
|
||
}
|
||
|
||
ingestTrustedMuteEvent(event) {
|
||
if (!event || event.kind !== KIND_MUTE_LIST) {
|
||
return;
|
||
}
|
||
|
||
const owner = normalizeHex(event.pubkey);
|
||
if (!owner || !this.isTrustedMuteOwner(owner)) {
|
||
return;
|
||
}
|
||
|
||
this.applyTrustedMuteEvent(owner, event);
|
||
}
|
||
|
||
getViewerMutedAuthors() {
|
||
return Array.from(this.viewerMuteList);
|
||
}
|
||
|
||
isAuthorMutedByViewer(pubkey) {
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
return this.viewerMuteList.has(normalized);
|
||
}
|
||
|
||
loadLocalMutes(pubkey) {
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
|
||
const storage = resolveStorage();
|
||
if (!storage) {
|
||
return;
|
||
}
|
||
|
||
const key = `bitvid:user-mutes:local:v1:${normalized}`;
|
||
let loaded = [];
|
||
|
||
try {
|
||
const raw = storage.getItem(key);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) {
|
||
loaded = parsed;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
this.log(
|
||
`[moderationService] failed to load local mutes for ${normalized}`,
|
||
error,
|
||
);
|
||
}
|
||
|
||
const mutes = new Set();
|
||
for (const item of loaded) {
|
||
const target = normalizeHex(item);
|
||
if (target && target !== normalized) {
|
||
mutes.add(target);
|
||
}
|
||
}
|
||
|
||
this.replaceTrustedMuteList(normalized, mutes, {
|
||
createdAt: Date.now() / 1000,
|
||
eventId: `local-${Date.now()}`,
|
||
});
|
||
}
|
||
|
||
saveLocalMutes(pubkey, muteSet) {
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
|
||
const storage = resolveStorage();
|
||
if (!storage) {
|
||
return;
|
||
}
|
||
|
||
const list = [];
|
||
if (muteSet instanceof Set || Array.isArray(muteSet)) {
|
||
for (const item of muteSet) {
|
||
const target = normalizeHex(item);
|
||
if (target && target !== normalized) {
|
||
list.push(target);
|
||
}
|
||
}
|
||
}
|
||
|
||
const key = `bitvid:user-mutes:local:v1:${normalized}`;
|
||
try {
|
||
if (!list.length) {
|
||
storage.removeItem(key);
|
||
} else {
|
||
storage.setItem(key, JSON.stringify(list));
|
||
}
|
||
} catch (error) {
|
||
this.log(
|
||
`[moderationService] failed to save local mutes for ${normalized}`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) {
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
this.viewerMuteList.clear();
|
||
this.viewerMuteEventId = "";
|
||
this.viewerMuteUpdatedAt = 0;
|
||
return;
|
||
}
|
||
|
||
if (this.trustedMuteLists.has(normalized)) {
|
||
return;
|
||
}
|
||
|
||
if (this.viewerIsSessionActor && normalized === this.viewerPubkey) {
|
||
this.loadLocalMutes(normalized);
|
||
return;
|
||
}
|
||
|
||
if (this.viewerMutePromise) {
|
||
try {
|
||
await this.viewerMutePromise;
|
||
} catch (_) {
|
||
/* noop */
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.viewerMutePromise = this.subscribeToTrustedMuteList(normalized)
|
||
.catch((error) => {
|
||
this.log("[moderationService] failed to load viewer mute list", error);
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
this.viewerMutePromise = null;
|
||
});
|
||
|
||
try {
|
||
await this.viewerMutePromise;
|
||
} catch (_) {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
async publishViewerMuteList() {
|
||
throw new Error("Mute list publishing is now handled by userBlocks.");
|
||
}
|
||
|
||
async addAuthorToViewerMuteList(pubkey) {
|
||
const viewer = normalizeHex(this.viewerPubkey);
|
||
if (!viewer) {
|
||
const error = new Error("viewer-not-logged-in");
|
||
error.code = "viewer-not-logged-in";
|
||
throw error;
|
||
}
|
||
|
||
if (this.userBlocks && typeof this.userBlocks.addMute === "function") {
|
||
return this.userBlocks.addMute(pubkey, viewer);
|
||
}
|
||
|
||
throw new Error("Mute list management is delegated to userBlocks.");
|
||
}
|
||
|
||
async removeAuthorFromViewerMuteList(pubkey) {
|
||
const viewer = normalizeHex(this.viewerPubkey);
|
||
if (!viewer) {
|
||
const error = new Error("viewer-not-logged-in");
|
||
error.code = "viewer-not-logged-in";
|
||
throw error;
|
||
}
|
||
|
||
if (this.userBlocks && typeof this.userBlocks.removeMute === "function") {
|
||
return this.userBlocks.removeMute(pubkey, viewer);
|
||
}
|
||
|
||
throw new Error("Mute list management is delegated to userBlocks.");
|
||
}
|
||
|
||
/**
|
||
* Checks if an author is muted by any of the trusted contacts.
|
||
*
|
||
* @param {string} pubkey - The author's pubkey.
|
||
* @returns {boolean} True if at least one trusted contact mutes this author.
|
||
*/
|
||
isAuthorMutedByTrusted(pubkey) {
|
||
const muters = this.getActiveTrustedMutersForAuthor(pubkey);
|
||
return muters.length > 0;
|
||
}
|
||
|
||
getTrustedMutersForAuthor(pubkey) {
|
||
return this.getActiveTrustedMutersForAuthor(pubkey);
|
||
}
|
||
|
||
/**
|
||
* Checks if a pubkey is blocked or muted by the current viewer.
|
||
* Delegates to the UserBlocks manager.
|
||
*
|
||
* @param {string} pubkey - The pubkey to check.
|
||
* @returns {boolean} True if blocked or muted by viewer.
|
||
*/
|
||
isPubkeyBlockedByViewer(pubkey) {
|
||
if (!this.userBlocks || typeof this.userBlocks.isBlocked !== "function") {
|
||
return false;
|
||
}
|
||
|
||
const normalized = normalizeHex(pubkey);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return this.userBlocks.isBlocked(normalized) === true;
|
||
} catch (error) {
|
||
this.log("[moderationService] user block lookup failed", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
getAdminListSnapshot() {
|
||
const snapshot = createEmptyAdminSnapshot();
|
||
|
||
if (!this.accessControl) {
|
||
return snapshot;
|
||
}
|
||
|
||
let whitelist = [];
|
||
let blacklist = [];
|
||
|
||
if (typeof this.accessControl.getWhitelist === "function") {
|
||
try {
|
||
whitelist = this.accessControl.getWhitelist() || [];
|
||
} catch (error) {
|
||
this.log("[moderationService] accessControl.getWhitelist threw", error);
|
||
}
|
||
}
|
||
|
||
if (typeof this.accessControl.getBlacklist === "function") {
|
||
try {
|
||
blacklist = this.accessControl.getBlacklist() || [];
|
||
} catch (error) {
|
||
this.log("[moderationService] accessControl.getBlacklist threw", error);
|
||
}
|
||
}
|
||
|
||
for (const entry of whitelist) {
|
||
if (typeof entry !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = entry.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
snapshot.whitelist.add(trimmed);
|
||
const decoded = decodeToHex(trimmed);
|
||
if (decoded) {
|
||
snapshot.whitelistHex.add(decoded);
|
||
}
|
||
}
|
||
|
||
for (const entry of blacklist) {
|
||
if (typeof entry !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = entry.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
snapshot.blacklist.add(trimmed);
|
||
const decoded = decodeToHex(trimmed);
|
||
if (decoded) {
|
||
snapshot.blacklistHex.add(decoded);
|
||
}
|
||
}
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
getAccessControlStatus(candidate, snapshot = null) {
|
||
const resolvedSnapshot = snapshot && typeof snapshot === "object" ? snapshot : this.getAdminListSnapshot();
|
||
const whitelist =
|
||
resolvedSnapshot?.whitelist instanceof Set
|
||
? resolvedSnapshot.whitelist
|
||
: new Set();
|
||
const whitelistHex =
|
||
resolvedSnapshot?.whitelistHex instanceof Set
|
||
? resolvedSnapshot.whitelistHex
|
||
: new Set();
|
||
const blacklist =
|
||
resolvedSnapshot?.blacklist instanceof Set
|
||
? resolvedSnapshot.blacklist
|
||
: new Set();
|
||
const blacklistHex =
|
||
resolvedSnapshot?.blacklistHex instanceof Set
|
||
? resolvedSnapshot.blacklistHex
|
||
: new Set();
|
||
|
||
const hex = normalizeToHex(candidate);
|
||
const status = {
|
||
hex,
|
||
whitelisted: false,
|
||
blacklisted: false,
|
||
npub: "",
|
||
};
|
||
|
||
if (hex) {
|
||
if (blacklistHex.has(hex)) {
|
||
status.blacklisted = true;
|
||
return status;
|
||
}
|
||
if (whitelistHex.has(hex)) {
|
||
status.whitelisted = true;
|
||
status.npub = encodeToNpub(hex) || "";
|
||
return status;
|
||
}
|
||
}
|
||
|
||
const encoded = hex ? encodeToNpub(hex) : "";
|
||
if (encoded) {
|
||
status.npub = encoded;
|
||
if (blacklist.has(encoded)) {
|
||
status.blacklisted = true;
|
||
return status;
|
||
}
|
||
if (whitelist.has(encoded)) {
|
||
status.whitelisted = true;
|
||
return status;
|
||
}
|
||
}
|
||
|
||
if (typeof candidate === "string" && !status.npub) {
|
||
const trimmed = candidate.trim();
|
||
if (trimmed && (blacklist.has(trimmed) || whitelist.has(trimmed))) {
|
||
status.npub = trimmed;
|
||
if (blacklist.has(trimmed)) {
|
||
status.blacklisted = true;
|
||
} else if (whitelist.has(trimmed)) {
|
||
status.whitelisted = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return status;
|
||
}
|
||
|
||
async ensurePool() {
|
||
if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") {
|
||
throw new Error("nostr-client-unavailable");
|
||
}
|
||
return this.nostrClient.ensurePool();
|
||
}
|
||
|
||
/**
|
||
* Checks the current Nostr client state and updates the viewer if changed.
|
||
* Useful when the user logs in or switches accounts externally.
|
||
*
|
||
* @returns {Promise<string>} The active viewer pubkey.
|
||
*/
|
||
async refreshViewerFromClient() {
|
||
const clientPubkey = normalizeHex(
|
||
this.nostrClient?.pubkey || this.nostrClient?.sessionActor?.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);
|
||
}
|
||
|
||
/**
|
||
* Switches the active "Viewer" context.
|
||
* This reloads the trusted contact list (Kind 3) for the new user,
|
||
* effectively rebuilding the "Web of Trust" graph.
|
||
*
|
||
* @param {string} pubkey - The public key of the new viewer.
|
||
* @returns {Promise<string>} The normalized pubkey of the new viewer.
|
||
*/
|
||
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);
|
||
}
|
||
await this.ensureUserBlocksLoaded(normalized);
|
||
return this.viewerPubkey;
|
||
}
|
||
|
||
const previousContacts =
|
||
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
|
||
|
||
this.viewerPubkey = normalized;
|
||
|
||
const loggedInPubkey = normalizeHex(this.nostrClient?.pubkey);
|
||
const sessionPubkey = normalizeHex(this.nostrClient?.sessionActor?.pubkey);
|
||
|
||
this.viewerIsSessionActor =
|
||
!!normalized && !loggedInPubkey && normalized === sessionPubkey;
|
||
|
||
this.clearTrustedMuteTracking({ previousContacts });
|
||
|
||
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) {
|
||
await this.ensureUserBlocksLoaded(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);
|
||
});
|
||
|
||
await this.ensureUserBlocksLoaded(normalized);
|
||
await this.ensureViewerMuteListLoaded(normalized);
|
||
return this.viewerPubkey;
|
||
}
|
||
|
||
async fetchTrustedContacts(pubkey) {
|
||
const normalized = normalizeHex(pubkey);
|
||
const previousContacts =
|
||
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
|
||
if (!normalized) {
|
||
this.clearTrustedMuteTracking({ previousContacts });
|
||
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.clearTrustedMuteTracking({ previousContacts });
|
||
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.clearTrustedMuteTracking({ previousContacts });
|
||
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, { previous: previousContacts });
|
||
}
|
||
|
||
applyContactEvent(event, { previous = null } = {}) {
|
||
const previousContacts =
|
||
previous instanceof Set
|
||
? new Set(previous)
|
||
: this.trustedContacts instanceof Set
|
||
? new Set(this.trustedContacts)
|
||
: new Set();
|
||
|
||
if (!event || !Array.isArray(event.tags)) {
|
||
this.clearTrustedMuteTracking({ previousContacts });
|
||
return;
|
||
}
|
||
|
||
if (event.id && event.id === this.lastContactEventId) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
if (this.viewerContacts instanceof Set && this.viewerContacts.size === nextSet.size) {
|
||
let changed = false;
|
||
for (const value of nextSet) {
|
||
if (!this.viewerContacts.has(value)) {
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!changed) {
|
||
if (event.id) {
|
||
this.lastContactEventId = event.id;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (event.id) {
|
||
this.lastContactEventId = event.id;
|
||
}
|
||
|
||
this.rebuildTrustedContacts(nextSet, { previous: previousContacts });
|
||
}
|
||
|
||
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 = []) {
|
||
if (!MODERATION_REPORT_SUBS_ENABLED) {
|
||
return;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Skip redundant work when the visible set is unchanged (the feed stage
|
||
// calls this on every run).
|
||
const prevIds = this.activeEventIds instanceof Set ? this.activeEventIds : new Set();
|
||
if (setsEqual(prevIds, nextIds)) {
|
||
return;
|
||
}
|
||
|
||
const manager =
|
||
typeof this.nostrClient?.getSubscriptionManager === "function"
|
||
? this.nostrClient.getSubscriptionManager()
|
||
: null;
|
||
|
||
// Fallback for environments without the SubscriptionManager (tests/mocks):
|
||
// keep the legacy per-id behavior so those paths are unaffected.
|
||
if (!manager) {
|
||
return this._setActiveEventIdsLegacy(nextIds, prevIds);
|
||
}
|
||
|
||
this.activeEventIds = nextIds;
|
||
|
||
if (nextIds.size === 0) {
|
||
if (this._reportSubHandle) {
|
||
this._reportSubHandle.close();
|
||
this._reportSubHandle = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const idArray = Array.from(nextIds);
|
||
const filters = buildReportFilters(idArray);
|
||
|
||
// ONE live batched subscription over the whole active set, updated in place
|
||
// as the set changes (no teardown/resubscribe churn).
|
||
if (this._reportSubHandle) {
|
||
this._reportSubHandle.update({ filters });
|
||
} else {
|
||
this._reportSubHandle = manager.subscribe({
|
||
key: "moderation:reports",
|
||
label: "reports",
|
||
filters,
|
||
onEvent: (event) => this.ingestReportEvent(event),
|
||
});
|
||
}
|
||
|
||
// Backfill history for only the newly-added ids — one batched list().
|
||
const newIds = idArray.filter((id) => !prevIds.has(id));
|
||
if (newIds.length) {
|
||
try {
|
||
const events = await manager.list({ filters: buildReportFilters(newIds) });
|
||
if (Array.isArray(events)) {
|
||
for (const event of events) {
|
||
this.ingestReportEvent(event);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
this.log("[moderationService] batched report backfill failed", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Legacy per-id path, retained only for environments without a
|
||
// SubscriptionManager (unit tests / mock clients). The real app uses the
|
||
// batched path above.
|
||
async _setActiveEventIdsLegacy(nextIds, prevIds = null) {
|
||
const previous =
|
||
prevIds instanceof Set
|
||
? prevIds
|
||
: this.activeEventIds instanceof Set
|
||
? this.activeEventIds
|
||
: new Set();
|
||
void previous;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
async refreshActiveReportSubscriptions() {
|
||
if (!this.activeEventIds || !this.activeEventIds.size) {
|
||
return;
|
||
}
|
||
|
||
// With the batched subscription the report *data* is unchanged by a trust
|
||
// graph update (same event ids); callers already recompute summaries. Just
|
||
// ensure the single live subscription is (re)issued for its current set.
|
||
if (this._reportSubHandle) {
|
||
this._reportSubHandle.update({ filters: buildReportFilters(Array.from(this.activeEventIds)) });
|
||
return;
|
||
}
|
||
|
||
// Legacy path (no SubscriptionManager): tear down + re-subscribe via the
|
||
// public (overridable) method. Clear activeEventIds first so the
|
||
// unchanged-set guard in setActiveEventIds doesn't short-circuit the rebuild.
|
||
const ids = Array.from(this.activeEventIds);
|
||
for (const id of ids) {
|
||
this.teardownReportSubscription(id);
|
||
}
|
||
this.activeEventIds = new Set();
|
||
await this.setActiveEventIds(ids);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Subscribes to Kind 1984 reports for a specific event.
|
||
*
|
||
* This fetches recent history (backfill) and opens a live subscription.
|
||
* Reports are ingested and trigger summary recomputation.
|
||
*
|
||
* @param {string} eventId - The ID of the event to monitor.
|
||
* @returns {Promise<void>}
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ingests a NIP-56 Report event (Kind 1984).
|
||
*
|
||
* The report is indexed by `targetEventId` -> `reporter` -> `reportType`.
|
||
* Note: This method only stores the report. Trust filtering happens during
|
||
* `recomputeSummaryForEvent`.
|
||
*
|
||
* @param {object} event - The Kind 1984 event.
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Aggregates all reports for a specific event and calculates the "Trust Score".
|
||
*
|
||
* Algorithm:
|
||
* 1. Iterate over all reports for the event.
|
||
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
|
||
* 3. If trusted, increment the count for that report type (e.g., "nudity").
|
||
* 4. Update the summary state and emit change events if thresholds are crossed.
|
||
*
|
||
* @param {string} eventId - The ID of the event to recompute.
|
||
*/
|
||
recomputeSummaryForEvent(eventId) {
|
||
const normalized = normalizeEventId(eventId);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
|
||
const eventReports = this.reportEvents.get(normalized);
|
||
if (!eventReports || !eventReports.size) {
|
||
const previousSummary = this.reportSummaries.get(normalized);
|
||
if (previousSummary && previousSummary.types) {
|
||
for (const [type, detail] of Object.entries(previousSummary.types)) {
|
||
if (!detail || typeof detail !== "object") {
|
||
continue;
|
||
}
|
||
const previousTrusted = Number.isFinite(detail.trusted) ? detail.trusted : 0;
|
||
if (previousTrusted > 0) {
|
||
this.logThresholdTransitions({
|
||
eventId: normalized,
|
||
reportType: type,
|
||
previousTrusted,
|
||
nextTrusted: 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
this.reportSummaries.delete(normalized);
|
||
this.emit("summary", { eventId: normalized, summary: null });
|
||
return;
|
||
}
|
||
|
||
const previousSummary = this.reportSummaries.get(normalized);
|
||
const typeStats = new Map();
|
||
let totalTrusted = 0;
|
||
const adminSnapshot = this.getAdminListSnapshot();
|
||
|
||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||
const status = this.getAccessControlStatus(reporter, adminSnapshot);
|
||
const reporterHex = status.hex || normalizeHex(reporter);
|
||
if (!reporterHex) {
|
||
continue;
|
||
}
|
||
|
||
if (this.isPubkeyBlockedByViewer(reporterHex)) {
|
||
continue;
|
||
}
|
||
|
||
if (status.blacklisted) {
|
||
continue;
|
||
}
|
||
|
||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||
|
||
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 (isTrustedReporter) {
|
||
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,
|
||
};
|
||
}
|
||
|
||
if (previousSummary && previousSummary.types) {
|
||
const seen = new Set([
|
||
...Object.keys(previousSummary.types || {}),
|
||
...Object.keys(types),
|
||
]);
|
||
for (const type of seen) {
|
||
const previousEntry = previousSummary?.types?.[type];
|
||
const nextEntry = types?.[type];
|
||
const previousTrusted = Number.isFinite(previousEntry?.trusted)
|
||
? previousEntry.trusted
|
||
: 0;
|
||
const nextTrusted = Number.isFinite(nextEntry?.trusted)
|
||
? nextEntry.trusted
|
||
: 0;
|
||
if (previousTrusted !== nextTrusted) {
|
||
this.logThresholdTransitions({
|
||
eventId: normalized,
|
||
reportType: type,
|
||
previousTrusted,
|
||
nextTrusted,
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
for (const [type, stats] of Object.entries(types)) {
|
||
const nextTrusted = Number.isFinite(stats?.trusted) ? stats.trusted : 0;
|
||
if (nextTrusted > 0) {
|
||
this.logThresholdTransitions({
|
||
eventId: normalized,
|
||
reportType: type,
|
||
previousTrusted: 0,
|
||
nextTrusted,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const summary = {
|
||
eventId: normalized,
|
||
totalTrusted,
|
||
types,
|
||
updatedAt: Date.now(),
|
||
};
|
||
|
||
this.reportSummaries.set(normalized, summary);
|
||
this.emit("summary", { eventId: normalized, summary: cloneSummary(summary) });
|
||
}
|
||
|
||
/**
|
||
* Retrieves the current aggregated trust score for an event.
|
||
*
|
||
* @param {string} eventId - The event ID.
|
||
* @returns {object} The summary object ({ totalTrusted, types, ... }).
|
||
*/
|
||
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);
|
||
}
|
||
|
||
getTrustedReporters(eventId, type) {
|
||
const normalizedEventId = normalizeEventId(eventId);
|
||
if (!normalizedEventId) {
|
||
return [];
|
||
}
|
||
|
||
const eventReports = this.reportEvents.get(normalizedEventId);
|
||
if (!eventReports || !eventReports.size) {
|
||
return [];
|
||
}
|
||
|
||
const normalizedType = normalizeReportType(type);
|
||
const results = [];
|
||
|
||
const adminSnapshot = this.getAdminListSnapshot();
|
||
|
||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||
const status = this.getAccessControlStatus(reporter, adminSnapshot);
|
||
const reporterHex = status.hex || normalizeHex(reporter);
|
||
if (!reporterHex) {
|
||
continue;
|
||
}
|
||
|
||
if (this.isPubkeyBlockedByViewer(reporterHex)) {
|
||
continue;
|
||
}
|
||
|
||
if (status.blacklisted) {
|
||
continue;
|
||
}
|
||
|
||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||
if (!isTrustedReporter) {
|
||
continue;
|
||
}
|
||
|
||
if (normalizedType) {
|
||
const detail = typeMap.get(normalizedType);
|
||
if (!detail) {
|
||
continue;
|
||
}
|
||
results.push({
|
||
pubkey: reporterHex,
|
||
latest: ensureNumber(detail?.created_at),
|
||
});
|
||
continue;
|
||
}
|
||
|
||
let latest = 0;
|
||
for (const entry of typeMap.values()) {
|
||
const candidate = ensureNumber(entry?.created_at);
|
||
if (candidate > latest) {
|
||
latest = candidate;
|
||
}
|
||
}
|
||
results.push({ pubkey: reporterHex, latest });
|
||
}
|
||
|
||
if (!results.length) {
|
||
return [];
|
||
}
|
||
|
||
results.sort((a, b) => ensureNumber(b.latest) - ensureNumber(a.latest));
|
||
return results.map((entry) => ({
|
||
pubkey: entry.pubkey,
|
||
latest: ensureNumber(entry.latest),
|
||
}));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Publishes a NIP-56 Report (Kind 1984).
|
||
*
|
||
* @param {object} params
|
||
* @param {string} params.eventId - The ID of the content being reported.
|
||
* @param {string} params.type - The category (e.g., "nudity", "spam").
|
||
* @param {string} params.targetPubkey - The pubkey of the reported content's author.
|
||
* @param {string} [params.relayHint] - Optional relay URL hint.
|
||
* @param {string} [params.content] - Optional text description of the report.
|
||
* @returns {Promise<{ok: boolean, event: object, results: object[]}>}
|
||
*/
|
||
async submitReport({
|
||
eventId,
|
||
type,
|
||
targetPubkey = "",
|
||
relayHint = "",
|
||
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 || this.nostrClient?.sessionActor?.pubkey,
|
||
);
|
||
|
||
if (!reporterPubkey) {
|
||
const error = new Error("viewer-not-logged-in");
|
||
error.code = "viewer-not-logged-in";
|
||
throw error;
|
||
}
|
||
|
||
const normalizedTarget = normalizeHex(targetPubkey);
|
||
if (!normalizedTarget) {
|
||
const error = new Error("invalid-target-pubkey");
|
||
error.code = "invalid-target-pubkey";
|
||
throw error;
|
||
}
|
||
|
||
const signer = await this.nostrClient.ensureActiveSignerForPubkey(
|
||
reporterPubkey,
|
||
);
|
||
if (!signer || typeof signer.signEvent !== "function") {
|
||
const error = new Error("signer-unavailable");
|
||
error.code = "signer-unavailable";
|
||
throw error;
|
||
}
|
||
|
||
if (
|
||
(signer.type === "extension" || signer.type === "nip07") &&
|
||
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 sanitizedRelayHint =
|
||
typeof relayHint === "string" ? relayHint.trim() : "";
|
||
|
||
const event = buildReportEvent({
|
||
pubkey: reporterPubkey,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
eventId: normalizedEventId,
|
||
userId: normalizedTarget,
|
||
reportType: normalizedType,
|
||
relayHint: sanitizedRelayHint,
|
||
content: typeof content === "string" ? content : "",
|
||
});
|
||
|
||
let signedEvent;
|
||
try {
|
||
signedEvent = await signer.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 };
|
||
}
|
||
|
||
async awaitUserBlockRefresh() {
|
||
if (!this.userBlockRefreshQueue) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.userBlockRefreshQueue;
|
||
} catch {
|
||
/* already reported during queue */
|
||
}
|
||
}
|
||
}
|
||
|
||
const moderationService = new ModerationService({
|
||
nostrClient,
|
||
logger,
|
||
userBlocks,
|
||
accessControl,
|
||
});
|
||
|
||
export default moderationService;
|