// js/userBlocks.js import { nostrClient, requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; import { getActiveSigner } from "./nostr/index.js"; import { isSessionActor } from "./nostr/sessionActor.js"; import { normalizeNostrPubkey } from "./nostr/nip46Client.js"; import { buildBlockListEvent, buildMuteListEvent, BLOCK_LIST_IDENTIFIER, } from "./nostrEventSchemas.js"; import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { publishEventToRelays, assertAnyRelayAccepted, } from "./nostrPublish.js"; import { profileCache } from "./state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; import { runNip07WithRetry, NIP07_PRIORITY } from "./nostr/nip07Permissions.js"; class TinyEventEmitter { constructor() { this.listeners = new Map(); } 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) { userLogger.warn( `[UserBlockList] listener for "${eventName}" threw an error`, error, ); } } } } export const USER_BLOCK_EVENTS = Object.freeze({ CHANGE: "change", STATUS: "status", }); const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; const DECRYPT_TIMEOUT_MS = 90000; const DECRYPT_RETRY_DELAY_MS = 10000; function sanitizeRelayList(candidate) { return Array.isArray(candidate) ? candidate .map((url) => (typeof url === "string" ? url.trim() : "")) .filter(Boolean) : []; } function normalizeEncryptionToken(value) { if (typeof value !== "string") { return ""; } return value .trim() .toLowerCase() .replace(/[^a-z0-9]/g, ""); } function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; } const tags = Array.isArray(event.tags) ? event.tags : []; const hints = []; const pushUnique = (scheme) => { if (!scheme || hints.includes(scheme)) { return; } hints.push(scheme); }; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const name = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; if (name !== "encrypted" && name !== "encryption") { continue; } const rawValue = typeof tag[1] === "string" ? tag[1] : ""; if (!rawValue) { continue; } const parts = rawValue .split(/[\s,]+/) .map((part) => normalizeEncryptionToken(part)) .filter(Boolean); for (const part of parts) { if (part === "nip44v2" || part === "nip44v02") { pushUnique("nip44_v2"); continue; } if (part === "nip44") { pushUnique("nip44"); continue; } if (part === "nip04" || part === "nip4") { pushUnique("nip04"); } } } return hints; } function determineDecryptionOrder(event, availableSchemes) { const available = Array.isArray(availableSchemes) ? availableSchemes : []; const availableSet = new Set(available); const prioritized = []; const hints = extractEncryptionHints(event); const aliasMap = { nip04: ["nip04"], nip44: ["nip44", "nip44_v2"], nip44_v2: ["nip44_v2", "nip44"], }; for (const hint of hints) { const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; for (const candidate of candidates) { if (availableSet.has(candidate) && !prioritized.includes(candidate)) { prioritized.push(candidate); break; } } } for (const fallback of ["nip44_v2", "nip44", "nip04"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } } return prioritized.length ? prioritized : available; } function describeDiscardedEntry(entry) { if (typeof entry === "string") { return entry.trim(); } if (entry === null) { return "null"; } if (typeof entry === "undefined") { return "undefined"; } if (Array.isArray(entry)) { try { return JSON.stringify(entry); } catch (_error) { return String(entry); } } if (typeof entry === "object") { try { return JSON.stringify(entry); } catch (_error) { return String(entry); } } return String(entry); } function sanitizeMuteTags(values, ownerPubkey, options = {}) { const logDiscarded = options?.logDiscarded !== false; const owner = normalizeHex(ownerPubkey); const sanitized = []; const seen = new Set(); const iterable = Array.isArray(values) ? values : values instanceof Set ? Array.from(values) : values && typeof values[Symbol.iterator] === "function" ? Array.from(values) : []; const logDiscard = (entry, reason, extra) => { if (!logDiscarded) { return; } const detail = describeDiscardedEntry(entry); if (extra) { userLogger.warn( `[UserBlockList] Discarded block entry (${reason}).`, detail, extra, ); return; } userLogger.warn(`[UserBlockList] Discarded block entry (${reason}).`, detail); }; for (const entry of iterable) { let targetHex = null; if (Array.isArray(entry)) { if (entry.length < 2) { logDiscard(entry, "invalid-tuple"); continue; } const label = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; if (label !== "p") { logDiscard(entry, "unsupported-tag"); continue; } targetHex = normalizeHex(entry[1]); if (!targetHex) { logDiscard(entry, "invalid-target"); continue; } } else if (typeof entry === "string") { targetHex = normalizeHex(entry); if (!targetHex) { logDiscard(entry, "invalid-string"); continue; } } else if (entry && typeof entry === "object") { if (typeof entry.pubkey === "string") { targetHex = normalizeHex(entry.pubkey); if (!targetHex) { logDiscard(entry, "invalid-object-pubkey"); continue; } } else { logDiscard(entry, "unsupported-entry"); continue; } } else { logDiscard(entry, "unsupported-entry"); continue; } if (owner && targetHex === owner) { logDiscard(entry, "self-target"); continue; } if (seen.has(targetHex)) { logDiscard(entry, "duplicate", targetHex); continue; } seen.add(targetHex); sanitized.push(["p", targetHex]); } return sanitized; } function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) { const tags = sanitizeMuteTags(values, ownerPubkey, options); return JSON.stringify(tags); } function extractPubkeysFromTags(tags, ownerPubkey = null) { if (!Array.isArray(tags)) { return []; } const collected = []; const seen = new Set(); const owner = ownerPubkey ? normalizeHex(ownerPubkey) : null; for (const entry of tags) { if (!Array.isArray(entry) || entry.length < 2) { continue; } const label = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : ""; if (label !== "p") { continue; } const normalized = normalizeHex(entry[1]); if (!normalized || (owner && normalized === owner) || seen.has(normalized)) { continue; } seen.add(normalized); collected.push(normalized); } return collected; } function parseBlockListPlaintext(plaintext, ownerPubkey) { if (typeof plaintext !== "string" || !plaintext) { return []; } let parsed; try { parsed = JSON.parse(plaintext); } catch (error) { userLogger.warn( "[UserBlockList] Failed to parse block list ciphertext as JSON; treating as empty.", error, ); return []; } const owner = normalizeHex(ownerPubkey); if (Array.isArray(parsed)) { return extractPubkeysFromTags(parsed, owner); } if (parsed && typeof parsed === "object") { if (Array.isArray(parsed.tags) && parsed.tags.length) { return extractPubkeysFromTags(parsed.tags, owner); } const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : []; return legacy .map((value) => normalizeHex(value)) .filter((value) => value && value !== owner); } return []; } function isUserBlockListEvent(event) { if (!event || typeof event.kind !== "number") { return false; } if (event.kind === 10000) { return true; } if (event.kind === 30002) { const tags = Array.isArray(event.tags) ? event.tags : []; return tags.some( (tag) => Array.isArray(tag) && typeof tag[0] === "string" && tag[0].trim().toLowerCase() === "d" && typeof tag[1] === "string" && tag[1].trim() === BLOCK_LIST_IDENTIFIER, ); } return false; } function isTaggedBlockListEvent(event) { if (!event || !Array.isArray(event.tags)) { return false; } return event.tags.some( (tag) => Array.isArray(tag) && tag.length >= 2 && typeof tag[0] === "string" && tag[0].trim().toLowerCase() === "d" && typeof tag[1] === "string" && tag[1].trim() === BLOCK_LIST_IDENTIFIER, ); } 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; })(); 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; } 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 bytesToHex(bytes); } function normalizeDecodedHex(value) { if (typeof value !== "string") { return null; } const trimmed = value.trim(); if (!/^[0-9a-f]{64}$/i.test(trimmed)) { return null; } return trimmed.toLowerCase(); } function normalizeNpubInput(value) { if (typeof value !== "string") { return ""; } const trimmed = value.trim(); if (!trimmed) { return ""; } const lower = trimmed.toLowerCase(); if (lower.startsWith("nostr:")) { return trimmed.slice(6).trim(); } return trimmed; } function decodeNpubToHex(npub) { if (typeof npub !== "string") { return null; } const trimmed = normalizeNpubInput(npub); if (!trimmed) { return null; } if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase(); } const tools = typeof globalThis !== "undefined" ? globalThis?.NostrTools : null; const decoder = tools?.nip19?.decode; if (typeof decoder === "function") { try { const decoded = decoder(trimmed); if (!decoded || decoded.type !== "npub") { return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed)); } if (typeof decoded.data === "string") { const normalized = normalizeDecodedHex(decoded.data); return normalized || normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed)); } return normalizeDecodedHex(bytesToHex(decoded.data)); } catch (error) { return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed)); } } return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed)); } function normalizeHex(pubkey) { if (typeof pubkey !== "string") { return null; } const trimmed = pubkey.trim(); if (!trimmed) { return null; } if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase(); } const decoded = decodeNpubToHex(trimmed); if (decoded) { return decoded; } return null; } class UserBlockListManager { constructor() { this.blockedPubkeys = new Set(); this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; this.muteEventId = null; this.muteEventCreatedAt = null; this.loaded = false; this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); this.decryptRetryTimeoutId = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { this.reset(); } else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) { this.reset(); if (this.activePubkey) { this.loadBlocks(this.activePubkey); } } }); } reset() { this.blockedPubkeys.clear(); this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; this.muteEventId = null; this.muteEventCreatedAt = null; this.loaded = false; if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } } on(eventName, handler) { return this.emitter.on(eventName, handler); } getBlockedPubkeys() { return Array.from(this.blockedPubkeys); } scheduleDecryptRetry(userPubkey, error) { if (!userPubkey) { return; } if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } this.decryptRetryTimeoutId = setTimeout(() => { this.decryptRetryTimeoutId = null; if (this.activePubkey !== userPubkey) { return; } this.loadBlocks(userPubkey, { since: Number.isFinite(this.blockEventCreatedAt) ? this.blockEventCreatedAt : undefined, }).catch((retryError) => { userLogger.warn("[UserBlockList] Decryption retry failed:", retryError); }); }, DECRYPT_RETRY_DELAY_MS); devLogger.log( `[UserBlockList] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, error, ); } isBlocked(pubkey) { const normalized = normalizeHex(pubkey); if (!normalized) { return false; } return this.blockedPubkeys.has(normalized); } async ensureLoaded(userPubkey) { if (this.loaded) { return; } const normalized = normalizeHex(userPubkey); if (!normalized) { return; } await this.loadBlocks(normalized); } async loadBlocks(userPubkey, options = {}) { const normalized = normalizeHex(userPubkey); this.activePubkey = normalized; if (!normalized) { this.reset(); this.loaded = true; return; } const since = Number.isFinite(options?.since) ? Math.max(0, Math.floor(options.since)) : null; const statusCallback = typeof options?.statusCallback === "function" ? options.statusCallback : null; const emitStatus = (detail) => { if (!detail || typeof detail !== "object") { return; } try { statusCallback?.(detail); } catch (callbackError) { userLogger.warn( "[UserBlockList] statusCallback threw while emitting status", callbackError, ); } try { this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail); } catch (emitterError) { userLogger.warn( "[UserBlockList] Failed to dispatch status event", emitterError, ); } }; const readRelays = relayManager.getReadRelayUrls(); const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); emitStatus({ status: "loading", relays }); const localBlocks = this._loadLocal(normalized); const hasLocalData = !!localBlocks; if (localBlocks) { this.blockedPubkeys = localBlocks; this.loaded = true; this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "sync", blockedPubkeys: Array.from(this.blockedPubkeys), source: "local", }); } const applyBlockedPubkeys = (nextValues, meta = {}) => { const nextSet = new Set(Array.isArray(nextValues) ? nextValues : []); this.blockedPubkeys = nextSet; this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "sync", blockedPubkeys: Array.from(this.blockedPubkeys), ...meta, }); }; const resolveDecryptors = async (event) => { let signer = getActiveSigner(); const hints = extractEncryptionHints(event); const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); const requiresNip04 = !hints.length || hints.includes("nip04"); const signerHasRequiredDecryptors = (candidate) => { const hasNip04 = typeof candidate?.nip04Decrypt === "function"; const hasNip44 = typeof candidate?.nip44Decrypt === "function"; return (!requiresNip44 || hasNip44) && (!requiresNip04 || hasNip04); }; if ( !signer || (!signerHasRequiredDecryptors(signer) && typeof nostrClient?.ensureActiveSignerForPubkey === "function") ) { signer = await nostrClient.ensureActiveSignerForPubkey(normalized); } const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; let permissionError = null; if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { try { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { permissionError = permissionResult?.error instanceof Error ? permissionResult.error : new Error( "Extension permissions are required to use the browser decryptor.", ); } } catch (error) { permissionError = error instanceof Error ? error : new Error(String(error)); } } const decryptors = new Map(); const sources = new Map(); const registerDecryptor = (scheme, handler, source) => { if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { return; } decryptors.set(scheme, handler); sources.set(scheme, source || "unknown"); }; if (signerHasNip44) { registerDecryptor( "nip44", (payload) => signer.nip44Decrypt(normalized, payload, { priority: NIP07_PRIORITY.HIGH, }), "active-signer", ); registerDecryptor( "nip44_v2", (payload) => signer.nip44Decrypt(normalized, payload, { priority: NIP07_PRIORITY.HIGH, }), "active-signer", ); } if (signerHasNip04) { registerDecryptor( "nip04", (payload) => signer.nip04Decrypt(normalized, payload, { priority: NIP07_PRIORITY.HIGH, }), "active-signer", ); } const nostrApi = typeof window !== "undefined" ? window?.nostr : null; if (nostrApi) { if (typeof nostrApi.nip04?.decrypt === "function") { registerDecryptor( "nip04", (payload) => runNip07WithRetry( () => nostrApi.nip04.decrypt(normalized, payload), { label: "nip04.decrypt", priority: NIP07_PRIORITY.HIGH }, ), "extension", ); } const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; if (nip44) { if (typeof nip44.decrypt === "function") { registerDecryptor( "nip44", (payload) => runNip07WithRetry(() => nip44.decrypt(normalized, payload), { label: "nip44.decrypt", priority: NIP07_PRIORITY.HIGH, }), "extension", ); } const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; if (nip44v2 && typeof nip44v2.decrypt === "function") { registerDecryptor( "nip44_v2", (payload) => runNip07WithRetry(() => nip44v2.decrypt(normalized, payload), { label: "nip44.v2.decrypt", priority: NIP07_PRIORITY.HIGH, }), "extension", ); if (!decryptors.has("nip44")) { registerDecryptor( "nip44", (payload) => runNip07WithRetry( () => nip44v2.decrypt(normalized, payload), { label: "nip44.v2.decrypt", priority: NIP07_PRIORITY.HIGH }, ), "extension", ); } } } } return { decryptors, sources, permissionError, order: determineDecryptionOrder(event, Array.from(decryptors.keys())), }; }; try { if (!relays.length) { // Only clear if truly no relays, though loadBlocks with no relays is weird. if (!this.loaded) { this.blockedPubkeys.clear(); this.blockEventId = null; this.blockEventCreatedAt = null; this.loaded = true; } emitStatus({ status: "applied-empty" }); emitStatus({ status: "settled" }); return; } const applyEvents = async ( events, { skipIfEmpty = false, source = "fast" } = {}, ) => { const previousState = { blockedPubkeys: new Set(this.blockedPubkeys), blockEventId: this.blockEventId, blockEventCreatedAt: this.blockEventCreatedAt, loaded: this.loaded, }; if (!Array.isArray(events) || !events.length) { if (skipIfEmpty) { return; } if ( this.lastPublishedCreatedAt !== null || this.blockEventCreatedAt !== null ) { emitStatus({ status: "stale", reason: "empty-result", source }); return; } if (!this.loaded) { this.blockEventId = null; this.blockEventCreatedAt = null; applyBlockedPubkeys([], { source, reason: "empty-result" }); } emitStatus({ status: "applied-empty", source }); return; } const validEvents = events.filter( (event) => event && event.pubkey === normalized && isUserBlockListEvent(event), ); if (!validEvents.length) { if (skipIfEmpty) { return; } if ( this.lastPublishedCreatedAt !== null || this.blockEventCreatedAt !== null ) { emitStatus({ status: "stale", reason: "empty-result", source }); return; } if (!this.loaded) { this.blockEventId = null; this.blockEventCreatedAt = null; applyBlockedPubkeys([], { source, reason: "empty-result" }); } emitStatus({ status: "applied-empty", source }); return; } // Separate by logic (Block vs Mute) // Block: Kind 10000 with d=user-blocks OR Kind 30002 (legacy) const blockEvents = validEvents .filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); // Mute: Kind 10000 WITHOUT d=user-blocks const muteEvents = validEvents .filter((e) => e.kind === 10000 && !isTaggedBlockListEvent(e)) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); const newestBlock = blockEvents[0] || null; const newestMute = muteEvents[0] || null; const newestMuteTime = Number.isFinite(newestMute?.created_at) ? newestMute.created_at : 0; const newestBlockTime = Number.isFinite(newestBlock?.created_at) ? newestBlock.created_at : 0; // The "canonical" newest event for stale checks is simply the latest of either. const newestOverall = newestMuteTime >= newestBlockTime ? newestMute : newestBlock; const newestCreatedAt = Math.max(newestMuteTime, newestBlockTime); // We accept this state. this.blockEventId = newestOverall?.id || null; this.blockEventCreatedAt = newestCreatedAt; // Helper to decrypt a single event const decryptEvent = async (ev) => { if (!ev) return []; const isContentEmpty = !ev.content || !ev.content.trim(); if (isContentEmpty) { // Handle tag-only lists (e.g. Kind 10000 with empty content but populated p tags) if (Array.isArray(ev.tags) && ev.tags.length) { return extractPubkeysFromTags(ev.tags, normalized); } return []; } const { decryptors, order, sources, permissionError } = await resolveDecryptors(ev); if (!decryptors.size) { if (permissionError) { throw permissionError; } return []; } let decryptedText = ""; for (const scheme of order) { const handler = decryptors.get(scheme); if (typeof handler !== "function") continue; try { const plaintext = await handler(ev.content); if (typeof plaintext === "string") { decryptedText = plaintext; break; } } catch { // ignore } } if (!decryptedText) { // If decryption fails completely for a present event, we might want to log it // but return empty for that specific event. return []; } return parseBlockListPlaintext(decryptedText, normalized); }; const mergedPubkeys = new Set(); let decryptionError = null; try { const decryptPromise = Promise.all([ decryptEvent(newestMute), decryptEvent(newestBlock), ]); const timeoutPromise = new Promise((_, reject) => setTimeout( () => { const timeoutError = new Error( `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, ); timeoutError.code = "user-blocklist-decrypt-timeout"; reject(timeoutError); }, DECRYPT_TIMEOUT_MS, ), ); const [muteList, blockList] = await Promise.race([ decryptPromise, timeoutPromise, ]); muteList.forEach((pk) => mergedPubkeys.add(pk)); blockList.forEach((pk) => mergedPubkeys.add(pk)); } catch (error) { // If permission denied or critical failure decryptionError = error; } if (decryptionError) { if (decryptionError.code === "user-blocklist-decrypt-timeout") { this.blockedPubkeys = previousState.blockedPubkeys; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; emitStatus({ status: "settled" }); this.scheduleDecryptRetry(normalized, decryptionError); return; } userLogger.warn( "[UserBlockList] Decryption failed during merge.", decryptionError, ); this.blockedPubkeys = previousState.blockedPubkeys; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; emitStatus({ status: "error", error: decryptionError, decryptor: "mixed", }); emitStatus({ status: "settled" }); return; } const hasMute = Boolean(newestMute); const hasBlock = Boolean(newestBlock); // Check if we should log "empty-events" reason // Effectively empty means: missing content AND missing valid tags const isMuteEffectiveEmpty = !newestMute || ((!newestMute.content || !newestMute.content.trim()) && (!newestMute.tags || !extractPubkeysFromTags(newestMute.tags, normalized).length)); const isBlockEffectiveEmpty = !newestBlock || ((!newestBlock.content || !newestBlock.content.trim()) && (!newestBlock.tags || !extractPubkeysFromTags(newestBlock.tags, normalized).length)); const effectiveReason = (isMuteEffectiveEmpty && isBlockEffectiveEmpty) ? "empty-events" : "applied-merge"; applyBlockedPubkeys(Array.from(mergedPubkeys), { source, reason: effectiveReason, events: [newestMute?.id, newestBlock?.id].filter(Boolean), }); if (effectiveReason === "empty-events") { emitStatus({ status: "applied-empty", event: newestOverall, source }); } else { emitStatus({ status: "applied", event: newestOverall, blockedPubkeys: Array.from(this.blockedPubkeys), source, }); } }; // If we don't have local data, we must ignore the sync metadata and force a full fetch (since: 0). // Otherwise, we might miss data if the metadata store says we are up-to-date but the actual list is missing. const fetchSince = hasLocalData ? undefined : 0; // Concurrent incremental fetch for both kinds const [muteEvents, blockEvents] = await Promise.all([ nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, relayUrls: relays, since: fetchSince, timeoutMs: 12000, }), nostrClient.fetchListIncrementally({ kind: 10000, pubkey: normalized, dTag: BLOCK_LIST_IDENTIFIER, relayUrls: relays, since: fetchSince, timeoutMs: 12000, }), ]); const combined = [...muteEvents, ...blockEvents]; // If combined is empty, check if we have loaded state if (!combined.length) { if (!this.loaded) { // Assuming empty if we have no prior state await applyEvents([], { source: "incremental" }); } } else { await applyEvents(combined, { source: "incremental" }); } } catch (error) { userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error); emitStatus({ status: "error", error }); } finally { this.loaded = true; emitStatus({ status: "settled" }); } } _loadLocal(actorHex) { const normalizedActorHex = normalizeHex(actorHex); if (!normalizedActorHex) { return null; } const cached = profileCache.getProfileData(normalizedActorHex, "blocks"); if (Array.isArray(cached)) { return new Set(cached); } return null; } _saveLocal(actorHex) { const normalizedActorHex = normalizeHex(actorHex); if (!normalizedActorHex) { return; } profileCache.setProfileData( normalizedActorHex, "blocks", Array.from(this.blockedPubkeys), ); } _getSeedState(actorHex) { const normalized = normalizeHex(actorHex); if (!normalized) { return { seeded: false, removals: new Set() }; } if (this.seedStateCache.has(normalized)) { return this.seedStateCache.get(normalized); } const stored = profileCache.getProfileData(normalized, "blocks_meta") || {}; const seeded = !!stored.seeded; const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []); const state = { seeded, removals }; this.seedStateCache.set(normalized, state); return state; } _persistSeedState(actorHex) { const normalized = normalizeHex(actorHex); if (!normalized) return; const state = this.seedStateCache.get(normalized); if (!state) return; profileCache.setProfileData(normalized, "blocks_meta", { seeded: state.seeded, removals: Array.from(state.removals), }); } _setSeeded(actorHex, seeded) { const normalized = normalizeHex(actorHex); if (!normalized) return; const state = this._getSeedState(normalized); state.seeded = Boolean(seeded); this._persistSeedState(normalized); } _addSeedRemoval(actorHex, targetHex) { const normalizedActor = normalizeHex(actorHex); const normalizedTarget = normalizeHex(targetHex); if (!normalizedActor || !normalizedTarget) return; const state = this._getSeedState(normalizedActor); if (!state.removals.has(normalizedTarget)) { state.removals.add(normalizedTarget); this._persistSeedState(normalizedActor); } } _clearSeedRemoval(actorHex, targetHex) { const normalizedActor = normalizeHex(actorHex); const normalizedTarget = normalizeHex(targetHex); if (!normalizedActor || !normalizedTarget) return; const state = this._getSeedState(normalizedActor); if (state.removals.delete(normalizedTarget)) { this._persistSeedState(normalizedActor); } } async seedWithNpubs(userPubkey, candidateNpubs = []) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { return { ok: false, seeded: false, reason: "invalid-user" }; } await this.ensureLoaded(actorHex); const state = this._getSeedState(actorHex); if (state.seeded) { return { ok: true, seeded: false, reason: "already-seeded" }; } const removals = state.removals; const additions = new Set(); let invalidCount = 0; const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : []; for (const candidate of candidates) { const candidateHex = normalizeHex(candidate); if (!candidateHex) { invalidCount += 1; continue; } if (candidateHex === actorHex) { continue; } if (this.blockedPubkeys.has(candidateHex)) { continue; } if (removals.has(candidateHex)) { continue; } additions.add(candidateHex); } if (!additions.size) { if (candidates.length) { devLogger.info( "[UserBlockList] Seed candidates resolved to zero additions.", { actorPubkey: actorHex, candidateCount: candidates.length, invalidCount, blockedCount: this.blockedPubkeys.size, removalCount: removals.size, }, ); } return { ok: true, seeded: false, reason: "no-candidates" }; } const snapshot = new Set(this.blockedPubkeys); additions.forEach((hex) => this.blockedPubkeys.add(hex)); try { await this.publishBlockList(actorHex); } catch (error) { this.blockedPubkeys = snapshot; throw error; } this._setSeeded(actorHex, true); additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex)); this._saveLocal(actorHex); try { this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "seed", actorPubkey: actorHex, blockedPubkeys: Array.from(this.blockedPubkeys), addedPubkeys: Array.from(additions), }); } catch (error) { userLogger.warn("[UserBlockList] Failed to emit seed change event:", error); } return { ok: true, seeded: true, addedPubkeys: Array.from(additions) }; } async seedLocalBaseline(userPubkey, candidateNpubs = []) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { return { ok: false, seeded: false, reason: "invalid-user" }; } await this.ensureLoaded(actorHex); const state = this._getSeedState(actorHex); if (state.seeded) { return { ok: true, seeded: false, reason: "already-seeded" }; } if (this.blockedPubkeys.size > 0) { return { ok: true, seeded: false, reason: "non-empty" }; } const removals = state.removals; const additions = new Set(); let invalidCount = 0; const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : []; for (const candidate of candidates) { const candidateHex = normalizeHex(candidate); if (!candidateHex) { invalidCount += 1; continue; } if (candidateHex === actorHex) { continue; } if (removals.has(candidateHex)) { continue; } additions.add(candidateHex); } if (!additions.size) { if (candidates.length) { devLogger.info( "[UserBlockList] Local seed candidates resolved to zero additions.", { actorPubkey: actorHex, candidateCount: candidates.length, invalidCount, removalCount: removals.size, }, ); } this._setSeeded(actorHex, true); return { ok: true, seeded: true, addedPubkeys: [] }; } additions.forEach((hex) => this.blockedPubkeys.add(hex)); this._setSeeded(actorHex, true); additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex)); try { this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "seed", actorPubkey: actorHex, blockedPubkeys: Array.from(this.blockedPubkeys), addedPubkeys: Array.from(additions), localOnly: true, }); } catch (error) { userLogger.warn("[UserBlockList] Failed to emit local seed change event:", error); } return { ok: true, seeded: true, addedPubkeys: Array.from(additions) }; } async seedBaselineDelta(userPubkey, candidateNpubs = []) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { return { ok: false, seeded: false, reason: "invalid-user" }; } await this.ensureLoaded(actorHex); const state = this._getSeedState(actorHex); const removals = state.removals; const additions = new Set(); let invalidCount = 0; const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : []; for (const candidate of candidates) { const candidateHex = normalizeHex(candidate); if (!candidateHex) { invalidCount += 1; continue; } if (candidateHex === actorHex) { continue; } if (this.blockedPubkeys.has(candidateHex)) { continue; } if (removals.has(candidateHex)) { continue; } additions.add(candidateHex); } if (!additions.size) { if (candidates.length) { devLogger.info( "[UserBlockList] Baseline delta candidates resolved to zero additions.", { actorPubkey: actorHex, candidateCount: candidates.length, invalidCount, blockedCount: this.blockedPubkeys.size, removalCount: removals.size, }, ); } if (!state.seeded) { this._setSeeded(actorHex, true); } return { ok: true, seeded: state.seeded, reason: "no-candidates" }; } additions.forEach((hex) => this.blockedPubkeys.add(hex)); this._setSeeded(actorHex, true); this._saveLocal(actorHex); try { this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "seed-delta", actorPubkey: actorHex, blockedPubkeys: Array.from(this.blockedPubkeys), addedPubkeys: Array.from(additions), localOnly: true, }); } catch (error) { userLogger.warn("[UserBlockList] Failed to emit delta seed change event:", error); } return { ok: true, seeded: true, addedPubkeys: Array.from(additions) }; } async addBlock(targetPubkey, userPubkey) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { throw new Error("Invalid user pubkey."); } const targetHex = normalizeHex(targetPubkey); if (!targetHex) { const err = new Error("Invalid target pubkey."); err.code = "invalid"; throw err; } if (actorHex === targetHex) { const err = new Error("Cannot block yourself."); err.code = "self"; throw err; } await this.ensureLoaded(actorHex); if (this.blockedPubkeys.has(targetHex)) { return { ok: true, already: true }; } const snapshot = new Set(this.blockedPubkeys); this.blockedPubkeys.add(targetHex); const loggedInPubkey = normalizeHex(nostrClient?.pubkey); const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey); const isSessionActor = !!actorHex && !loggedInPubkey && actorHex === sessionPubkey; if (isSessionActor) { this._saveLocal(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "block", targetPubkey: targetHex, actorPubkey: actorHex, }); this._clearSeedRemoval(actorHex, targetHex); return { ok: true }; } try { await this.publishBlockList(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "block", targetPubkey: targetHex, actorPubkey: actorHex, }); this._clearSeedRemoval(actorHex, targetHex); return { ok: true }; } catch (err) { this.blockedPubkeys = snapshot; throw err; } } async removeBlock(targetPubkey, userPubkey) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { throw new Error("Invalid user pubkey."); } const targetHex = normalizeHex(targetPubkey); if (!targetHex) { return { ok: true, already: true }; } await this.ensureLoaded(actorHex); if (!this.blockedPubkeys.has(targetHex)) { return { ok: true, already: true }; } const snapshot = new Set(this.blockedPubkeys); this.blockedPubkeys.delete(targetHex); const loggedInPubkey = normalizeHex(nostrClient?.pubkey); const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey); const isSessionActor = !!actorHex && !loggedInPubkey && actorHex === sessionPubkey; if (isSessionActor) { this._saveLocal(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "unblock", targetPubkey: targetHex, actorPubkey: actorHex, }); this._addSeedRemoval(actorHex, targetHex); return { ok: true }; } try { await this.publishBlockList(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "unblock", targetPubkey: targetHex, actorPubkey: actorHex, }); this._addSeedRemoval(actorHex, targetHex); return { ok: true }; } catch (err) { this.blockedPubkeys = snapshot; throw err; } } async publishMuteListSnapshot({ signer, ownerPubkey, blockedPubkeys = [], createdAt, plaintext = "", onStatus, } = {}) { // nostrClient imported from nostrClientFacade if (isSessionActor(nostrClient)) { const error = new Error( "Publishing mute lists is not allowed for session actors." ); error.code = "session-actor-publish-blocked"; throw error; } const owner = normalizeHex(ownerPubkey); if (!owner || !signer || typeof signer.signEvent !== "function") { return null; } const sanitizedTags = sanitizeMuteTags(blockedPubkeys, owner); const pTags = sanitizedTags.map((tag) => tag[1]); const timestamp = Number.isFinite(createdAt) ? Math.max(0, Math.floor(createdAt)) : Math.floor(Date.now() / 1000); const payloadText = typeof plaintext === "string" ? plaintext : ""; let encryptedContent = ""; let encryptionTagValue = ""; if (payloadText) { const encryptionCandidates = []; if (typeof signer.nip44Encrypt === "function") { encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt }); } if (typeof signer.nip04Encrypt === "function") { encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt }); } for (const candidate of encryptionCandidates) { try { const encrypted = await candidate.encrypt(owner, payloadText); if (typeof encrypted === "string" && encrypted) { encryptedContent = encrypted; encryptionTagValue = candidate.tag; break; } } catch (error) { // Ignore encryption failures and fall back to the next candidate. } } } const event = buildMuteListEvent({ pubkey: owner, created_at: timestamp, pTags, content: encryptedContent, encrypted: !!encryptionTagValue, encryptionTag: encryptionTagValue, }); onStatus?.({ status: "mute-publishing" }); let signedEvent; try { signedEvent = await signer.signEvent(event); } catch (error) { const wrapped = error instanceof Error ? error : new Error(String(error || "mute-signature-failed")); wrapped.code = wrapped.code || "mute-signature-failed"; throw wrapped; } const publishResults = await publishEventToRelays( nostrClient.pool, nostrClient.relays, signedEvent, ); const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" }); if (summary.failed.length) { summary.failed.forEach(({ url, error: relayError }) => { const reason = relayError instanceof Error ? relayError.message : relayError ? String(relayError) : "publish failed"; userLogger.warn( `[UserBlockList] Mute list not accepted by ${url}: ${reason}`, relayError, ); }); } this.muteEventId = signedEvent.id; this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at) ? signedEvent.created_at : event.created_at; onStatus?.({ status: "mute-published", event: signedEvent }); return signedEvent; } async publishBlockList(userPubkey, options = {}) { if (isSessionActor(nostrClient)) { const error = new Error( "Publishing block lists is not allowed for session actors." ); error.code = "session-actor-publish-blocked"; throw error; } const onStatus = options && typeof options.onStatus === "function" ? options.onStatus : null; onStatus?.({ status: "publishing" }); let signer = getActiveSigner(); if (!signer) { signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey); } if (!signer) { const err = new Error( "An active signer is required to update the block list." ); err.code = "signer-missing"; throw err; } if (signer.type === "extension") { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult.ok) { userLogger.warn( "[UserBlockList] Signer permissions denied while updating the block list.", permissionResult.error, ); const err = new Error( "The active signer must allow encryption and signing before updating the block list.", ); err.code = "extension-permission-denied"; err.cause = permissionResult.error; throw err; } } if (typeof signer.signEvent !== "function") { const err = new Error("Active signer missing signEvent support."); err.code = "sign-event-missing"; throw err; } const normalized = normalizeHex(userPubkey); if (!normalized) { throw new Error("Invalid user pubkey."); } const sanitizedTags = sanitizeMuteTags(this.blockedPubkeys, normalized); const payload = { blockedPubkeys: sanitizedTags.map(([, target]) => target), }; const plaintext = serializeBlockListTagMatrix(sanitizedTags, normalized, { logDiscarded: false, }); const encryptors = []; const registerEncryptor = (scheme, handler) => { if (!scheme || typeof handler !== "function") { return; } encryptors.push({ scheme, handler }); }; if (typeof signer.nip44Encrypt === "function") { registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(normalized, value)); registerEncryptor("nip44", (value) => signer.nip44Encrypt(normalized, value)); } if (typeof signer.nip04Encrypt === "function") { registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value)); } if (!encryptors.length) { const err = new Error( "An encryption-capable signer is required to update the block list." ); err.code = "block-list-missing-encryptor"; throw err; } let cipherText = ""; let encryptionScheme = ""; const seenSchemes = new Set(); const encryptionErrors = []; for (const candidate of encryptors) { if (seenSchemes.has(candidate.scheme)) { continue; } seenSchemes.add(candidate.scheme); try { const encrypted = await candidate.handler(plaintext); if (typeof encrypted === "string" && encrypted) { cipherText = encrypted; encryptionScheme = candidate.scheme; break; } } catch (error) { encryptionErrors.push({ scheme: candidate.scheme, error }); } } if (!cipherText) { const err = new Error("Failed to encrypt block list."); err.code = "block-list-encrypt-failed"; err.cause = encryptionErrors; throw err; } const encryptionTagValue = encryptionScheme === "nip44_v2" ? "nip44_v2" : encryptionScheme === "nip44" ? "nip44" : encryptionScheme === "nip04" ? "nip04" : undefined; const event = buildBlockListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), content: cipherText, encryption: encryptionTagValue, }); const signedEvent = await signer.signEvent(event); const publishResults = await publishEventToRelays( nostrClient.pool, nostrClient.relays, signedEvent ); let publishSummary; try { publishSummary = assertAnyRelayAccepted(publishResults, { context: "block list", }); } catch (publishError) { if (publishError?.relayFailures?.length) { publishError.relayFailures.forEach( ({ url, error: relayError, reason }) => { userLogger.error( `[UserBlockList] Block list rejected by ${url}: ${reason}`, relayError || reason ); } ); } throw publishError; } if (publishSummary.failed.length) { publishSummary.failed.forEach(({ url, error: relayError }) => { const reason = relayError instanceof Error ? relayError.message : relayError ? String(relayError) : "publish failed"; userLogger.warn( `[UserBlockList] Block list not accepted by ${url}: ${reason}`, relayError ); }); } this.blockEventId = signedEvent.id; this.blockEventCreatedAt = Number.isFinite(signedEvent?.created_at) ? signedEvent.created_at : event.created_at; this.lastPublishedCreatedAt = this.blockEventCreatedAt; onStatus?.({ status: "published", event: signedEvent }); try { await this.publishMuteListSnapshot({ signer, ownerPubkey: normalized, blockedPubkeys: payload.blockedPubkeys, createdAt: event.created_at, plaintext, onStatus, }); } catch (muteError) { userLogger.warn("[UserBlockList] Failed to publish mute list", muteError); } try { await this.loadBlocks(normalized, { since: this.lastPublishedCreatedAt ?? undefined, statusCallback: (detail) => { if (!onStatus) { return; } onStatus({ status: "relay", detail }); }, }); } catch (refreshError) { onStatus?.({ status: "relay-error", error: refreshError }); } return signedEvent; } } export const userBlocks = new UserBlockListManager(); if (typeof window !== "undefined") { window.userBlocks = userBlocks; }