From 3dbacd20ccf32a07e26ce0e7ec5e1ec9d179ce7b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:09:08 -0500 Subject: [PATCH] fix(sync): emit settled list state for hashtags and blocks with regression tests --- js/services/hashtagPreferencesService.js | 12 ++ js/userBlocks.js | 14 +- tests/hashtag-preferences.test.mjs | 177 +++++++++++++++++++++++ tests/user-blocks.test.mjs | 170 ++++++++++++++++++++++ 4 files changed, 366 insertions(+), 7 deletions(-) diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 4348525c..94176763 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -526,6 +526,13 @@ class HashtagPreferencesService { eventId: this.eventId, createdAt: this.eventCreatedAt, version: this.preferencesVersion, + loaded: this.loaded === true, + uiReady: this.uiReady === true, + dataReady: this.dataReady === true, + loadedFromCache: this.loadedFromCache === true, + backgroundLoading: this.backgroundLoading === true, + lastLoadError: this.lastLoadError || null, + activePubkey: this.activePubkey || null, ...detail, }); } catch (error) { @@ -589,6 +596,11 @@ class HashtagPreferencesService { state: this.getLoadState(), }; } finally { + this.emitChange("load-settled", { + ...this.getLoadState(), + loaded: this.loaded === true, + backgroundLoading: this.backgroundLoading === true, + }); if (this.loadPromise === loadPromise) { this.loadPromise = null; this.loadingPubkey = null; diff --git a/js/userBlocks.js b/js/userBlocks.js index c37f859c..8a787f4d 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -705,6 +705,7 @@ class UserBlockListManager { this.blockedPubkeys = new Set(); this._privateBlocks = new Set(); this._publicMutes = new Set(); + this.activePubkey = null; this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; @@ -718,7 +719,6 @@ class UserBlockListManager { this.loadPromise = null; this.loadingPubkey = null; this.blockListSubscriptionKey = null; - profileCache.subscribe((event, detail) => { if (event === "profileChanged") { this.reset(); @@ -793,7 +793,6 @@ class UserBlockListManager { if (!isUserBlockListEvent(event)) { return; } - this.loadBlocks(normalized).catch((error) => { devLogger.warn("[UserBlockList] Failed to refresh after block list event", error); }); @@ -864,7 +863,6 @@ class UserBlockListManager { if (!normalized) { return; } - await this.loadBlocks(normalized); } @@ -877,7 +875,6 @@ class UserBlockListManager { }); 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, @@ -889,10 +886,15 @@ class UserBlockListManager { const loadPromise = this.loadBlocksInternal(normalized, options); this.loadPromise = loadPromise; this.loadingPubkey = normalized; - try { return await loadPromise; } finally { + this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { + action: "load-settled", + blockedPubkeys: Array.from(this.blockedPubkeys), + loaded: this.loaded === true, + activePubkey: this.activePubkey || null, + }); if (this.loadPromise === loadPromise) { this.loadPromise = null; this.loadingPubkey = null; @@ -931,7 +933,6 @@ class UserBlockListManager { // realistic window while still failing faster than the full 15s interactive // timeout. const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000; - const emitStatus = (detail) => { if (!detail || typeof detail !== "object") { return; @@ -997,7 +998,6 @@ class UserBlockListManager { 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), diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index bf56e8b8..2d4604c7 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -323,6 +323,183 @@ test( }, ); +test( + "load emits load-settled with readiness metadata for empty relay results", + { concurrency: false }, + async () => { + const pubkey = "1".repeat(64); + const changes = []; + const unsubscribe = hashtagPreferences.on("change", (detail) => { + changes.push(detail); + }); + + const originalFetchIncremental = nostrClient.fetchListIncrementally; + try { + relayManager.setEntries( + [{ url: "wss://relay.empty", mode: "both" }], + { allowEmpty: false, updateClient: false }, + ); + nostrClient.fetchListIncrementally = async () => []; + + await hashtagPreferences.load(pubkey); + + const settled = changes.filter((entry) => entry?.action === "load-settled"); + assert.ok(settled.length >= 1, "load should emit a final load-settled event"); + + const final = settled[settled.length - 1]; + assert.equal(final.activePubkey, pubkey); + assert.equal(final.uiReady, true); + assert.equal(final.dataReady, true); + assert.equal(final.loaded, true); + assert.equal(final.loadedFromCache, false); + assert.equal(final.lastLoadError, null); + assert.deepEqual(final.interests, []); + assert.deepEqual(final.disinterests, []); + } finally { + unsubscribe(); + nostrClient.fetchListIncrementally = originalFetchIncremental; + } + }, +); + +test( + "load emits load-settled with permission-required metadata when decrypt is deferred", + { concurrency: false }, + async () => { + const pubkey = "2".repeat(64); + const changes = []; + const unsubscribe = hashtagPreferences.on("change", (detail) => { + changes.push(detail); + }); + + const originalFetchIncremental = nostrClient.fetchListIncrementally; + const originalWindowNostr = window.nostr; + const relayUrls = ["wss://relay.permission-metadata"]; + + try { + relayManager.setEntries( + relayUrls.map((url) => ({ url, mode: "both" })), + { allowEmpty: false, updateClient: false }, + ); + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + nostrClient.fetchListIncrementally = async () => [ + { + id: "pref-permission-metadata", + created_at: 401, + pubkey, + content: "cipher-permission-metadata", + tags: [["encrypted", "nip04"]], + }, + ]; + + window.nostr = { + enable: async () => {}, + getPublicKey: async () => pubkey, + nip04: { + decrypt: async () => { + throw new Error("permission denied"); + }, + }, + }; + clearActiveSigner(); + + await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false }); + + const settled = changes.filter((entry) => entry?.action === "load-settled"); + assert.ok(settled.length >= 1, "load should emit load-settled even on deferred decrypt"); + + const final = settled[settled.length - 1]; + assert.equal(final.activePubkey, pubkey); + assert.equal(final.uiReady, true); + assert.equal(typeof final.dataReady, "boolean"); + assert.equal(final.dataReady, hashtagPreferences.dataReady === true); + assert.equal(typeof final.loaded, "boolean"); + assert.equal(final.loaded, hashtagPreferences.loaded === true); + assert.equal(final.lastLoadError?.code, "hashtag-preferences-permission-required"); + } finally { + unsubscribe(); + nostrClient.fetchListIncrementally = originalFetchIncremental; + window.nostr = originalWindowNostr; + } + }, +); + +test( + "load emits load-settled with loadedFromCache when relay fetch is unavailable", + { concurrency: false }, + async () => { + const pubkey = "3".repeat(64); + const originalPool = nostrClient.pool; + const originalLoadFromCache = hashtagPreferences.loadFromCache; + const originalInterests = hashtagPreferences.interests; + const originalDisinterests = hashtagPreferences.disinterests; + const originalEventId = hashtagPreferences.eventId; + const originalEventCreatedAt = hashtagPreferences.eventCreatedAt; + const originalLoaded = hashtagPreferences.loaded; + const originalUiReady = hashtagPreferences.uiReady; + const originalDataReady = hashtagPreferences.dataReady; + const originalLoadedFromCache = hashtagPreferences.loadedFromCache; + const originalLastSuccessfulSyncAt = hashtagPreferences.lastSuccessfulSyncAt; + const originalPreferencesVersion = hashtagPreferences.preferencesVersion; + + try { + hashtagPreferences.loadFromCache = (candidate) => { + if (candidate !== pubkey) { + return false; + } + hashtagPreferences.activePubkey = candidate; + hashtagPreferences.interests = new Set(["cachepref"]); + hashtagPreferences.disinterests = new Set(); + hashtagPreferences.eventId = "cache-event"; + hashtagPreferences.eventCreatedAt = 777; + hashtagPreferences.loaded = true; + hashtagPreferences.uiReady = true; + hashtagPreferences.dataReady = true; + hashtagPreferences.loadedFromCache = true; + hashtagPreferences.lastSuccessfulSyncAt = 777000; + hashtagPreferences.preferencesVersion = 1; + hashtagPreferences.emitChange("cache-load"); + return true; + }; + + const changes = []; + const unsubscribe = hashtagPreferences.on("change", (detail) => { + changes.push(detail); + }); + try { + nostrClient.pool = null; + await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false }); + + const settled = changes.filter((entry) => entry?.action === "load-settled"); + assert.ok(settled.length >= 1, "cache-backed load should emit load-settled"); + const final = settled[settled.length - 1]; + + assert.equal(final.activePubkey, pubkey); + assert.equal(final.loadedFromCache, true); + assert.equal(final.dataReady, true); + assert.equal(final.uiReady, true); + assert.deepEqual(final.interests, ["cachepref"]); + } finally { + unsubscribe(); + } + } finally { + nostrClient.pool = originalPool; + hashtagPreferences.loadFromCache = originalLoadFromCache; + hashtagPreferences.interests = originalInterests; + hashtagPreferences.disinterests = originalDisinterests; + hashtagPreferences.eventId = originalEventId; + hashtagPreferences.eventCreatedAt = originalEventCreatedAt; + hashtagPreferences.loaded = originalLoaded; + hashtagPreferences.uiReady = originalUiReady; + hashtagPreferences.dataReady = originalDataReady; + hashtagPreferences.loadedFromCache = originalLoadedFromCache; + hashtagPreferences.lastSuccessfulSyncAt = originalLastSuccessfulSyncAt; + hashtagPreferences.preferencesVersion = originalPreferencesVersion; + } + }, +); + test( "interest and disinterest lists remain exclusive", { concurrency: false }, diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 86c4b7d9..57555a48 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -166,6 +166,176 @@ await (async () => { } })(); +await (async () => { + const actor = "7".repeat(64); + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelayEntries = relayManager.getEntries(); + const originalFetchIncremental = nostrClient.fetchListIncrementally; + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + + const relayUrls = ["wss://relay-blocks-empty.example"]; + relayManager.setEntries( + relayUrls.map((url) => ({ url, mode: "both" })), + { allowEmpty: false, updateClient: false }, + ); + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + nostrClient.pool = originalPool || { list: async () => [] }; + nostrClient.fetchListIncrementally = async () => []; + + const changeEvents = []; + const statusEvents = []; + const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => { + changeEvents.push(detail); + }); + const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => { + statusEvents.push(detail); + }); + + try { + await manager.loadBlocks(actor); + + const settledChange = changeEvents.filter( + (detail) => detail?.action === "load-settled", + ); + assert.ok( + settledChange.length >= 1, + "loadBlocks should emit a final load-settled change event", + ); + const finalChange = settledChange[settledChange.length - 1]; + assert.equal(finalChange.activePubkey, actor); + assert.equal(finalChange.loaded, true); + assert.deepEqual(finalChange.blockedPubkeys, []); + + const settledStatus = statusEvents.filter( + (detail) => detail?.status === "settled", + ); + assert.ok( + settledStatus.length >= 1, + "loadBlocks should emit settled status metadata", + ); + const finalStatus = settledStatus[settledStatus.length - 1]; + assert.equal(finalStatus.status, "settled"); + } finally { + unsubscribeChange?.(); + unsubscribeStatus?.(); + relayManager.setEntries( + originalRelayEntries, + { allowEmpty: false, updateClient: false }, + ); + nostrClient.fetchListIncrementally = originalFetchIncremental; + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + manager.reset(); + } +})(); + +await (async () => { + const actor = "8".repeat(64); + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelayEntries = relayManager.getEntries(); + const originalFetchIncremental = nostrClient.fetchListIncrementally; + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalNostr = window.nostr; + const originalEnsureSignerForPubkey = nostrClient.ensureActiveSignerForPubkey; + const originalSigner = getActiveSigner(); + + const relayUrls = ["wss://relay-blocks-permission.example"]; + relayManager.setEntries( + relayUrls.map((url) => ({ url, mode: "both" })), + { allowEmpty: false, updateClient: false }, + ); + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + nostrClient.pool = originalPool || { list: async () => [] }; + nostrClient.fetchListIncrementally = async () => [ + { + id: "event-blocks-permission", + kind: 10000, + created_at: 505, + pubkey: actor, + content: "cipher-blocks-permission", + tags: [["encrypted", "nip04"]], + }, + ]; + nostrClient.ensureActiveSignerForPubkey = async () => null; + clearActiveSigner(); + window.nostr = undefined; + + const changeEvents = []; + const statusEvents = []; + const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => { + changeEvents.push(detail); + }); + const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => { + statusEvents.push(detail); + }); + + try { + await manager.loadBlocks(actor, { allowPermissionPrompt: false }); + + const settledChange = changeEvents.filter( + (detail) => detail?.action === "load-settled", + ); + assert.ok( + settledChange.length >= 1, + "permission-gated loads should still emit load-settled change", + ); + const finalChange = settledChange[settledChange.length - 1]; + assert.equal(finalChange.activePubkey, actor); + assert.equal(finalChange.loaded, true); + + const settledStatus = statusEvents.filter( + (detail) => detail?.status === "settled", + ); + assert.ok( + settledStatus.length >= 1, + "permission-gated loads should still emit settled status", + ); + assert.ok( + statusEvents.some( + (detail) => detail?.status === "permission-required", + ), + "permission-gated load should emit permission-required status before settling", + ); + } finally { + unsubscribeChange?.(); + unsubscribeStatus?.(); + relayManager.setEntries( + originalRelayEntries, + { allowEmpty: false, updateClient: false }, + ); + nostrClient.fetchListIncrementally = originalFetchIncremental; + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.ensureActiveSignerForPubkey = originalEnsureSignerForPubkey; + window.nostr = originalNostr; + clearActiveSigner(); + if (originalSigner) { + setActiveSigner(originalSigner); + } + manager.reset(); + } +})(); + await new Promise((resolve) => setTimeout(resolve, 100)); await (async () => {