diff --git a/AFTER.md b/AFTER.md new file mode 100644 index 00000000..172d96c0 --- /dev/null +++ b/AFTER.md @@ -0,0 +1,23 @@ +# After Benchmark for Moderation Stage Batching + +## Command +`node benchmarks/moderation_batch_bench.js` + +## Environment +- Node.js v22.22.0 +- 5000 items +- 500 authors +- 100 muters per author +- Forced prune check (lastPrunedAt = 0) + +## Results +- Run 1: 184.34ms +- Run 2: 277.68ms +- Run 3: 188.89ms +- Run 4: 256.48ms +- **Average: ~227ms** + +## Comparison +- Baseline: ~454ms +- After: ~227ms +- **Improvement: ~50% faster** diff --git a/BASELINE.md b/BASELINE.md new file mode 100644 index 00000000..b5e42a5b --- /dev/null +++ b/BASELINE.md @@ -0,0 +1,18 @@ +# Baseline Benchmark for Moderation Stage Batching + +## Command +`node benchmarks/moderation_batch_bench.js` + +## Environment +- Node.js v22.22.0 +- 5000 items +- 500 authors +- 100 muters per author +- Forced prune check (lastPrunedAt = 0) + +## Results +- Run 1: 545.80ms +- Run 2: 370.08ms +- Run 3: 523.04ms +- Run 4: 379.80ms +- **Average: ~454ms** diff --git a/benchmarks/moderation_batch_bench.js b/benchmarks/moderation_batch_bench.js new file mode 100644 index 00000000..132f705b --- /dev/null +++ b/benchmarks/moderation_batch_bench.js @@ -0,0 +1,121 @@ + +// Mock Globals BEFORE imports +global.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} +}; +global.window = { localStorage: global.localStorage }; + +// Dynamic imports to ensure globals are set first +const { ModerationService } = await import('../js/services/moderationService.js'); +const { createModerationStage } = await import('../js/feedEngine/stages.js'); +const { performance } = await import('node:perf_hooks'); + +// Mock Dependencies +const mockClient = { + pool: {}, + ensurePool: async () => {}, + pubkey: 'viewer-pubkey', +}; + +const mockLogger = { + log: () => {}, + dev: { log: () => {}, debug: () => {} }, + user: { warn: () => {}, info: () => {} } +}; + +const service = new ModerationService({ + nostrClient: mockClient, + logger: mockLogger, +}); + +// Helper to generate valid hex +function makeHex(prefix, index) { + return `${prefix}${index.toString(16).padStart(64 - prefix.length, '0')}`; +} + +// Setup Data +const AUTHOR_COUNT = 500; +const MUTERS_PER_AUTHOR = 100; +const now = Date.now() / 1000; + +console.log(`Populating ${AUTHOR_COUNT} authors with ${MUTERS_PER_AUTHOR} muters each...`); + +for (let i = 0; i < AUTHOR_COUNT; i++) { + const author = makeHex('abc', i); + const muters = new Map(); + const categories = new Map(); + + for (let j = 0; j < MUTERS_PER_AUTHOR; j++) { + const muter = makeHex('def', j); + muters.set(muter, now); // Not expired + } + + // Add some categories + const catMuters = new Map(); + const firstMuter = makeHex('def', 0); + catMuters.set(firstMuter, now); + categories.set('spam', catMuters); + + // We manually inject into internal map to bypass setter logic + service.trustedMutedAuthors.set(author, { + muters, + categories, + count: muters.size, + lastPrunedAt: 0 // Force prune check + }); +} + +// Prepare items for stage +const ITEM_COUNT = 5000; +const items = []; +for (let i = 0; i < ITEM_COUNT; i++) { + const authorIndex = i % AUTHOR_COUNT; + const author = makeHex('abc', authorIndex); + items.push({ + video: { + id: makeHex('123', i), + pubkey: author, + kind: 30078, + moderation: {} + }, + metadata: {} + }); +} + +const context = { + runtime: { + moderationThresholds: { + autoplayBlockThreshold: 1, + blurThreshold: 1, + trustedSpamHideThreshold: 1, + trustedMuteHideThresholds: {} + }, + isAuthorBlocked: () => false + }, + log: () => {}, + addWhy: () => {} +}; + +async function runBenchmark() { + console.log('Running Moderation Stage Benchmark (Sequential Lookups)...'); + + const moderationStage = createModerationStage({ + service: service, + stageName: 'bench-moderation-sequential' + }); + + // Warmup + await moderationStage(items.slice(0, 100), context); + + // Measure + const start = performance.now(); + await moderationStage(items, context); + const end = performance.now(); + + console.log(`Time: ${(end - start).toFixed(2)}ms`); +} + +runBenchmark().catch(console.error); diff --git a/docs/agents/task-logs/weekly/2026-02-22_01-25-01_perf-optimization-agent_completed.md b/docs/agents/task-logs/weekly/2026-02-22_01-25-01_perf-optimization-agent_completed.md new file mode 100644 index 00000000..413a53ee --- /dev/null +++ b/docs/agents/task-logs/weekly/2026-02-22_01-25-01_perf-optimization-agent_completed.md @@ -0,0 +1,27 @@ +# Weekly Perf Optimization Agent - Completed + +**Agent:** perf-optimization-agent +**Date:** 2026-02-22 +**Cadence:** Weekly + +## Summary +Executed a performance optimization on the Moderation Stage in the feed engine. + +### Tasks +1. **Diagnosis:** Identified that `moderationStage` was performing sequential moderation lookups for each item in the feed, causing repeated calls to `getActiveTrustedMutersForAuthor` and potentially expensive pruning checks. +2. **Benchmarking:** Created `benchmarks/moderation_batch_bench.js` to simulate feed processing. + - Baseline: ~454ms for 5000 items (500 authors). +3. **Optimization:** + - Refactored `js/services/moderationService.js` to extract static utilities to `js/services/moderationUtils.js` (reducing file size and complexity). + - Implemented `getModerationInfoForAuthors(authors)` in `ModerationService` to batch author lookups and prune aggregates in a single pass. + - Updated `js/feedEngine/stages.js` to use `getModerationInfoForAuthors` for batch retrieval. +4. **Verification:** + - Re-ran benchmarks: ~227ms average (~50% improvement). + - Verified logic with `npm run test:unit`. + - Lint checks passed (resolved file size growth by extraction). + +## Artifacts +- `benchmarks/moderation_batch_bench.js` +- `BASELINE.md` +- `AFTER.md` +- `js/services/moderationUtils.js` (New file) diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index bd8e2c0f..c9c7a1c1 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -877,6 +877,24 @@ export function createModerationStage({ const results = []; + let authorModerationInfo = new Map(); + try { + if (typeof resolvedService.getModerationInfoForAuthors === "function") { + const authorSet = new Set(); + for (const item of items) { + const pubkey = item?.video?.pubkey; + if (typeof pubkey === "string") { + authorSet.add(pubkey); + } + } + if (authorSet.size > 0) { + authorModerationInfo = resolvedService.getModerationInfoForAuthors(authorSet); + } + } + } catch (error) { + context?.log?.(`[${stageName}] Failed to batch fetch moderation info`, error); + } + for (const item of items) { if (!item || typeof item !== "object") { results.push(item); @@ -982,39 +1000,59 @@ export function createModerationStage({ if (authorHex) { try { - if (typeof resolvedService.isAuthorMutedByTrusted === "function") { - trustedMuted = resolvedService.isAuthorMutedByTrusted(authorHex) === true; - } - if (trustedMuted && typeof resolvedService.getTrustedMutersForAuthor === "function") { - const muters = resolvedService.getTrustedMutersForAuthor(authorHex); - if (Array.isArray(muters)) { - const seen = new Set(); - trustedMuters = muters - .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : "")) - .filter((value) => { - if (!value || seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); - } - } - if ( - trustedMuted && - typeof resolvedService.getTrustedMuteCountsForAuthor === "function" - ) { - const summary = resolvedService.getTrustedMuteCountsForAuthor(authorHex); - if (summary && typeof summary === "object") { - trustedMuteCountTotal = Number.isFinite(summary.total) - ? Math.max(0, Math.floor(summary.total)) + let infoFound = false; + if (authorModerationInfo instanceof Map) { + const info = authorModerationInfo.get(authorHex); + if (info) { + trustedMuted = info.trustedMuted === true; + trustedMuters = Array.isArray(info.trustedMuters) ? info.trustedMuters : []; + trustedMuteCountTotal = Number.isFinite(info.trustedMuteCountTotal) + ? Math.max(0, Math.floor(info.trustedMuteCountTotal)) : 0; trustedMuteCountsByCategory = - summary.categories && typeof summary.categories === "object" - ? { ...summary.categories } + info.trustedMuteCountsByCategory && typeof info.trustedMuteCountsByCategory === "object" + ? info.trustedMuteCountsByCategory : null; + infoFound = true; } } + + if (!infoFound) { + if (typeof resolvedService.isAuthorMutedByTrusted === "function") { + trustedMuted = resolvedService.isAuthorMutedByTrusted(authorHex) === true; + } + if (trustedMuted && typeof resolvedService.getTrustedMutersForAuthor === "function") { + const muters = resolvedService.getTrustedMutersForAuthor(authorHex); + if (Array.isArray(muters)) { + const seen = new Set(); + trustedMuters = muters + .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : "")) + .filter((value) => { + if (!value || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + } + if ( + trustedMuted && + typeof resolvedService.getTrustedMuteCountsForAuthor === "function" + ) { + const summary = resolvedService.getTrustedMuteCountsForAuthor(authorHex); + if (summary && typeof summary === "object") { + trustedMuteCountTotal = Number.isFinite(summary.total) + ? Math.max(0, Math.floor(summary.total)) + : 0; + trustedMuteCountsByCategory = + summary.categories && typeof summary.categories === "object" + ? { ...summary.categories } + : null; + } + } + } + if (typeof resolvedService.isAuthorMutedByViewer === "function") { viewerMuted = resolvedService.isAuthorMutedByViewer(authorHex) === true; } diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 3942bfb8..8807c203 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -25,517 +25,29 @@ 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"; - -/** - * Trust threshold for disabling autoplay on reported videos. - * If >= 1 trusted user reports content, autoplay is disabled. - */ -const AUTOPLAY_TRUST_THRESHOLD = 1; - -/** - * Trust threshold for blurring content. - * If >= 1 trusted user reports content, it is blurred. - */ -const BLUR_TRUST_THRESHOLD = 1; - -/** - * Time window for considering trusted mute list entries valid. - * Mutes older than 60 days are ignored to prevent stale lists from affecting scores. - */ -const TRUSTED_MUTE_WINDOW_DAYS = 60; -const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60; - -function normalizeUserLogger(candidate) { - if ( - candidate && - typeof candidate === "object" && - typeof candidate.info === "function" - ) { - return candidate; - } - return logger.user; -} - -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 isRelayHint(value) { - if (typeof value !== "string") { - return false; - } - const trimmed = value.trim().toLowerCase(); - return ( - trimmed.startsWith("wss://") || - trimmed.startsWith("ws://") || - trimmed.startsWith("https://") || - trimmed.startsWith("http://") - ); -} - -function normalizeMuteCategory(value) { - return normalizeReportType(value); -} - -function extractMuteCategoryFromTag(tag) { - if (!Array.isArray(tag)) { - return ""; - } - - const direct = normalizeMuteCategory(tag[3]); - if (direct) { - return direct; - } - - const fallback = typeof tag[2] === "string" && !isRelayHint(tag[2]) - ? normalizeMuteCategory(tag[2]) - : ""; - return fallback; -} - -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 getNostrTools() { - if (typeof window !== "undefined" && window?.NostrTools) { - return window.NostrTools; - } - if (typeof globalThis !== "undefined" && globalThis?.NostrTools) { - return globalThis.NostrTools; - } - return null; -} - -function bytesToHex(bytes) { - if (!bytes || typeof bytes.length !== "number") { - return ""; - } - - let hex = ""; - for (let index = 0; index < bytes.length; index += 1) { - const value = bytes[index]; - if (typeof value !== "number") { - return ""; - } - const normalized = value & 0xff; - hex += normalized.toString(16).padStart(2, "0"); - } - return hex; -} - -/** - * @internal - * Internal implementation of bech32 encoding/decoding. - * This ensures the service can normalize `npub` inputs even if the global - * `NostrTools` object is unavailable or version-mismatched. - */ -const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; -const BECH32_CHARSET_MAP = (() => { - const map = new Map(); - for (let index = 0; index < BECH32_CHARSET.length; index += 1) { - map.set(BECH32_CHARSET[index], index); - } - return map; -})(); - -/** @internal */ -function bech32Polymod(values) { - let chk = 1; - const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; - - for (const value of values) { - const top = chk >>> 25; - chk = ((chk & 0x1ffffff) << 5) ^ value; - for (let bit = 0; bit < generators.length; bit += 1) { - if ((top >>> bit) & 1) { - chk ^= generators[bit]; - } - } - } - - return chk; -} - -function bech32HrpExpand(hrp) { - const expanded = []; - for (let index = 0; index < hrp.length; index += 1) { - const code = hrp.charCodeAt(index); - expanded.push(code >>> 5); - } - expanded.push(0); - for (let index = 0; index < hrp.length; index += 1) { - const code = hrp.charCodeAt(index); - expanded.push(code & 31); - } - return expanded; -} - -function bech32VerifyChecksum(hrp, data) { - return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1; -} - -function bech32Decode(value) { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - const lower = trimmed.toLowerCase(); - const upper = trimmed.toUpperCase(); - if (trimmed !== lower && trimmed !== upper) { - return null; - } - - const normalized = lower; - const separatorIndex = normalized.lastIndexOf("1"); - if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) { - return null; - } - - const hrp = normalized.slice(0, separatorIndex); - const data = []; - for (let index = separatorIndex + 1; index < normalized.length; index += 1) { - const char = normalized[index]; - if (!BECH32_CHARSET_MAP.has(char)) { - return null; - } - data.push(BECH32_CHARSET_MAP.get(char)); - } - - if (!bech32VerifyChecksum(hrp, data)) { - return null; - } - - return { hrp, words: data.slice(0, -6) }; -} - -function convertBits(data, fromBits, toBits, pad = true) { - const result = []; - let acc = 0; - let bits = 0; - const maxv = (1 << toBits) - 1; - const maxAcc = (1 << (fromBits + toBits - 1)) - 1; - - for (const value of data) { - if (value < 0 || value >> fromBits) { - return null; - } - acc = ((acc << fromBits) | value) & maxAcc; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - result.push((acc >>> bits) & maxv); - } - } - - if (pad) { - if (bits) { - result.push((acc << (toBits - bits)) & maxv); - } - } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { - return null; - } - - return result; -} - -function fallbackDecodeNpubToHex(value) { - const decoded = bech32Decode(value); - if (!decoded || decoded.hrp !== "npub") { - return ""; - } - - const bytes = convertBits(decoded.words, 5, 8, false); - if (!bytes || !bytes.length) { - return ""; - } - - return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -function decodeToHex(candidate) { - if (typeof candidate !== "string") { - return ""; - } - - const trimmed = candidate.trim(); - if (!trimmed) { - return ""; - } - - try { - const tools = getNostrTools(); - const decoder = tools?.nip19?.decode; - if (typeof decoder === "function") { - const decoded = decoder(trimmed); - if (!decoded || decoded.type !== "npub") { - return fallbackDecodeNpubToHex(trimmed); - } - const data = decoded.data; - if (typeof data === "string") { - const normalized = normalizeHex(data); - return normalized || fallbackDecodeNpubToHex(trimmed); - } - return bytesToHex(data); - } - return fallbackDecodeNpubToHex(trimmed); - } catch (error) { - return fallbackDecodeNpubToHex(trimmed); - } -} - -function encodeToNpub(hex) { - const normalized = normalizeHex(hex); - if (!normalized) { - return ""; - } - try { - const tools = getNostrTools(); - const encoder = tools?.nip19?.npubEncode; - if (typeof encoder !== "function") { - return ""; - } - return encoder(normalized) || ""; - } catch (error) { - return ""; - } -} - -function normalizeToHex(candidate) { - const direct = normalizeHex(candidate); - if (direct) { - return direct; - } - return decodeToHex(candidate); -} - -function createEmptyAdminSnapshot() { - return { - whitelist: new Set(), - whitelistHex: new Set(), - blacklist: new Set(), - blacklistHex: new Set(), - }; -} - -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; -} - -function resolveStorage() { - if (typeof localStorage !== "undefined") { - return localStorage; - } - if (typeof window !== "undefined" && window.localStorage) { - return window.localStorage; - } - if (typeof globalThis !== "undefined" && globalThis.localStorage) { - return globalThis.localStorage; - } - return null; -} +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 @@ -975,6 +487,72 @@ export class ModerationService { }; } + /** + * Batch retrieval of moderation info for multiple authors. + * Optimized to prune aggregates in one pass. + * + * @param {string[]} authors - List of author pubkeys. + * @returns {Map} 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); } diff --git a/js/services/moderationUtils.js b/js/services/moderationUtils.js new file mode 100644 index 00000000..b773705d --- /dev/null +++ b/js/services/moderationUtils.js @@ -0,0 +1,512 @@ +import logger from "../utils/logger.js"; + +/** + * Trust threshold for disabling autoplay on reported videos. + * If >= 1 trusted user reports content, autoplay is disabled. + */ +export const AUTOPLAY_TRUST_THRESHOLD = 1; + +/** + * Trust threshold for blurring content. + * If >= 1 trusted user reports content, it is blurred. + */ +export const BLUR_TRUST_THRESHOLD = 1; + +/** + * Time window for considering trusted mute list entries valid. + * Mutes older than 60 days are ignored to prevent stale lists from affecting scores. + */ +export const TRUSTED_MUTE_WINDOW_DAYS = 60; +export const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60; + +export function normalizeUserLogger(candidate) { + if ( + candidate && + typeof candidate === "object" && + typeof candidate.info === "function" + ) { + return candidate; + } + return logger.user; +} + +export 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); + } + } + } + } + } +} + +export 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 () => {}; +} + +export function normalizeHex(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim().toLowerCase(); + return trimmed && /^[0-9a-f]{40,64}$/i.test(trimmed) ? trimmed : ""; +} + +export function normalizeEventId(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim().toLowerCase(); + return trimmed && /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed : ""; +} + +export function normalizeReportType(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim().toLowerCase(); + return trimmed ? trimmed : ""; +} + +export function isRelayHint(value) { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim().toLowerCase(); + return ( + trimmed.startsWith("wss://") || + trimmed.startsWith("ws://") || + trimmed.startsWith("https://") || + trimmed.startsWith("http://") + ); +} + +export function normalizeMuteCategory(value) { + return normalizeReportType(value); +} + +export function extractMuteCategoryFromTag(tag) { + if (!Array.isArray(tag)) { + return ""; + } + + const direct = normalizeMuteCategory(tag[3]); + if (direct) { + return direct; + } + + const fallback = typeof tag[2] === "string" && !isRelayHint(tag[2]) + ? normalizeMuteCategory(tag[2]) + : ""; + return fallback; +} + +export 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, + }; +} + +export function getNostrTools() { + if (typeof window !== "undefined" && window?.NostrTools) { + return window.NostrTools; + } + if (typeof globalThis !== "undefined" && globalThis?.NostrTools) { + return globalThis.NostrTools; + } + return null; +} + +export function bytesToHex(bytes) { + if (!bytes || typeof bytes.length !== "number") { + return ""; + } + + let hex = ""; + for (let index = 0; index < bytes.length; index += 1) { + const value = bytes[index]; + if (typeof value !== "number") { + return ""; + } + const normalized = value & 0xff; + hex += normalized.toString(16).padStart(2, "0"); + } + return hex; +} + +/** + * @internal + * Internal implementation of bech32 encoding/decoding. + * This ensures the service can normalize `npub` inputs even if the global + * `NostrTools` object is unavailable or version-mismatched. + */ +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_CHARSET_MAP = (() => { + const map = new Map(); + for (let index = 0; index < BECH32_CHARSET.length; index += 1) { + map.set(BECH32_CHARSET[index], index); + } + return map; +})(); + +/** @internal */ +export function bech32Polymod(values) { + let chk = 1; + const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let bit = 0; bit < generators.length; bit += 1) { + if ((top >>> bit) & 1) { + chk ^= generators[bit]; + } + } + } + + return chk; +} + +export function bech32HrpExpand(hrp) { + const expanded = []; + for (let index = 0; index < hrp.length; index += 1) { + const code = hrp.charCodeAt(index); + expanded.push(code >>> 5); + } + expanded.push(0); + for (let index = 0; index < hrp.length; index += 1) { + const code = hrp.charCodeAt(index); + expanded.push(code & 31); + } + return expanded; +} + +export function bech32VerifyChecksum(hrp, data) { + return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1; +} + +export function bech32Decode(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + const upper = trimmed.toUpperCase(); + if (trimmed !== lower && trimmed !== upper) { + return null; + } + + const normalized = lower; + const separatorIndex = normalized.lastIndexOf("1"); + if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) { + return null; + } + + const hrp = normalized.slice(0, separatorIndex); + const data = []; + for (let index = separatorIndex + 1; index < normalized.length; index += 1) { + const char = normalized[index]; + if (!BECH32_CHARSET_MAP.has(char)) { + return null; + } + data.push(BECH32_CHARSET_MAP.get(char)); + } + + if (!bech32VerifyChecksum(hrp, data)) { + return null; + } + + return { hrp, words: data.slice(0, -6) }; +} + +export function convertBits(data, fromBits, toBits, pad = true) { + const result = []; + let acc = 0; + let bits = 0; + const maxv = (1 << toBits) - 1; + const maxAcc = (1 << (fromBits + toBits - 1)) - 1; + + for (const value of data) { + if (value < 0 || value >> fromBits) { + return null; + } + acc = ((acc << fromBits) | value) & maxAcc; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >>> bits) & maxv); + } + } + + if (pad) { + if (bits) { + result.push((acc << (toBits - bits)) & maxv); + } + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { + return null; + } + + return result; +} + +export function fallbackDecodeNpubToHex(value) { + const decoded = bech32Decode(value); + if (!decoded || decoded.hrp !== "npub") { + return ""; + } + + const bytes = convertBits(decoded.words, 5, 8, false); + if (!bytes || !bytes.length) { + return ""; + } + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function decodeToHex(candidate) { + if (typeof candidate !== "string") { + return ""; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + + try { + const tools = getNostrTools(); + const decoder = tools?.nip19?.decode; + if (typeof decoder === "function") { + const decoded = decoder(trimmed); + if (!decoded || decoded.type !== "npub") { + return fallbackDecodeNpubToHex(trimmed); + } + const data = decoded.data; + if (typeof data === "string") { + const normalized = normalizeHex(data); + return normalized || fallbackDecodeNpubToHex(trimmed); + } + return bytesToHex(data); + } + return fallbackDecodeNpubToHex(trimmed); + } catch (error) { + return fallbackDecodeNpubToHex(trimmed); + } +} + +export function encodeToNpub(hex) { + const normalized = normalizeHex(hex); + if (!normalized) { + return ""; + } + try { + const tools = getNostrTools(); + const encoder = tools?.nip19?.npubEncode; + if (typeof encoder !== "function") { + return ""; + } + return encoder(normalized) || ""; + } catch (error) { + return ""; + } +} + +export function normalizeToHex(candidate) { + const direct = normalizeHex(candidate); + if (direct) { + return direct; + } + return decodeToHex(candidate); +} + +export function createEmptyAdminSnapshot() { + return { + whitelist: new Set(), + whitelistHex: new Set(), + blacklist: new Set(), + blacklistHex: new Set(), + }; +} + +export 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; +} + +export 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 ""; +} + +export 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 ""; +} + +export function ensureNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function resolveStorage() { + if (typeof localStorage !== "undefined") { + return localStorage; + } + if (typeof window !== "undefined" && window.localStorage) { + return window.localStorage; + } + if (typeof globalThis !== "undefined" && globalThis.localStorage) { + return globalThis.localStorage; + } + return null; +}