// js/userBlocks.js import { nostrClient, requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; import { getActiveSigner } from "./nostr/index.js"; import { isSessionActor } from "./nostr/sessionActor.js"; import { buildBlockListEvent, buildMuteListEvent, BLOCK_LIST_IDENTIFIER, KIND_MUTE_LIST, } from "./nostrEventSchemas.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { STANDARD_TIMEOUT_MS, MAX_BLOCKLIST_ENTRIES } from "./constants.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 { DEFAULT_NIP07_ENCRYPTION_METHODS, runNip07WithRetry, NIP07_PRIORITY, } from "./nostr/nip07Permissions.js"; import { relaySubscriptionService } from "./services/relaySubscriptionService.js"; import { getLastSuccessfulScheme, setLastSuccessfulScheme, } from "./nostr/decryptionSchemeCache.js"; import { compareListEventsDesc, computeIncrementalSinceWithOverlap, selectNewestListEvent, } from "./nostr/listEventOrdering.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 MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT = 24; // Keep interactive decrypt timeout above extension retry timeout window. const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 15000); const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000; // PERF: Reduced from 10s to 3s for faster recovery during login. const DECRYPT_RETRY_DELAY_MS = 3000; const MAX_DECRYPT_RETRY_DELAY_MS = 30000; function computeRetryDelay(baseDelayMs, attempt) { const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; const multiplier = 2 ** normalizedAttempt; const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); } function sanitizeRelayList(candidate) { if (!Array.isArray(candidate)) return []; const seen = new Set(); const relays = []; for (const value of candidate) { const normalized = typeof value === "string" ? value.trim() : ""; if (!normalized || seen.has(normalized)) continue; seen.add(normalized); relays.push(normalized); } return relays; } 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, cachedScheme = null) { const available = Array.isArray(availableSchemes) ? availableSchemes : []; const availableSet = new Set(available); const prioritized = []; if (cachedScheme && availableSet.has(cachedScheme)) { prioritized.push(cachedScheme); } 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, options = {}) { if (!Array.isArray(tags)) { return []; } const collected = []; const seen = new Set(); const owner = ownerPubkey ? normalizeHex(ownerPubkey) : null; const limit = Number.isFinite(options?.limit) ? Math.max(0, Math.floor(options.limit)) : 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); if (limit !== null && collected.length >= limit) { break; } } return collected; } function parseBlockListPlaintext(plaintext, ownerPubkey, options = {}) { 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); const limit = Number.isFinite(options?.limit) ? Math.max(0, Math.floor(options.limit)) : null; if (Array.isArray(parsed)) { if (limit !== null && parsed.length > limit) { devLogger.warn( "[UserBlockList] Block list payload exceeds entry limit; truncating.", { limit, payloadSize: parsed.length }, ); } return extractPubkeysFromTags(parsed, owner, { limit }); } if (parsed && typeof parsed === "object") { if (Array.isArray(parsed.tags) && parsed.tags.length) { if (limit !== null && parsed.tags.length > limit) { devLogger.warn( "[UserBlockList] Block list tag payload exceeds entry limit; truncating.", { limit, payloadSize: parsed.tags.length }, ); } return extractPubkeysFromTags(parsed.tags, owner, { limit }); } const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : []; if (limit !== null && legacy.length > limit) { devLogger.warn( "[UserBlockList] Legacy block list payload exceeds entry limit; truncating.", { limit, payloadSize: legacy.length }, ); } const normalizedLegacy = legacy .map((value) => normalizeHex(value)) .filter((value) => value && value !== owner); if (limit === null) { return normalizedLegacy; } return normalizedLegacy.slice(0, limit); } return []; } function isUserBlockListEvent(event) { if (!event || typeof event.kind !== "number") { return false; } if (event.kind === KIND_MUTE_LIST) { 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._privateBlocks = new Set(); this._publicMutes = new Set(); this.activePubkey = null; 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; this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.blockListSubscriptionKey = 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.stopBlockListSubscription(); this.blockedPubkeys.clear(); this._privateBlocks.clear(); this._publicMutes.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; } this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; } stopBlockListSubscription() { if (this.blockListSubscriptionHandle) { try { this.blockListSubscriptionHandle.close(); } catch (error) { devLogger.warn("[UserBlockList] Failed to close block-list subscription handle", error); } this.blockListSubscriptionHandle = null; } if (this.blockListSubscriptionKey) { relaySubscriptionService.stopSubscription( this.blockListSubscriptionKey, "reset", ); this.blockListSubscriptionKey = null; } } ensureBlockListSubscription(userPubkey, relays) { const normalized = normalizeHex(userPubkey); if (!normalized || !nostrClient?.pool) { return null; } const key = `block-list:${normalized}`; this.blockListSubscriptionKey = key; const filters = [ { kinds: [KIND_MUTE_LIST], authors: [normalized] }, { kinds: [30002], authors: [normalized], "#d": [BLOCK_LIST_IDENTIFIER] }, ]; // Route through the L1 SubscriptionManager when available (dedup + reconnect // re-issue); fall back to relaySubscriptionService in manager-less envs. const manager = typeof nostrClient.getSubscriptionManager === "function" ? nostrClient.getSubscriptionManager() : null; if (manager) { this.blockListSubscriptionHandle = manager.subscribe({ key, relays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), }); return this.blockListSubscriptionHandle; } return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, relays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), }); } handleBlockListEvent(event) { const normalized = normalizeHex(event?.pubkey); if (!normalized || normalized !== this.activePubkey) { return; } if (!isUserBlockListEvent(event)) { return; } this.loadBlocks(normalized).catch((error) => { devLogger.warn("[UserBlockList] Failed to refresh after block list event", error); }); } on(eventName, handler) { return this.emitter.on(eventName, handler); } getBlockedPubkeys() { return Array.from(this.blockedPubkeys); } getPublicMutes() { return Array.from(this._publicMutes); } getPrivateBlocks() { return Array.from(this._privateBlocks); } scheduleDecryptRetry(userPubkey, detail = {}) { if (!userPubkey) { return; } if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } const attempt = this.decryptRetryAttempt; const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt); this.decryptRetryAttempt = attempt + 1; const retryMeta = detail && typeof detail === "object" ? detail : {}; const signerStatus = retryMeta.signerStatus || "unknown"; this.decryptRetryTimeoutId = setTimeout(() => { this.decryptRetryTimeoutId = null; if (this.activePubkey !== userPubkey) { return; } const sinceValue = Number.isFinite(this.blockEventCreatedAt) ? Math.max(0, this.blockEventCreatedAt - 1) : undefined; this.loadBlocks(userPubkey, { since: sinceValue, }).catch((retryError) => { userLogger.warn("[UserBlockList] Decryption retry failed:", retryError); }); }, retryDelayMs); devLogger.log( `[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${retryDelayMs / 1000}s.`, retryMeta.error || retryMeta, ); } 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); if (this.loadPromise && this.loadingPubkey === normalized) { devLogger.log("[UserBlockList] Reusing in-flight block list load.", { pubkey: normalized, }); return this.loadPromise; } if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) { devLogger.log("[UserBlockList] Waiting for existing block list load to settle.", { previous: this.loadingPubkey, next: normalized, }); await this.loadPromise.catch(() => {}); } const loadPromise = this.loadBlocksInternal(normalized, options); this.loadPromise = loadPromise; this.loadingPubkey = normalized; try { return await loadPromise; } finally { this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "load-settled", blockedPubkeys: Array.from(this.blockedPubkeys), loaded: this.loaded === true, activePubkey: this.activePubkey || null, }); if (this.loadPromise === loadPromise) { this.loadPromise = null; this.loadingPubkey = null; } } } async loadBlocksInternal(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 loadMode = options?.mode === "background" ? "background" : "interactive"; const allowPermissionPrompt = options?.allowPermissionPrompt !== false && loadMode !== "background"; const decryptTimeoutMs = Number.isFinite(options?.decryptTimeoutMs) ? Math.max(0, Math.floor(options.decryptTimeoutMs)) : allowPermissionPrompt ? DECRYPT_TIMEOUT_MS : BACKGROUND_DECRYPT_TIMEOUT_MS; // PERF: Previous 3s no-prompt timeout was too aggressive — extensions that // have already granted permission still need time to decrypt. 8s gives a // realistic window while still failing faster than the full 15s interactive // timeout. const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000; 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 writeRelays = relayManager.getWriteRelayUrls(); const relays = sanitizeRelayList([ ...readRelays, ...writeRelays, ]); const effectiveRelays = relays.length > 0 ? relays : Array.from(DEFAULT_RELAY_URLS); emitStatus({ status: "loading", relays: effectiveRelays }); if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") { try { await nostrClient.ensurePool(); } catch (error) { devLogger.warn("[UserBlockList] Failed to initialize relay pool for subscription", error); } } this.ensureBlockListSubscription(normalized, effectiveRelays); const localData = this._loadLocal(normalized); const hasLocalData = !!localData; if (localData) { this._privateBlocks = localData.privateBlocks || localData.blockedPubkeys || new Set(); this._publicMutes = localData.publicMutes || new Set(); this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]); if (Number.isFinite(localData.createdAt)) { this.blockEventCreatedAt = localData.createdAt; } this.loaded = true; this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "sync", blockedPubkeys: Array.from(this.blockedPubkeys), source: "local", }); } const applySets = (privateSet, publicSet, meta = {}) => { this._privateBlocks = privateSet; this._publicMutes = publicSet; this.blockedPubkeys = new Set([...privateSet, ...publicSet]); 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); }; // FIX: Always attempt to resolve the signer if one isn't available, // regardless of allowPermissionPrompt. The previous guard meant that // background refreshes (allowPermissionPrompt=false) could never decrypt // because they skipped signer resolution entirely. ensureActiveSignerForPubkey // does not itself prompt the user — it only waits for an already-injected // extension or returns the existing signer. if ( (!signer || !signerHasRequiredDecryptors(signer)) && typeof nostrClient?.ensureActiveSignerForPubkey === "function" ) { signer = await nostrClient.ensureActiveSignerForPubkey(normalized); } // FIX: Check method existence only, not capabilities. NIP-07 adapters // always expose decrypt methods that check extension state at call time. // The capabilities getter dynamically queries window.nostr which may not // have injected nip04/nip44 modules yet (lazy injection), causing false // negatives that block decryption even when methods will work at call time. const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; const signerAvailable = Boolean(signer); const signerHasDecryptors = signerHasNip04 || signerHasNip44; const signerStatus = signerAvailable ? signerHasDecryptors ? "present" : "present-missing-decryptor" : "missing"; let permissionError = null; if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { if (!allowPermissionPrompt) { const error = new Error( "Encryption permissions are required to decrypt the block list.", ); error.code = "user-blocklist-permission-required"; error.permissionRequestDeferred = true; permissionError = error; } else { try { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS, ); if (!permissionResult?.ok) { const error = permissionResult?.error instanceof Error ? permissionResult.error : new Error( "Extension encryption permissions are required to use the browser decryptor.", ); error.code = "user-blocklist-permission-required"; permissionError = error; } } catch (error) { const wrapped = error instanceof Error ? error : new Error(String(error)); wrapped.code = "user-blocklist-permission-required"; permissionError = wrapped; } } } 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"); }; // Pass per-call timeouts to signer decrypt methods so they fail fast // during login (3s) instead of using the 60s default. This prevents a // single stalled extension call from blocking the entire decrypt pipeline. // Use HIGH priority for user blocks as they are critical for feed safety. const signerDecryptOptions = { priority: NIP07_PRIORITY.HIGH, timeoutMs: nip07DecryptTimeoutMs, retryMultiplier: 1, }; if (signerHasNip44) { registerDecryptor( "nip44", (payload) => signer.nip44Decrypt(normalized, payload, signerDecryptOptions), "active-signer", ); registerDecryptor( "nip44_v2", (payload) => signer.nip44Decrypt(normalized, payload, signerDecryptOptions), "active-signer", ); } if (signerHasNip04) { registerDecryptor( "nip04", (payload) => signer.nip04Decrypt(normalized, payload, signerDecryptOptions), "active-signer", ); } const nostrApi = typeof window !== "undefined" ? window?.nostr : null; const decrypterOptions = { priority: NIP07_PRIORITY.HIGH, timeoutMs: nip07DecryptTimeoutMs, retryMultiplier: 1, }; if (nostrApi) { if (typeof nostrApi.nip04?.decrypt === "function") { registerDecryptor( "nip04", (payload) => runNip07WithRetry( () => nostrApi.nip04.decrypt(normalized, payload), { label: "nip04.decrypt", ...decrypterOptions }, ), "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", ...decrypterOptions, }), "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", ...decrypterOptions, }), "extension", ); if (!decryptors.has("nip44")) { registerDecryptor( "nip44", (payload) => runNip07WithRetry( () => nip44v2.decrypt(normalized, payload), { label: "nip44.v2.decrypt", ...decrypterOptions }, ), "extension", ); } } } } if (permissionError) { permissionError.signerStatus = signerStatus; } // Use the shared cross-service cache so all list services benefit from // the first successful decrypt during login. const cachedScheme = getLastSuccessfulScheme(normalized); return { decryptors, sources, permissionError, order: determineDecryptionOrder( event, Array.from(decryptors.keys()), cachedScheme, ), signerStatus, }; }; try { if (!effectiveRelays.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), privateBlocks: new Set(this._privateBlocks), publicMutes: new Set(this._publicMutes), 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; applySets(new Set(), new Set(), { 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; applySets(new Set(), new Set(), { source, reason: "empty-result" }); } emitStatus({ status: "applied-empty", source }); return; } // Separate by logic (Block vs Mute) // Legacy Block: Kind 10000 with d=user-blocks OR Kind 30002 const legacyBlockEvents = validEvents .filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002); // Standard Mute: Kind 10000 WITHOUT d=user-blocks const standardMuteEvents = validEvents .filter((e) => e.kind === KIND_MUTE_LIST && !isTaggedBlockListEvent(e)); const newestLegacy = selectNewestListEvent(legacyBlockEvents, { preferredKinds: [KIND_MUTE_LIST, 30002], }); const newestStandard = selectNewestListEvent(standardMuteEvents, { preferredKinds: [KIND_MUTE_LIST], }); const newestStandardTime = Number.isFinite(newestStandard?.created_at) ? newestStandard.created_at : 0; const newestLegacyTime = Number.isFinite(newestLegacy?.created_at) ? newestLegacy.created_at : 0; // The "canonical" newest event for stale checks is simply the latest of either. const newestOverall = compareListEventsDesc(newestStandard, newestLegacy) <= 0 ? newestStandard : newestLegacy; const newestCreatedAt = Math.max(newestStandardTime, newestLegacyTime); // We accept this state. this.blockEventId = newestOverall?.id || null; this.blockEventCreatedAt = newestCreatedAt; const looksLikeJsonPayload = (value) => { if (typeof value !== "string") { return false; } const trimmed = value.trim(); if (!trimmed) { return false; } const first = trimmed[0]; return first === "[" || first === "{"; }; // Helper to decrypt a single event const decryptEvent = async (ev, meta = {}) => { if (!ev) return []; const trimmedContent = typeof ev.content === "string" ? ev.content.trim() : ""; const isContentEmpty = !trimmedContent; if (isContentEmpty) { return []; } const hasEncryptionHints = extractEncryptionHints(ev).length > 0; const isLegacyBlockFormat = isTaggedBlockListEvent(ev) || ev.kind === 30002; const optionalDecrypt = !hasEncryptionHints && !isLegacyBlockFormat; if ( !hasEncryptionHints && !isLegacyBlockFormat && looksLikeJsonPayload(trimmedContent) ) { return parseBlockListPlaintext(trimmedContent, normalized, { limit: MAX_BLOCKLIST_ENTRIES, }); } const { decryptors, order, sources, permissionError, signerStatus } = await resolveDecryptors(ev); meta.signerStatus = signerStatus; meta.decryptorSources = Array.from(sources.entries()).map( ([scheme, source]) => ({ scheme, source }), ); if (!decryptors.size) { if (optionalDecrypt) { // NIP-51 allows descriptive plaintext in content for standard // mute lists. If decryptors are unavailable and encryption was // not explicitly indicated, keep public tags and continue. return []; } if (permissionError) { throw permissionError; } const error = new Error("No decryptor available for block list event."); error.code = "user-blocklist-no-decryptor"; error.signerStatus = signerStatus; throw error; } // PERF: Fast path — if the shared cache already knows the correct // scheme (e.g. another list decrypted first), try ONLY that scheme // before falling back to the full parallel probe. This avoids // queuing redundant NIP-07 extension calls. let decryptedText = ""; const sharedCachedScheme = getLastSuccessfulScheme(normalized); if (sharedCachedScheme && decryptors.has(sharedCachedScheme)) { try { const fastResult = await decryptors.get(sharedCachedScheme)(ev.content); if (typeof fastResult === "string" && fastResult) { decryptedText = fastResult; setLastSuccessfulScheme(normalized, sharedCachedScheme); } } catch { // Cached scheme failed — fall through to parallel probe. } } // PERF: Try all decryption schemes in parallel via Promise.any(). // The first scheme to succeed wins, avoiding sequential 3-15s timeouts // per scheme that previously made worst-case decryption take 45s+. // Deduplicate schemes in the same family (nip44/nip44_v2 both call // the same underlying signer.nip44Decrypt) to avoid redundant calls. if (!decryptedText) { try { const getSchemeFamily = (s) => s === "nip44" || s === "nip44_v2" ? "nip44" : s; const seenFamilies = new Set(); const attempts = order .map((scheme) => { const family = getSchemeFamily(scheme); if (seenFamilies.has(family)) return null; seenFamilies.add(family); const handler = decryptors.get(scheme); if (typeof handler !== "function") return null; return handler(ev.content).then((plaintext) => { if (typeof plaintext !== "string") { throw new Error("non-string-result"); } return { plaintext, scheme }; }); }) .filter(Boolean); if (attempts.length) { const result = await Promise.any(attempts); decryptedText = result.plaintext; setLastSuccessfulScheme(normalized, result.scheme); } } catch { // All schemes failed — decryptedText stays empty } } if (!decryptedText) { return []; } return parseBlockListPlaintext(decryptedText, normalized, { limit: MAX_BLOCKLIST_ENTRIES, }); }; const nextPrivateBlocks = new Set(); const nextPublicMutes = new Set(); let decryptionError = null; const decryptMeta = { standard: { eventId: newestStandard?.id || null }, legacy: { eventId: newestLegacy?.id || null }, }; const resolveSignerStatus = () => { const statuses = [decryptMeta.standard.signerStatus, decryptMeta.legacy.signerStatus] .filter(Boolean); if (!statuses.length) { return "unknown"; } if (statuses.every((status) => status === "missing")) { return "missing"; } if (statuses.some((status) => status === "present-missing-decryptor")) { return "present-missing-decryptor"; } return "present"; }; try { const decryptPromise = Promise.all([ decryptEvent(newestStandard, decryptMeta.standard), decryptEvent(newestLegacy, decryptMeta.legacy), ]); const timeoutPromise = new Promise((_, reject) => setTimeout( () => { const timeoutError = new Error( `Decryption timed out after ${decryptTimeoutMs / 1000}s`, ); timeoutError.code = "user-blocklist-decrypt-timeout"; timeoutError.signerStatus = resolveSignerStatus(); reject(timeoutError); }, decryptTimeoutMs, ), ); const [standardDecrypted, legacyDecrypted] = await Promise.race([ decryptPromise, timeoutPromise, ]); // Merge the newest standard mute snapshot with legacy/private lists so // users retain full cross-client compatibility during migration. if (newestStandard) { const publicTags = extractPubkeysFromTags(newestStandard.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES }); publicTags.forEach((pk) => nextPublicMutes.add(pk)); standardDecrypted.forEach((pk) => nextPrivateBlocks.add(pk)); } if (newestLegacy) { legacyDecrypted.forEach((pk) => nextPrivateBlocks.add(pk)); } } catch (error) { // If permission denied or critical failure decryptionError = error; } if (decryptionError) { if (decryptionError.code === "user-blocklist-permission-required") { const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); this.blockedPubkeys = previousState.blockedPubkeys; this._privateBlocks = previousState.privateBlocks; this._publicMutes = previousState.publicMutes; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; emitStatus({ status: "permission-required", signerStatus, source, reason: "decryptor-permission", allowPermissionPrompt, mode: loadMode, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), error: decryptionError, }); emitStatus({ status: "settled" }); return; } if (decryptionError.code === "user-blocklist-decrypt-timeout") { const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); const recoveredPrivateBlocks = new Set(previousState.privateBlocks); const recoveredPublicMutes = new Set([ ...previousState.publicMutes, ...extractPubkeysFromTags(newestStandard?.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES, }), ]); this.blockEventId = newestOverall?.id || previousState.blockEventId; this.blockEventCreatedAt = Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt; applySets(recoveredPrivateBlocks, recoveredPublicMutes, { source, reason: "decrypt-timeout-partial", events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); this.loaded = true; emitStatus({ status: "stale", reason: "decrypt-timeout", signerStatus, source, blockedPubkeys: Array.from(this.blockedPubkeys), }); emitStatus({ status: "settled" }); userLogger.warn( "[UserBlockList] Decryption timed out; keeping stale list and retrying in background.", { signerStatus, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }, ); this.scheduleDecryptRetry(normalized, { error: decryptionError, signerStatus, }); return; } userLogger.warn( "[UserBlockList] Decryption failed during merge.", decryptionError, ); this.blockedPubkeys = previousState.blockedPubkeys; this._privateBlocks = previousState.privateBlocks; this._publicMutes = previousState.publicMutes; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); emitStatus({ status: "error", error: decryptionError, decryptor: "mixed", signerStatus, }); emitStatus({ status: "settled" }); return; } const isStandardEffectiveEmpty = !newestStandard || ((!newestStandard.content || !newestStandard.content.trim()) && (!newestStandard.tags || !extractPubkeysFromTags(newestStandard.tags, normalized).length)); const isLegacyEffectiveEmpty = !newestLegacy || ((!newestLegacy.content || !newestLegacy.content.trim()) && (!newestLegacy.tags || !extractPubkeysFromTags(newestLegacy.tags, normalized).length)); const effectiveReason = (isStandardEffectiveEmpty && isLegacyEffectiveEmpty) ? "empty-events" : "applied-merge"; applySets(nextPrivateBlocks, nextPublicMutes, { source, reason: effectiveReason, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); this.decryptRetryAttempt = 0; if (effectiveReason === "empty-events") { emitStatus({ status: "applied-empty", event: newestOverall, source }); } else { emitStatus({ status: "applied", event: newestOverall, blockedPubkeys: Array.from(this.blockedPubkeys), source, }); } }; // Anchor the fetch to the service's current state to prevent desync. // If we have cached data, we ask for updates since that timestamp. // If we have no data, we force a full fetch (since=0). const fetchSince = hasLocalData && Number.isFinite(this.blockEventCreatedAt) ? computeIncrementalSinceWithOverlap(this.blockEventCreatedAt, 1) : 0; // Concurrent incremental fetch for all variants // We prioritize standard NIP-51 lists (Kind 10000) to ensure the UI loads quickly. // Legacy variants are fetched in parallel but processed separately to avoid blocking. const fastRelayLimit = Math.max( 1, Math.min(FAST_BLOCKLIST_RELAY_LIMIT, relays.length || FAST_BLOCKLIST_RELAY_LIMIT), ); const backgroundRelayLimit = Math.max( fastRelayLimit, Math.min( MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT, relays.length || MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT, ), ); const standardFetchPromise = nostrClient.fetchListIncrementally({ kind: KIND_MUTE_LIST, pubkey: normalized, relayUrls: effectiveRelays, since: fetchSince, timeoutMs: FAST_BLOCKLIST_TIMEOUT_MS, maxRelays: fastRelayLimit, }); const legacyFetchPromise = Promise.all([ nostrClient.fetchListIncrementally({ kind: KIND_MUTE_LIST, pubkey: normalized, relayUrls: effectiveRelays, since: fetchSince, timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS, maxRelays: backgroundRelayLimit, }), nostrClient.fetchListIncrementally({ kind: KIND_MUTE_LIST, pubkey: normalized, dTag: BLOCK_LIST_IDENTIFIER, relayUrls: effectiveRelays, since: fetchSince, timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS, maxRelays: backgroundRelayLimit, }), nostrClient.fetchListIncrementally({ kind: 30002, pubkey: normalized, dTag: BLOCK_LIST_IDENTIFIER, relayUrls: effectiveRelays, since: fetchSince, timeoutMs: BACKGROUND_BLOCKLIST_TIMEOUT_MS, maxRelays: backgroundRelayLimit, }), ]); // FAST PATH: Process Standard List immediately let standardEvents = []; try { standardEvents = await standardFetchPromise; if (Array.isArray(standardEvents) && standardEvents.length > 0) { await applyEvents(standardEvents, { source: "fast-standard" }); this.loaded = true; } } catch (standardError) { devLogger.warn("[UserBlockList] Standard fetch failed:", standardError); } // SLOW PATH: Process Legacy Lists (and Standard if it was empty/failed) // We do NOT await this for the main return, allowing the UI to unblock immediately // if the standard list was found. legacyFetchPromise .then(async ([backgroundStandardEvents, blockEvents, legacyEvents]) => { const combined = [ ...standardEvents, ...backgroundStandardEvents, ...blockEvents, ...legacyEvents, ]; if (!combined.length) { // Only apply empty if we haven't already marked loaded (meaning standard failed or was empty) // or if we want to ensure we settle state. // If standard was found, we don't need to do anything for empty legacy. if (!this.loaded) { await applyEvents([], { source: "empty-result" }); } } else { // Re-apply merged results to catch any legacy blocks await applyEvents(combined, { source: "incremental-merge" }); } }) .catch((legacyError) => { devLogger.warn("[UserBlockList] Legacy fetch failed:", legacyError); }); } 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 { blockedPubkeys: new Set(cached), createdAt: null }; } if (cached && typeof cached === "object") { const privateBlocks = new Set( Array.isArray(cached.privateBlocks) ? cached.privateBlocks : [], ); const publicMutes = new Set( Array.isArray(cached.publicMutes) ? cached.publicMutes : [], ); // Fallback for legacy cache format const blockedPubkeys = new Set( Array.isArray(cached.blockedPubkeys) ? cached.blockedPubkeys : [], ); return { privateBlocks, publicMutes, blockedPubkeys, createdAt: Number.isFinite(cached.createdAt) ? cached.createdAt : null, }; } return null; } _saveLocal(actorHex) { const normalizedActorHex = normalizeHex(actorHex); if (!normalizedActorHex) { return; } profileCache.setProfileData(normalizedActorHex, "blocks", { privateBlocks: Array.from(this._privateBlocks), publicMutes: Array.from(this._publicMutes), // Keep blockedPubkeys for backward compatibility if needed, or just relying on new keys blockedPubkeys: Array.from(this.blockedPubkeys), createdAt: this.blockEventCreatedAt, version: 2, }); } _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 snapshotPrivate = new Set(this._privateBlocks); const snapshotPublic = new Set(this._publicMutes); const snapshotUnion = new Set(this.blockedPubkeys); additions.forEach((hex) => { this._privateBlocks.add(hex); this.blockedPubkeys.add(hex); }); try { await this.publishBlockList(actorHex); } catch (error) { this._privateBlocks = snapshotPrivate; this._publicMutes = snapshotPublic; this.blockedPubkeys = snapshotUnion; 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._privateBlocks.add(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._privateBlocks.add(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) { return this.modifyList("block", targetPubkey, userPubkey); } async removeBlock(targetPubkey, userPubkey) { return this.modifyList("unblock", targetPubkey, userPubkey); } async addMute(targetPubkey, userPubkey) { return this.modifyList("mute", targetPubkey, userPubkey); } async removeMute(targetPubkey, userPubkey) { return this.modifyList("unmute", targetPubkey, userPubkey); } async modifyList(action, 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/mute yourself."); err.code = "self"; throw err; } await this.ensureLoaded(actorHex); const snapshotPrivate = new Set(this._privateBlocks); const snapshotPublic = new Set(this._publicMutes); const snapshotUnion = new Set(this.blockedPubkeys); let changed = false; if (action === "block") { if (this._privateBlocks.has(targetHex)) { return { ok: true, already: true }; } this._privateBlocks.add(targetHex); changed = true; } else if (action === "unblock") { const inPrivate = this._privateBlocks.delete(targetHex); const inPublic = this._publicMutes.delete(targetHex); changed = inPrivate || inPublic; } else if (action === "mute") { if (this._publicMutes.has(targetHex)) { return { ok: true, already: true }; } this._publicMutes.add(targetHex); changed = true; } else if (action === "unmute") { const inPublic = this._publicMutes.delete(targetHex); changed = inPublic; } if (!changed) { return { ok: true, already: true }; } this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]); 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, targetPubkey: targetHex, actorPubkey: actorHex, }); if (action === "block" || action === "mute") { this._clearSeedRemoval(actorHex, targetHex); } else { this._addSeedRemoval(actorHex, targetHex); } return { ok: true }; } try { await this.publishBlockList(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action, targetPubkey: targetHex, actorPubkey: actorHex, }); if (action === "block" || action === "mute") { this._clearSeedRemoval(actorHex, targetHex); } else { this._addSeedRemoval(actorHex, targetHex); } return { ok: true }; } catch (err) { this._privateBlocks = snapshotPrivate; this._publicMutes = snapshotPublic; this.blockedPubkeys = snapshotUnion; throw err; } } 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" || signer.type === "nip07") { const permissionResult = await requestDefaultExtensionPermissions( DEFAULT_NIP07_ENCRYPTION_METHODS, ); 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 before updating the block list.", ); err.code = "extension-encryption-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."); } // Encrypt private blocks const privateBlockArray = Array.from(this._privateBlocks); const sanitizedPrivateTags = sanitizeMuteTags(privateBlockArray, normalized); const plaintext = serializeBlockListTagMatrix(sanitizedPrivateTags, 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)); } let cipherText = ""; let encryptionScheme = ""; const seenSchemes = new Set(); const encryptionErrors = []; // Only attempt encryption if we have private blocks if (privateBlockArray.length > 0) { if (!encryptors.length) { const err = new Error( "An encryption-capable signer is required to update private blocks." ); err.code = "block-list-missing-encryptor"; throw err; } 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; // Build event with both public mutes (p tags) and private blocks (encrypted content) const publicMuteArray = Array.from(this._publicMutes); const sanitizedPublicTags = sanitizeMuteTags(publicMuteArray, normalized); const pTags = sanitizedPublicTags.map((tag) => tag[1]); const event = buildMuteListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), content: cipherText, pTags, encrypted: !!encryptionTagValue, encryptionTag: encryptionTagValue, }); const signedEvent = await signer.signEvent(event); const publishResults = await publishEventToRelays( nostrClient.pool, nostrClient.relays, signedEvent ); let publishSummary; try { publishSummary = assertAnyRelayAccepted(publishResults, { context: "mute list", }); } catch (publishError) { if (publishError?.relayFailures?.length) { publishError.relayFailures.forEach( ({ url, error: relayError, reason }) => { userLogger.error( `[UserBlockList] Mute 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] Mute 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.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; }