Files
bitvid/js/userBlocks.js
T
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00

2444 lines
76 KiB
JavaScript

// 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 {
TinyEventEmitter,
isTransientDecryptError,
computeRetryDelay,
} from "./userBlocksHelpers.js";
import {
noteSignerDecryptTimeout,
noteSignerDecryptSuccess,
} from "./utils/signerHealthNotice.js";
import { STANDARD_TIMEOUT_MS, MAX_BLOCKLIST_ENTRIES } from "./constants.js";
import {
publishEventToRelays,
assertAnyRelayAcceptedOrUnconfirmed,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS, capReadRelays } 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";
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;
// Decrypt timeout budget. A nip-07 decrypt is a postMessage round-trip to the
// extension's (single-threaded, possibly sleeping) service worker — under
// cold-login load real extensions can take 15-25s to answer even though the
// crypto itself is instant. The harness (scripts/perf/nip07-channel-sim.mjs)
// shows lists fail to load the moment per-call latency crosses this budget, so
// keep it generous (30s) to give a slow-but-responsive signer room to complete
// rather than timing out and retrying forever (KNOWN_BUGS #0).
const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 30000);
const BACKGROUND_DECRYPT_TIMEOUT_MS = 12000;
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
// Cap the exponential backoff well above the decrypt budget so a persistently
// unresponsive signer settles into an occasional heartbeat instead of pinning
// the extension/CPU every ~15s indefinitely.
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;
}
// Bound the live block-list subscription to the §17 read cap (user relays
// first + reserved defaults, ≤8). Without this the subscription fanned out
// across the user's full NIP-65 set, a primary contributor to the cold-login
// REQ storm.
const cappedRelays = capReadRelays(relays);
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: cappedRelays,
filters,
label: "block-list",
onEvent: (event) => this.handleBlockListEvent(event),
});
return this.blockListSubscriptionHandle;
}
return relaySubscriptionService.ensureSubscription({
key,
pool: nostrClient.pool,
relays: cappedRelays,
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;
// Per-call nip-07 decrypt budget. A 6s value (tried earlier) killed
// in-progress decrypts on slow extensions that legitimately need 15-25s to
// answer under cold-login load, so the list never decrypted and retried
// forever. Give the call real room (interactive 25s, background 12s) — it
// sits below the service-level DECRYPT_TIMEOUT_MS backstop.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 25000 : 12000;
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;
// Route by ciphertext format: a NIP-04 payload carries the "?iv="
// marker and can ONLY be decrypted by nip04; everything else is
// nip44. Racing the non-matching family is wasted work — and under
// a NIP-46 remote signer each attempt is a separately PUBLISHED
// relay RPC, so racing both families floods the relay
// ("rate-limited: noting too much") and stalls decryption. Restrict
// the probe to the matching family (fall back to all if unknown).
const formatFamily =
typeof ev.content === "string" && ev.content.includes("?iv=")
? "nip04"
: "nip44";
const matchingOrder = order.filter(
(scheme) => getSchemeFamily(scheme) === formatFamily,
);
const probeOrder = matchingOrder.length ? matchingOrder : order;
const seenFamilies = new Set();
const attempts = probeOrder
.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 (aggregateError) {
// Promise.any rejects with an AggregateError once EVERY scheme
// failed. If any failure was a transient channel/extension error
// (a severed message port, breaker fast-fail, etc.) we must NOT
// swallow it and return [] — that silently misreads a brief
// channel drop as "the user has no blocks" and never retries
// (KNOWN_BUGS #0). Re-throw so applyEvents keeps the stale list
// and schedules a background retry; once the channel recovers the
// retry succeeds. Genuine decrypt mismatches (wrong key/content)
// still fall through to the empty result.
const subErrors =
aggregateError && Array.isArray(aggregateError.errors)
? aggregateError.errors
: [aggregateError];
const transient = subErrors.find((e) => isTransientDecryptError(e));
if (transient) {
if (!transient.signerStatus) {
transient.signerStatus = signerStatus;
}
throw transient;
}
// All schemes failed for non-transient reasons — decryptedText
// stays empty (treated as "no blocks").
}
}
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 (isTransientDecryptError(decryptionError)) {
const isTimeout =
decryptionError.code === "user-blocklist-decrypt-timeout";
const reason = isTimeout ? "decrypt-timeout" : "channel-unavailable";
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: `${reason}-partial`,
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
});
this.loaded = true;
emitStatus({
status: "stale",
reason,
signerStatus,
source,
blockedPubkeys: Array.from(this.blockedPubkeys),
});
emitStatus({ status: "settled" });
userLogger.warn(
isTimeout
? "[UserBlockList] Decryption timed out; keeping stale list and retrying in background."
: "[UserBlockList] Signer channel unavailable; keeping stale list and retrying in background.",
{
signerStatus,
reason,
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
},
);
// Signer reported present but didn't answer — feed the shared
// "signer unresponsive" notice so the user gets told to reload/unlock
// their extension instead of silently retrying forever.
if (signerStatus === "present") {
noteSignerDecryptTimeout("blocks");
}
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;
noteSignerDecryptSuccess();
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
);
// An all-timeout (unconfirmed) publish of this idempotent, replaceable mute
// list is treated as a soft success — the event was sent and almost always
// persists (it shows up after a refresh), so don't surface a spurious error /
// revert the optimistic state. Explicit rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "mute list",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
"[UserBlockList] Mute list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
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;
}