mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactored `js/userBlocks.js` to handle NIP-07 decryption timeouts gracefully. Previously, if a background decryption timed out (common with busy extensions or queues), the user's block list was reset to an empty state, exposing them to blocked content until the next successful retry. The fix ensures that if a timeout occurs, the existing (stale) block list is preserved while a retry is scheduled in the background. Also increased the `BACKGROUND_DECRYPT_TIMEOUT_MS` from 2500ms to 5000ms to reduce false positives. Added `tests/user-blocks-decryption.test.mjs` to verify the fix and audit the decryption flow. Co-authored-by: PR0M3TH3AN <[email protected]>
2224 lines
65 KiB
JavaScript
2224 lines
65 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 = 5000;
|
|
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 caps = signer?.capabilities || {};
|
|
const signerHasNip04 =
|
|
caps.nip04 !== false && typeof signer?.nip04Decrypt === "function";
|
|
const signerHasNip44 =
|
|
caps.nip44 !== false && 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.LOW,
|
|
}),
|
|
"active-signer",
|
|
);
|
|
registerDecryptor(
|
|
"nip44_v2",
|
|
(payload) =>
|
|
signer.nip44Decrypt(normalized, payload, {
|
|
priority: NIP07_PRIORITY.LOW,
|
|
}),
|
|
"active-signer",
|
|
);
|
|
}
|
|
|
|
if (signerHasNip04) {
|
|
registerDecryptor(
|
|
"nip04",
|
|
(payload) =>
|
|
signer.nip04Decrypt(normalized, payload, {
|
|
priority: NIP07_PRIORITY.LOW,
|
|
}),
|
|
"active-signer",
|
|
);
|
|
}
|
|
|
|
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
|
|
const decrypterOptions = {
|
|
priority: NIP07_PRIORITY.LOW,
|
|
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, restore the previous state to avoid wiping the user's blocks.
|
|
// We then schedule a background retry.
|
|
this.blockedPubkeys = previousState.blockedPubkeys;
|
|
this._privateBlocks = previousState.privateBlocks;
|
|
this._publicMutes = previousState.publicMutes;
|
|
this.blockEventId = previousState.blockEventId;
|
|
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
|
this.loaded = true; // Mark as loaded so UI renders, even if stale
|
|
|
|
emitStatus({
|
|
status: "stale",
|
|
reason: "decrypt-timeout",
|
|
signerStatus,
|
|
source,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
});
|
|
emitStatus({ status: "settled" });
|
|
|
|
userLogger.warn(
|
|
"[UserBlockList] Decryption timed out; keeping stale list and retrying in background.",
|
|
{
|
|
signerStatus,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
},
|
|
);
|
|
this.scheduleDecryptRetry(normalized, {
|
|
error: decryptionError,
|
|
signerStatus,
|
|
});
|
|
return;
|
|
}
|
|
userLogger.warn(
|
|
"[UserBlockList] Decryption failed during merge.",
|
|
decryptionError,
|
|
);
|
|
this.blockedPubkeys = previousState.blockedPubkeys;
|
|
this._privateBlocks = previousState.privateBlocks;
|
|
this._publicMutes = previousState.publicMutes;
|
|
this.blockEventId = previousState.blockEventId;
|
|
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
|
|
this.loaded = true;
|
|
const signerStatus =
|
|
decryptionError.signerStatus || resolveSignerStatus();
|
|
emitStatus({
|
|
status: "error",
|
|
error: decryptionError,
|
|
decryptor: "mixed",
|
|
signerStatus,
|
|
});
|
|
emitStatus({ status: "settled" });
|
|
return;
|
|
}
|
|
|
|
const isStandardEffectiveEmpty = !newestStandard || ((!newestStandard.content || !newestStandard.content.trim()) && (!newestStandard.tags || !extractPubkeysFromTags(newestStandard.tags, normalized).length));
|
|
const isLegacyEffectiveEmpty = !newestLegacy || ((!newestLegacy.content || !newestLegacy.content.trim()) && (!newestLegacy.tags || !extractPubkeysFromTags(newestLegacy.tags, normalized).length));
|
|
|
|
const effectiveReason = (isStandardEffectiveEmpty && isLegacyEffectiveEmpty)
|
|
? "empty-events"
|
|
: "applied-merge";
|
|
|
|
applySets(nextPrivateBlocks, nextPublicMutes, {
|
|
source,
|
|
reason: effectiveReason,
|
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
|
});
|
|
|
|
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
|
|
// We prioritize standard NIP-51 lists (Kind 10000) to ensure the UI loads quickly.
|
|
// Legacy variants are fetched in parallel but processed separately to avoid blocking.
|
|
|
|
const standardFetchPromise = nostrClient.fetchListIncrementally({
|
|
kind: 10000,
|
|
pubkey: normalized,
|
|
relayUrls: relays,
|
|
since: fetchSince,
|
|
timeoutMs: 6000,
|
|
});
|
|
|
|
const legacyFetchPromise = Promise.all([
|
|
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,
|
|
}),
|
|
]);
|
|
|
|
// FAST PATH: Process Standard List immediately
|
|
let standardEvents = [];
|
|
try {
|
|
standardEvents = await standardFetchPromise;
|
|
if (Array.isArray(standardEvents) && standardEvents.length > 0) {
|
|
await applyEvents(standardEvents, { source: "fast-standard" });
|
|
this.loaded = true;
|
|
}
|
|
} catch (standardError) {
|
|
devLogger.warn("[UserBlockList] Standard fetch failed:", standardError);
|
|
}
|
|
|
|
// SLOW PATH: Process Legacy Lists (and Standard if it was empty/failed)
|
|
// We do NOT await this for the main return, allowing the UI to unblock immediately
|
|
// if the standard list was found.
|
|
legacyFetchPromise
|
|
.then(async ([blockEvents, legacyEvents]) => {
|
|
const combined = [...standardEvents, ...blockEvents, ...legacyEvents];
|
|
|
|
if (!combined.length) {
|
|
// Only apply empty if we haven't already marked loaded (meaning standard failed or was empty)
|
|
// or if we want to ensure we settle state.
|
|
// If standard was found, we don't need to do anything for empty legacy.
|
|
if (!this.loaded) {
|
|
await applyEvents([], { source: "empty-result" });
|
|
}
|
|
} else {
|
|
// Re-apply merged results to catch any legacy blocks
|
|
await applyEvents(combined, { source: "incremental-merge" });
|
|
}
|
|
})
|
|
.catch((legacyError) => {
|
|
devLogger.warn("[UserBlockList] Legacy fetch failed:", legacyError);
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
userLogger.error("[UserBlockList] loadBlocks failed (keeping local state):", error);
|
|
emitStatus({ status: "error", error });
|
|
} finally {
|
|
this.loaded = true;
|
|
emitStatus({ status: "settled" });
|
|
}
|
|
}
|
|
|
|
_loadLocal(actorHex) {
|
|
const normalizedActorHex = normalizeHex(actorHex);
|
|
if (!normalizedActorHex) {
|
|
return null;
|
|
}
|
|
const cached = profileCache.getProfileData(normalizedActorHex, "blocks");
|
|
if (Array.isArray(cached)) {
|
|
return { blockedPubkeys: new Set(cached), createdAt: null };
|
|
}
|
|
if (cached && typeof cached === "object") {
|
|
const privateBlocks = new Set(
|
|
Array.isArray(cached.privateBlocks) ? cached.privateBlocks : [],
|
|
);
|
|
const publicMutes = new Set(
|
|
Array.isArray(cached.publicMutes) ? cached.publicMutes : [],
|
|
);
|
|
// Fallback for legacy cache format
|
|
const blockedPubkeys = new Set(
|
|
Array.isArray(cached.blockedPubkeys) ? cached.blockedPubkeys : [],
|
|
);
|
|
|
|
return {
|
|
privateBlocks,
|
|
publicMutes,
|
|
blockedPubkeys,
|
|
createdAt: Number.isFinite(cached.createdAt) ? cached.createdAt : null,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_saveLocal(actorHex) {
|
|
const normalizedActorHex = normalizeHex(actorHex);
|
|
if (!normalizedActorHex) {
|
|
return;
|
|
}
|
|
profileCache.setProfileData(normalizedActorHex, "blocks", {
|
|
privateBlocks: Array.from(this._privateBlocks),
|
|
publicMutes: Array.from(this._publicMutes),
|
|
// Keep blockedPubkeys for backward compatibility if needed, or just relying on new keys
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
createdAt: this.blockEventCreatedAt,
|
|
version: 2,
|
|
});
|
|
}
|
|
|
|
_getSeedState(actorHex) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) {
|
|
return { seeded: false, removals: new Set() };
|
|
}
|
|
|
|
if (this.seedStateCache.has(normalized)) {
|
|
return this.seedStateCache.get(normalized);
|
|
}
|
|
|
|
const stored =
|
|
profileCache.getProfileData(normalized, "blocks_meta") || {};
|
|
const seeded = !!stored.seeded;
|
|
const removals = new Set(Array.isArray(stored.removals) ? stored.removals : []);
|
|
|
|
const state = { seeded, removals };
|
|
this.seedStateCache.set(normalized, state);
|
|
return state;
|
|
}
|
|
|
|
_persistSeedState(actorHex) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) return;
|
|
const state = this.seedStateCache.get(normalized);
|
|
if (!state) return;
|
|
|
|
profileCache.setProfileData(normalized, "blocks_meta", {
|
|
seeded: state.seeded,
|
|
removals: Array.from(state.removals),
|
|
});
|
|
}
|
|
|
|
_setSeeded(actorHex, seeded) {
|
|
const normalized = normalizeHex(actorHex);
|
|
if (!normalized) return;
|
|
|
|
const state = this._getSeedState(normalized);
|
|
state.seeded = Boolean(seeded);
|
|
this._persistSeedState(normalized);
|
|
}
|
|
|
|
_addSeedRemoval(actorHex, targetHex) {
|
|
const normalizedActor = normalizeHex(actorHex);
|
|
const normalizedTarget = normalizeHex(targetHex);
|
|
if (!normalizedActor || !normalizedTarget) return;
|
|
|
|
const state = this._getSeedState(normalizedActor);
|
|
if (!state.removals.has(normalizedTarget)) {
|
|
state.removals.add(normalizedTarget);
|
|
this._persistSeedState(normalizedActor);
|
|
}
|
|
}
|
|
|
|
_clearSeedRemoval(actorHex, targetHex) {
|
|
const normalizedActor = normalizeHex(actorHex);
|
|
const normalizedTarget = normalizeHex(targetHex);
|
|
if (!normalizedActor || !normalizedTarget) return;
|
|
|
|
const state = this._getSeedState(normalizedActor);
|
|
if (state.removals.delete(normalizedTarget)) {
|
|
this._persistSeedState(normalizedActor);
|
|
}
|
|
}
|
|
|
|
async seedWithNpubs(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
if (state.seeded) {
|
|
return { ok: true, seeded: false, reason: "already-seeded" };
|
|
}
|
|
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (this.blockedPubkeys.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Seed candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
blockedCount: this.blockedPubkeys.size,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
return { ok: true, seeded: false, reason: "no-candidates" };
|
|
}
|
|
|
|
const snapshotPrivate = new Set(this._privateBlocks);
|
|
const snapshotPublic = new Set(this._publicMutes);
|
|
const snapshotUnion = new Set(this.blockedPubkeys);
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
try {
|
|
await this.publishBlockList(actorHex);
|
|
} catch (error) {
|
|
this._privateBlocks = snapshotPrivate;
|
|
this._publicMutes = snapshotPublic;
|
|
this.blockedPubkeys = snapshotUnion;
|
|
throw error;
|
|
}
|
|
|
|
this._setSeeded(actorHex, true);
|
|
additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex));
|
|
this._saveLocal(actorHex);
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async seedLocalBaseline(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
if (state.seeded) {
|
|
return { ok: true, seeded: false, reason: "already-seeded" };
|
|
}
|
|
|
|
if (this.blockedPubkeys.size > 0) {
|
|
return { ok: true, seeded: false, reason: "non-empty" };
|
|
}
|
|
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Local seed candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
this._setSeeded(actorHex, true);
|
|
return { ok: true, seeded: true, addedPubkeys: [] };
|
|
}
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
this._setSeeded(actorHex, true);
|
|
additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex));
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
localOnly: true,
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit local seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async seedBaselineDelta(userPubkey, candidateNpubs = []) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
return { ok: false, seeded: false, reason: "invalid-user" };
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const state = this._getSeedState(actorHex);
|
|
const removals = state.removals;
|
|
const additions = new Set();
|
|
let invalidCount = 0;
|
|
|
|
const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : [];
|
|
for (const candidate of candidates) {
|
|
const candidateHex = normalizeHex(candidate);
|
|
if (!candidateHex) {
|
|
invalidCount += 1;
|
|
continue;
|
|
}
|
|
if (candidateHex === actorHex) {
|
|
continue;
|
|
}
|
|
if (this.blockedPubkeys.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
if (removals.has(candidateHex)) {
|
|
continue;
|
|
}
|
|
additions.add(candidateHex);
|
|
}
|
|
|
|
if (!additions.size) {
|
|
if (candidates.length) {
|
|
devLogger.info(
|
|
"[UserBlockList] Baseline delta candidates resolved to zero additions.",
|
|
{
|
|
actorPubkey: actorHex,
|
|
candidateCount: candidates.length,
|
|
invalidCount,
|
|
blockedCount: this.blockedPubkeys.size,
|
|
removalCount: removals.size,
|
|
},
|
|
);
|
|
}
|
|
if (!state.seeded) {
|
|
this._setSeeded(actorHex, true);
|
|
}
|
|
return { ok: true, seeded: state.seeded, reason: "no-candidates" };
|
|
}
|
|
|
|
additions.forEach((hex) => {
|
|
this._privateBlocks.add(hex);
|
|
this.blockedPubkeys.add(hex);
|
|
});
|
|
|
|
this._setSeeded(actorHex, true);
|
|
this._saveLocal(actorHex);
|
|
|
|
try {
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action: "seed-delta",
|
|
actorPubkey: actorHex,
|
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
|
addedPubkeys: Array.from(additions),
|
|
localOnly: true,
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn("[UserBlockList] Failed to emit delta seed change event:", error);
|
|
}
|
|
|
|
return { ok: true, seeded: true, addedPubkeys: Array.from(additions) };
|
|
}
|
|
|
|
async addBlock(targetPubkey, userPubkey) {
|
|
return this.modifyList("block", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async removeBlock(targetPubkey, userPubkey) {
|
|
return this.modifyList("unblock", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async addMute(targetPubkey, userPubkey) {
|
|
return this.modifyList("mute", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async removeMute(targetPubkey, userPubkey) {
|
|
return this.modifyList("unmute", targetPubkey, userPubkey);
|
|
}
|
|
|
|
async modifyList(action, targetPubkey, userPubkey) {
|
|
const actorHex = normalizeHex(userPubkey);
|
|
if (!actorHex) {
|
|
throw new Error("Invalid user pubkey.");
|
|
}
|
|
|
|
const targetHex = normalizeHex(targetPubkey);
|
|
if (!targetHex) {
|
|
const err = new Error("Invalid target pubkey.");
|
|
err.code = "invalid";
|
|
throw err;
|
|
}
|
|
|
|
if (actorHex === targetHex) {
|
|
const err = new Error("Cannot block/mute yourself.");
|
|
err.code = "self";
|
|
throw err;
|
|
}
|
|
|
|
await this.ensureLoaded(actorHex);
|
|
|
|
const snapshotPrivate = new Set(this._privateBlocks);
|
|
const snapshotPublic = new Set(this._publicMutes);
|
|
const snapshotUnion = new Set(this.blockedPubkeys);
|
|
|
|
let changed = false;
|
|
|
|
if (action === "block") {
|
|
if (this._privateBlocks.has(targetHex)) {
|
|
return { ok: true, already: true };
|
|
}
|
|
this._privateBlocks.add(targetHex);
|
|
changed = true;
|
|
} else if (action === "unblock") {
|
|
const inPrivate = this._privateBlocks.delete(targetHex);
|
|
const inPublic = this._publicMutes.delete(targetHex);
|
|
changed = inPrivate || inPublic;
|
|
} else if (action === "mute") {
|
|
if (this._publicMutes.has(targetHex)) {
|
|
return { ok: true, already: true };
|
|
}
|
|
this._publicMutes.add(targetHex);
|
|
changed = true;
|
|
} else if (action === "unmute") {
|
|
const inPublic = this._publicMutes.delete(targetHex);
|
|
changed = inPublic;
|
|
}
|
|
|
|
if (!changed) {
|
|
return { ok: true, already: true };
|
|
}
|
|
|
|
this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]);
|
|
|
|
const loggedInPubkey = normalizeHex(nostrClient?.pubkey);
|
|
const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey);
|
|
const isSessionActor =
|
|
!!actorHex && !loggedInPubkey && actorHex === sessionPubkey;
|
|
|
|
if (isSessionActor) {
|
|
this._saveLocal(actorHex);
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action,
|
|
targetPubkey: targetHex,
|
|
actorPubkey: actorHex,
|
|
});
|
|
if (action === "block" || action === "mute") {
|
|
this._clearSeedRemoval(actorHex, targetHex);
|
|
} else {
|
|
this._addSeedRemoval(actorHex, targetHex);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
try {
|
|
await this.publishBlockList(actorHex);
|
|
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
|
|
action,
|
|
targetPubkey: targetHex,
|
|
actorPubkey: actorHex,
|
|
});
|
|
if (action === "block" || action === "mute") {
|
|
this._clearSeedRemoval(actorHex, targetHex);
|
|
} else {
|
|
this._addSeedRemoval(actorHex, targetHex);
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
this._privateBlocks = snapshotPrivate;
|
|
this._publicMutes = snapshotPublic;
|
|
this.blockedPubkeys = snapshotUnion;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async publishBlockList(userPubkey, options = {}) {
|
|
if (isSessionActor(nostrClient)) {
|
|
const error = new Error(
|
|
"Publishing block lists is not allowed for session actors."
|
|
);
|
|
error.code = "session-actor-publish-blocked";
|
|
throw error;
|
|
}
|
|
|
|
const onStatus =
|
|
options && typeof options.onStatus === "function" ? options.onStatus : null;
|
|
|
|
onStatus?.({ status: "publishing" });
|
|
|
|
let signer = getActiveSigner();
|
|
if (!signer) {
|
|
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
|
|
}
|
|
|
|
if (!signer) {
|
|
const err = new Error(
|
|
"An active signer is required to update the block list."
|
|
);
|
|
err.code = "signer-missing";
|
|
throw err;
|
|
}
|
|
|
|
if (signer.type === "extension") {
|
|
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;
|
|
}
|