mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
perf(feed): optimize moderation stage with batch author lookups (perf-optimization-agent)
- Implement `getModerationInfoForAuthors` in `ModerationService` for batching. - Extract utility functions to `js/services/moderationUtils.js` to reduce `ModerationService` size. - Update `createModerationStage` to use batch lookup, reducing sequential overhead. - Benchmarks show ~50% improvement (454ms -> 227ms) for 5000 items. - All unit tests pass. - Task log: `docs/agents/task-logs/weekly/2026-02-22_01-25-01_perf-optimization-agent_completed.md` Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
e6e85ff845
commit
0ae813ea66
7 files changed
+856
-539
No files matched your search
@@ -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**
|
||||
+18
@@ -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**
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
+66
-28
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user