Files
bitvid/js/userBlocks.js
T
google-labs-jules[bot]andPR0M3TH3AN de5e453b34 Refactor user blocking to prioritize standard NIP-51 Mute Lists
- Updates `js/userBlocks.js` to write block entries to a standard Kind 10000 Mute List event.
- Implements a hybrid storage model: unencrypted public mutes are stored in `p` tags, while private blocks are stored in the encrypted event content.
- Maintains read compatibility with legacy `d=user-blocks` lists (both Kind 10000 and Kind 30002) by merging and prioritizing the newest event timestamp.
- Updates local caching to persist both public mutes and private blocks, preventing data loss or "zombie" entries on reload.
- Updates `js/subscriptions.js` to correctly detect NIP-44 capabilities on `Nip07Adapter` signers, fixing a regression where NIP-04 fallback was skipped.
- Updates `js/nostrEventSchemas.js` to mark `USER_BLOCK_LIST` as legacy and define `MUTE_LIST`.
- Updates unit tests to verify the new multi-kind fetching and merged publishing logic.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-01 22:10:42 +00:00

2193 lines
63 KiB
JavaScript

// js/userBlocks.js
import {
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { getActiveSigner } from "./nostr/index.js";
import { isSessionActor } from "./nostr/sessionActor.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import {
buildBlockListEvent,
buildMuteListEvent,
BLOCK_LIST_IDENTIFIER,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import {
DEFAULT_NIP07_ENCRYPTION_METHODS,
runNip07WithRetry,
NIP07_PRIORITY,
} from "./nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
class TinyEventEmitter {
constructor() {
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof eventName !== "string" || typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
userLogger.warn(
`[UserBlockList] listener for "${eventName}" threw an error`,
error,
);
}
}
}
}
export const USER_BLOCK_EVENTS = Object.freeze({
CHANGE: "change",
STATUS: "status",
});
const FAST_BLOCKLIST_RELAY_LIMIT = 3;
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const DECRYPT_TIMEOUT_MS = 15000;
const BACKGROUND_DECRYPT_TIMEOUT_MS = 2500;
const DECRYPT_RETRY_DELAY_MS = 10000;
const MAX_BLOCKLIST_ENTRIES = 5000;
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
}
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function extractEncryptionHints(event) {
if (!event || typeof event !== "object") {
return [];
}
const tags = Array.isArray(event.tags) ? event.tags : [];
const hints = [];
const pushUnique = (scheme) => {
if (!scheme || hints.includes(scheme)) {
return;
}
hints.push(scheme);
};
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const name = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (name !== "encrypted" && name !== "encryption") {
continue;
}
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
if (!rawValue) {
continue;
}
const parts = rawValue
.split(/[\s,]+/)
.map((part) => normalizeEncryptionToken(part))
.filter(Boolean);
for (const part of parts) {
if (part === "nip44v2" || part === "nip44v02") {
pushUnique("nip44_v2");
continue;
}
if (part === "nip44") {
pushUnique("nip44");
continue;
}
if (part === "nip04" || part === "nip4") {
pushUnique("nip04");
}
}
}
return hints;
}
function determineDecryptionOrder(event, availableSchemes) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
const hints = extractEncryptionHints(event);
const aliasMap = {
nip04: ["nip04"],
nip44: ["nip44", "nip44_v2"],
nip44_v2: ["nip44_v2", "nip44"],
};
for (const hint of hints) {
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
for (const candidate of candidates) {
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
prioritized.push(candidate);
break;
}
}
}
for (const fallback of ["nip44_v2", "nip44", "nip04"]) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
function describeDiscardedEntry(entry) {
if (typeof entry === "string") {
return entry.trim();
}
if (entry === null) {
return "null";
}
if (typeof entry === "undefined") {
return "undefined";
}
if (Array.isArray(entry)) {
try {
return JSON.stringify(entry);
} catch (_error) {
return String(entry);
}
}
if (typeof entry === "object") {
try {
return JSON.stringify(entry);
} catch (_error) {
return String(entry);
}
}
return String(entry);
}
function sanitizeMuteTags(values, ownerPubkey, options = {}) {
const logDiscarded = options?.logDiscarded !== false;
const owner = normalizeHex(ownerPubkey);
const sanitized = [];
const seen = new Set();
const iterable = Array.isArray(values)
? values
: values instanceof Set
? Array.from(values)
: values && typeof values[Symbol.iterator] === "function"
? Array.from(values)
: [];
const logDiscard = (entry, reason, extra) => {
if (!logDiscarded) {
return;
}
const detail = describeDiscardedEntry(entry);
if (extra) {
userLogger.warn(
`[UserBlockList] Discarded block entry (${reason}).`,
detail,
extra,
);
return;
}
userLogger.warn(`[UserBlockList] Discarded block entry (${reason}).`, detail);
};
for (const entry of iterable) {
let targetHex = null;
if (Array.isArray(entry)) {
if (entry.length < 2) {
logDiscard(entry, "invalid-tuple");
continue;
}
const label =
typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
if (label !== "p") {
logDiscard(entry, "unsupported-tag");
continue;
}
targetHex = normalizeHex(entry[1]);
if (!targetHex) {
logDiscard(entry, "invalid-target");
continue;
}
} else if (typeof entry === "string") {
targetHex = normalizeHex(entry);
if (!targetHex) {
logDiscard(entry, "invalid-string");
continue;
}
} else if (entry && typeof entry === "object") {
if (typeof entry.pubkey === "string") {
targetHex = normalizeHex(entry.pubkey);
if (!targetHex) {
logDiscard(entry, "invalid-object-pubkey");
continue;
}
} else {
logDiscard(entry, "unsupported-entry");
continue;
}
} else {
logDiscard(entry, "unsupported-entry");
continue;
}
if (owner && targetHex === owner) {
logDiscard(entry, "self-target");
continue;
}
if (seen.has(targetHex)) {
logDiscard(entry, "duplicate", targetHex);
continue;
}
seen.add(targetHex);
sanitized.push(["p", targetHex]);
}
return sanitized;
}
function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) {
const tags = sanitizeMuteTags(values, ownerPubkey, options);
return JSON.stringify(tags);
}
function extractPubkeysFromTags(tags, ownerPubkey = null, 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 === 10000) {
return true;
}
if (event.kind === 30002) {
const tags = Array.isArray(event.tags) ? event.tags : [];
return tags.some(
(tag) =>
Array.isArray(tag) &&
typeof tag[0] === "string" &&
tag[0].trim().toLowerCase() === "d" &&
typeof tag[1] === "string" &&
tag[1].trim() === BLOCK_LIST_IDENTIFIER,
);
}
return false;
}
function isTaggedBlockListEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return false;
}
return event.tags.some(
(tag) =>
Array.isArray(tag) &&
tag.length >= 2 &&
typeof tag[0] === "string" &&
tag[0].trim().toLowerCase() === "d" &&
typeof tag[1] === "string" &&
tag[1].trim() === BLOCK_LIST_IDENTIFIER,
);
}
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_CHARSET_MAP = (() => {
const map = new Map();
for (let index = 0; index < BECH32_CHARSET.length; index += 1) {
map.set(BECH32_CHARSET[index], index);
}
return map;
})();
function bytesToHex(bytes) {
if (!bytes || typeof bytes.length !== "number") {
return "";
}
let hex = "";
for (let index = 0; index < bytes.length; index += 1) {
const value = bytes[index];
if (typeof value !== "number") {
return "";
}
const normalized = value & 0xff;
hex += normalized.toString(16).padStart(2, "0");
}
return hex;
}
function bech32Polymod(values) {
let chk = 1;
const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
for (const value of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let bit = 0; bit < generators.length; bit += 1) {
if ((top >>> bit) & 1) {
chk ^= generators[bit];
}
}
}
return chk;
}
function bech32HrpExpand(hrp) {
const expanded = [];
for (let index = 0; index < hrp.length; index += 1) {
const code = hrp.charCodeAt(index);
expanded.push(code >>> 5);
}
expanded.push(0);
for (let index = 0; index < hrp.length; index += 1) {
const code = hrp.charCodeAt(index);
expanded.push(code & 31);
}
return expanded;
}
function bech32VerifyChecksum(hrp, data) {
return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1;
}
function bech32Decode(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const lower = trimmed.toLowerCase();
const upper = trimmed.toUpperCase();
if (trimmed !== lower && trimmed !== upper) {
return null;
}
const normalized = lower;
const separatorIndex = normalized.lastIndexOf("1");
if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) {
return null;
}
const hrp = normalized.slice(0, separatorIndex);
const data = [];
for (let index = separatorIndex + 1; index < normalized.length; index += 1) {
const char = normalized[index];
if (!BECH32_CHARSET_MAP.has(char)) {
return null;
}
data.push(BECH32_CHARSET_MAP.get(char));
}
if (!bech32VerifyChecksum(hrp, data)) {
return null;
}
return { hrp, words: data.slice(0, -6) };
}
function convertBits(data, fromBits, toBits, pad = true) {
const result = [];
let acc = 0;
let bits = 0;
const maxv = (1 << toBits) - 1;
const maxAcc = (1 << (fromBits + toBits - 1)) - 1;
for (const value of data) {
if (value < 0 || value >> fromBits) {
return null;
}
acc = ((acc << fromBits) | value) & maxAcc;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.push((acc >>> bits) & maxv);
}
}
if (pad) {
if (bits) {
result.push((acc << (toBits - bits)) & maxv);
}
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
return null;
}
return result;
}
function fallbackDecodeNpubToHex(value) {
const decoded = bech32Decode(value);
if (!decoded || decoded.hrp !== "npub") {
return "";
}
const bytes = convertBits(decoded.words, 5, 8, false);
if (!bytes || !bytes.length) {
return "";
}
return bytesToHex(bytes);
}
function normalizeDecodedHex(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
return null;
}
return trimmed.toLowerCase();
}
function normalizeNpubInput(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const lower = trimmed.toLowerCase();
if (lower.startsWith("nostr:")) {
return trimmed.slice(6).trim();
}
return trimmed;
}
function decodeNpubToHex(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = normalizeNpubInput(npub);
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = typeof globalThis !== "undefined" ? globalThis?.NostrTools : null;
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (!decoded || decoded.type !== "npub") {
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
}
if (typeof decoded.data === "string") {
const normalized = normalizeDecodedHex(decoded.data);
return normalized || normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
}
return normalizeDecodedHex(bytesToHex(decoded.data));
} catch (error) {
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
}
}
return normalizeDecodedHex(fallbackDecodeNpubToHex(trimmed));
}
function normalizeHex(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
const decoded = decodeNpubToHex(trimmed);
if (decoded) {
return decoded;
}
return null;
}
class UserBlockListManager {
constructor() {
this.blockedPubkeys = new Set();
this._privateBlocks = new Set();
this._publicMutes = new Set();
this.blockEventId = null;
this.blockEventCreatedAt = null;
this.lastPublishedCreatedAt = null;
this.muteEventId = null;
this.muteEventCreatedAt = null;
this.loaded = false;
this.emitter = new TinyEventEmitter();
this.seedStateCache = new Map();
this.decryptRetryTimeoutId = null;
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.loadPromise = null;
this.loadingPubkey = null;
}
stopBlockListSubscription() {
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: [10000], authors: [normalized] },
{ kinds: [30002], authors: [normalized], "#d": [BLOCK_LIST_IDENTIFIER] },
];
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 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);
});
}, DECRYPT_RETRY_DELAY_MS);
devLogger.log(
`[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${DECRYPT_RETRY_DELAY_MS / 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 {
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;
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 3000;
const emitStatus = (detail) => {
if (!detail || typeof detail !== "object") {
return;
}
try {
statusCallback?.(detail);
} catch (callbackError) {
userLogger.warn(
"[UserBlockList] statusCallback threw while emitting status",
callbackError,
);
}
try {
this.emitter.emit(USER_BLOCK_EVENTS.STATUS, detail);
} catch (emitterError) {
userLogger.warn(
"[UserBlockList] Failed to dispatch status event",
emitterError,
);
}
};
const readRelays = relayManager.getReadRelayUrls();
const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
emitStatus({ status: "loading", relays });
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, relays);
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);
};
if (
allowPermissionPrompt &&
(!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"))
) {
signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
}
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");
};
if (signerHasNip44) {
registerDecryptor(
"nip44",
(payload) =>
signer.nip44Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) =>
signer.nip44Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
if (signerHasNip04) {
registerDecryptor(
"nip04",
(payload) =>
signer.nip04Decrypt(normalized, payload, {
priority: NIP07_PRIORITY.HIGH,
}),
"active-signer",
);
}
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
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;
}
return {
decryptors,
sources,
permissionError,
order: determineDecryptionOrder(event, Array.from(decryptors.keys())),
signerStatus,
};
};
try {
if (!relays.length) {
// Only clear if truly no relays, though loadBlocks with no relays is weird.
if (!this.loaded) {
this.blockedPubkeys.clear();
this.blockEventId = null;
this.blockEventCreatedAt = null;
this.loaded = true;
}
emitStatus({ status: "applied-empty" });
emitStatus({ status: "settled" });
return;
}
const applyEvents = async (
events,
{ skipIfEmpty = false, source = "fast" } = {},
) => {
const previousState = {
blockedPubkeys: new Set(this.blockedPubkeys),
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)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
// Standard Mute: Kind 10000 WITHOUT d=user-blocks
const standardMuteEvents = validEvents
.filter((e) => e.kind === 10000 && !isTaggedBlockListEvent(e))
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const newestLegacy = legacyBlockEvents[0] || null;
const newestStandard = standardMuteEvents[0] || null;
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 =
newestStandardTime >= newestLegacyTime ? newestStandard : newestLegacy;
const newestCreatedAt = Math.max(newestStandardTime, newestLegacyTime);
// We accept this state.
this.blockEventId = newestOverall?.id || null;
this.blockEventCreatedAt = newestCreatedAt;
// Helper to decrypt a single event
const decryptEvent = async (ev, meta = {}) => {
if (!ev) return [];
const isContentEmpty = !ev.content || !ev.content.trim();
if (isContentEmpty) {
return [];
}
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 (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;
}
let decryptedText = "";
for (const scheme of order) {
const handler = decryptors.get(scheme);
if (typeof handler !== "function") continue;
try {
const plaintext = await handler(ev.content);
if (typeof plaintext === "string") {
decryptedText = plaintext;
break;
}
} catch {
// ignore
}
}
if (!decryptedText) {
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,
]);
// Only apply the newest list to avoid zombie data from stale lists
if (newestOverall === newestStandard && newestStandard) {
const publicTags = extractPubkeysFromTags(newestStandard.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES });
publicTags.forEach((pk) => nextPublicMutes.add(pk));
standardDecrypted.forEach((pk) => nextPrivateBlocks.add(pk));
} else 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();
// If decrypt times out, we can still load public mutes from standard event tags?
// For safety, we probably shouldn't partially load if we expect encryption.
// But preserving existing state is safer or applying empty.
// Current logic applies empty.
applySets(new Set(), new Set(), {
source,
reason: "decrypt-timeout",
signerStatus,
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
});
this.loaded = true;
emitStatus({
status: "applied-empty",
reason: "decrypt-timeout",
signerStatus,
source,
});
emitStatus({ status: "settled" });
userLogger.warn(
"[UserBlockList] Decryption timed out; applying empty 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),
});
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)
? this.blockEventCreatedAt
: 0;
// Concurrent incremental fetch for all variants
const [muteEvents, blockEvents, legacyEvents] = await Promise.all([
nostrClient.fetchListIncrementally({
kind: 10000,
pubkey: normalized,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
nostrClient.fetchListIncrementally({
kind: 10000,
pubkey: normalized,
dTag: BLOCK_LIST_IDENTIFIER,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
nostrClient.fetchListIncrementally({
kind: 30002,
pubkey: normalized,
dTag: BLOCK_LIST_IDENTIFIER,
relayUrls: relays,
since: fetchSince,
timeoutMs: 12000,
}),
]);
const combined = [...muteEvents, ...blockEvents, ...legacyEvents];
// If combined is empty, check if we have loaded state
if (!combined.length) {
if (!this.loaded) {
// Assuming empty if we have no prior state
await applyEvents([], { source: "incremental" });
}
} else {
await applyEvents(combined, { source: "incremental" });
}
} catch (error) {
userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error);
emitStatus({ status: "error", error });
} finally {
this.loaded = true;
emitStatus({ status: "settled" });
}
}
_loadLocal(actorHex) {
const normalizedActorHex = normalizeHex(actorHex);
if (!normalizedActorHex) {
return null;
}
const cached = profileCache.getProfileData(normalizedActorHex, "blocks");
if (Array.isArray(cached)) {
return { 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") {
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;
}