diff --git a/js/userBlocks.js b/js/userBlocks.js index 7473e8ea..da2d3966 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -54,7 +54,10 @@ class TinyEventEmitter { } } -export const USER_BLOCK_EVENTS = Object.freeze({ CHANGE: "change" }); +export const USER_BLOCK_EVENTS = Object.freeze({ + CHANGE: "change", + STATUS: "status", +}); const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; @@ -138,7 +141,42 @@ class UserBlockListManager { const statusCallback = typeof options?.statusCallback === "function" ? options.statusCallback : null; - statusCallback?.({ status: "loading", relays: Array.from(nostrClient.relays || []) }); + 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, + ); + } + }; + + emitStatus({ status: "loading", relays: Array.from(nostrClient.relays || []) }); + + 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 permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult.ok) { @@ -152,8 +190,8 @@ class UserBlockListManager { permissionResult.error instanceof Error ? permissionResult.error : new Error("Extension permissions required to load block list."); - statusCallback?.({ status: "error", error }); - statusCallback?.({ status: "settled" }); + emitStatus({ status: "error", error }); + emitStatus({ status: "settled" }); return; } @@ -163,11 +201,11 @@ class UserBlockListManager { ); this.reset(); this.loaded = true; - statusCallback?.({ + emitStatus({ status: "error", error: new Error("nip04.decrypt is unavailable"), }); - statusCallback?.({ status: "settled" }); + emitStatus({ status: "settled" }); return; } @@ -192,8 +230,8 @@ class UserBlockListManager { this.blockEventId = null; this.blockEventCreatedAt = null; this.loaded = true; - statusCallback?.({ status: "applied-empty" }); - statusCallback?.({ status: "settled" }); + emitStatus({ status: "applied-empty" }); + emitStatus({ status: "settled" }); return; } @@ -203,6 +241,16 @@ class UserBlockListManager { const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => new Promise((resolve, reject) => { let settled = false; + const pool = nostrClient?.pool; + if (!pool || typeof pool.list !== "function") { + const poolError = new Error( + "nostrClient.pool.list is unavailable; cannot query block list.", + ); + poolError.code = "pool-unavailable"; + poolError.relay = relayUrl; + reject(poolError); + return; + } const timer = setTimeout(() => { if (settled) { return; @@ -218,7 +266,7 @@ class UserBlockListManager { }, timeoutMs); Promise.resolve() - .then(() => nostrClient.pool.list([relayUrl], [filter])) + .then(() => pool.list([relayUrl], [filter])) .then((result) => { if (settled) { return; @@ -259,19 +307,22 @@ class UserBlockListManager { fetchFromRelay(relayUrl, BACKGROUND_BLOCKLIST_TIMEOUT_MS, false) ); - const applyEvents = async (events, { skipIfEmpty = false } = {}) => { + const applyEvents = async ( + events, + { skipIfEmpty = false, source = "fast" } = {}, + ) => { if (!Array.isArray(events) || !events.length) { if (skipIfEmpty) { return; } if (this.lastPublishedCreatedAt !== null || this.blockEventCreatedAt !== null) { - statusCallback?.({ status: "stale", reason: "empty-result" }); + emitStatus({ status: "stale", reason: "empty-result", source }); return; } - this.blockedPubkeys.clear(); this.blockEventId = null; this.blockEventCreatedAt = null; - statusCallback?.({ status: "applied-empty" }); + applyBlockedPubkeys([], { source, reason: "empty-result" }); + emitStatus({ status: "applied-empty", source }); return; } @@ -284,13 +335,13 @@ class UserBlockListManager { return; } if (this.lastPublishedCreatedAt !== null || this.blockEventCreatedAt !== null) { - statusCallback?.({ status: "stale", reason: "empty-result" }); + emitStatus({ status: "stale", reason: "empty-result", source }); return; } - this.blockedPubkeys.clear(); this.blockEventId = null; this.blockEventCreatedAt = null; - statusCallback?.({ status: "applied-empty" }); + applyBlockedPubkeys([], { source, reason: "empty-result" }); + emitStatus({ status: "applied-empty", source }); return; } @@ -304,7 +355,7 @@ class UserBlockListManager { ); if (newestCreatedAt < guardCreatedAt) { - statusCallback?.({ status: "stale", event: newest, guardCreatedAt }); + emitStatus({ status: "stale", event: newest, guardCreatedAt, source }); return; } @@ -314,7 +365,7 @@ class UserBlockListManager { newest?.id && newest.id !== this.blockEventId ) { - statusCallback?.({ status: "stale", event: newest, guardCreatedAt }); + emitStatus({ status: "stale", event: newest, guardCreatedAt, source }); return; } @@ -322,7 +373,7 @@ class UserBlockListManager { this.blockEventCreatedAt = Number.isFinite(newestCreatedAt) ? newestCreatedAt : this.blockEventCreatedAt; - statusCallback?.({ status: "confirmed", event: newest }); + emitStatus({ status: "confirmed", event: newest, source }); return; } @@ -332,8 +383,8 @@ class UserBlockListManager { : null; if (!newest?.content) { - this.blockedPubkeys.clear(); - statusCallback?.({ status: "applied-empty", event: newest }); + applyBlockedPubkeys([], { source, reason: "empty-event", event: newest }); + emitStatus({ status: "applied-empty", event: newest, source }); return; } @@ -342,8 +393,13 @@ class UserBlockListManager { decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); } catch (err) { userLogger.error("[UserBlockList] Failed to decrypt block list:", err); - this.blockedPubkeys.clear(); - statusCallback?.({ status: "error", event: newest, error: err }); + applyBlockedPubkeys([], { + source, + reason: "decrypt-error", + event: newest, + error: err, + }); + emitStatus({ status: "error", event: newest, error: err, source }); return; } @@ -363,16 +419,26 @@ class UserBlockListManager { } return true; }); - this.blockedPubkeys = new Set(sanitized); - statusCallback?.({ + applyBlockedPubkeys(sanitized, { + source, + reason: "applied", + event: newest, + }); + emitStatus({ status: "applied", event: newest, blockedPubkeys: Array.from(this.blockedPubkeys), + source, }); } catch (err) { userLogger.error("[UserBlockList] Failed to parse block list:", err); - this.blockedPubkeys.clear(); - statusCallback?.({ status: "error", event: newest, error: err }); + applyBlockedPubkeys([], { + source, + reason: "parse-error", + event: newest, + error: err, + }); + emitStatus({ status: "error", event: newest, error: err, source }); } }; @@ -407,13 +473,15 @@ class UserBlockListManager { } if (!aggregated.length) { - return; + return { foundEvents: false }; } - await applyEvents(aggregated, { skipIfEmpty: true }); + await applyEvents(aggregated, { skipIfEmpty: true, source: "background" }); + return { foundEvents: true }; }) .catch((error) => { userLogger.error("[UserBlockList] background block list refresh failed:", error); + return { foundEvents: false, error }; }); let fastResult = null; @@ -436,25 +504,39 @@ class UserBlockListManager { } if (fastResult?.events?.length) { - await applyEvents(fastResult.events); + await applyEvents(fastResult.events, { source: "fast" }); background.catch(() => {}); return; } - this.blockedPubkeys.clear(); - this.blockEventId = null; - this.blockEventCreatedAt = null; - statusCallback?.({ status: "applied-empty" }); - background.catch(() => {}); + if (backgroundRelays.length || fastPromises.length) { + emitStatus({ + status: "awaiting-background", + relays: backgroundRelays.length ? backgroundRelays : fastRelays, + }); + } + + const backgroundOutcome = await background; + + if (backgroundOutcome?.foundEvents) { + return; + } + + if (backgroundOutcome?.error) { + emitStatus({ status: "error", error: backgroundOutcome.error, source: "background" }); + return; + } + + await applyEvents([], { source: "background" }); } catch (error) { userLogger.error("[UserBlockList] loadBlocks failed:", error); - this.blockedPubkeys.clear(); + applyBlockedPubkeys([], { source: "fast", reason: "load-error", error }); this.blockEventId = null; this.blockEventCreatedAt = null; - statusCallback?.({ status: "error", error }); + emitStatus({ status: "error", error }); } finally { this.loaded = true; - statusCallback?.({ status: "settled" }); + emitStatus({ status: "settled" }); } } diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 6c28daa9..ab379701 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -1,7 +1,7 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; -const { userBlocks } = await import("../js/userBlocks.js"); +const { userBlocks, USER_BLOCK_EVENTS } = await import("../js/userBlocks.js"); const { nostrClient } = await import("../js/nostr.js"); if (typeof globalThis.window === "undefined") { @@ -55,6 +55,12 @@ await (async () => { nostrClient.pool = pool; nostrClient.relays = relays; + assert.equal( + typeof nostrClient.pool?.list, + "function", + "nostrClient.pool.list should be stubbed for block list tests", + ); + userBlocks.blockedPubkeys = new Set(); userBlocks.blockEventId = null; userBlocks.loaded = false; @@ -62,7 +68,7 @@ await (async () => { try { const loadPromise = userBlocks.loadBlocks(actor); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual( calls, @@ -75,6 +81,7 @@ await (async () => { { id: "event-old", created_at: 1700, + pubkey: actor, content: "ciphertext-old", }, ], @@ -82,6 +89,7 @@ await (async () => { { id: "event-new", created_at: 1800, + pubkey: actor, content: "ciphertext-new", }, ], @@ -113,3 +121,151 @@ await (async () => { window.nostr = originalNostr; } })(); + +await (async () => { + const actor = "a".repeat(64); + const initialBlocked = "b".repeat(64); + const backgroundBlocked = "c".repeat(64); + const relays = [ + "wss://fast-one.example", + "wss://fast-two.example", + "wss://fast-three.example", + "wss://background.example", + ]; + + const originalNostr = window.nostr; + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalLoaded = userBlocks.loaded; + + const calls = []; + const resolvers = []; + + window.nostr = { + ...(originalNostr || {}), + nip04: { + ...((originalNostr && originalNostr.nip04) || {}), + decrypt: async () => + JSON.stringify({ + blockedPubkeys: [backgroundBlocked], + }), + }, + }; + + const pool = originalPool ? { ...originalPool } : {}; + pool.list = (targets) => { + const relay = Array.isArray(targets) ? targets[0] : undefined; + calls.push(relay); + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + resolvers.push({ relay, resolve }); + return promise; + }; + + nostrClient.pool = pool; + nostrClient.relays = relays; + + assert.equal( + typeof nostrClient.pool?.list, + "function", + "nostrClient.pool.list should be stubbed for background relay tests", + ); + + userBlocks.blockedPubkeys = new Set([initialBlocked]); + userBlocks.blockEventId = "existing-event"; + userBlocks.blockEventCreatedAt = 1_000; + userBlocks.loaded = false; + + const statusEvents = []; + const changeEvents = []; + const unsubscribeStatus = userBlocks.on(USER_BLOCK_EVENTS.STATUS, (detail) => { + statusEvents.push(detail); + }); + const unsubscribeChange = userBlocks.on(USER_BLOCK_EVENTS.CHANGE, (detail) => { + changeEvents.push(detail); + }); + + try { + const loadPromise = userBlocks.loadBlocks(actor); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + calls, + relays, + "loadBlocks should query fast and background relays concurrently", + ); + + for (const { relay, resolve } of resolvers) { + if (relay !== relays[3]) { + resolve([]); + } + } + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + userBlocks.getBlockedPubkeys(), + [initialBlocked], + "existing blocked list should be preserved while waiting for background relays", + ); + + const backgroundResolver = resolvers.find((entry) => entry.relay === relays[3]); + backgroundResolver.resolve([ + { + id: "background-event", + pubkey: actor, + created_at: 2_000, + content: "ciphertext-background", + }, + ]); + + await loadPromise; + + assert.equal( + userBlocks.blockEventId, + "background-event", + "background relay event should be applied after fast relay failures", + ); + assert.deepEqual( + userBlocks.getBlockedPubkeys(), + [backgroundBlocked], + "block list should hydrate from background relay payload", + ); + + assert( + statusEvents.some((detail) => detail?.status === "awaiting-background"), + "status events should indicate when background relays are pending", + ); + assert( + statusEvents.some((detail) => detail?.status === "applied"), + "status events should report when the background payload is applied", + ); + assert( + changeEvents.some( + (detail) => + detail?.action === "sync" && + Array.isArray(detail?.blockedPubkeys) && + detail.blockedPubkeys.includes(backgroundBlocked), + ), + "change events should emit sync updates after applying background payloads", + ); + } finally { + unsubscribeStatus?.(); + unsubscribeChange?.(); + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.loaded = originalLoaded; + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + window.nostr = originalNostr; + } +})();