Files
bitvid/js/userBlocks.js
T

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