diff --git a/js/subscriptions.js b/js/subscriptions.js index e4d28053..4a44bc12 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -215,6 +215,50 @@ const listVideoViewEventsApi = (pointer, options) => const subscribeVideoViewEventsApi = (pointer, options) => subscribeVideoViewEvents(nostrClient, pointer, options); +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( + `[SubscriptionsManager] listener for "${eventName}" threw an error`, + error, + ); + } + } + } +} + /** * Manages the user's subscription list (kind=30000 follow set) *privately*, * using encrypted NIP-51 tag arrays (NIP-04/NIP-44) for the content field. @@ -237,6 +281,7 @@ class SubscriptionsManager { this.scheduledRefreshDetail = null; this.isRunningFeed = false; this.hasRenderedOnce = false; + this.emitter = new TinyEventEmitter(); this.ensureNostrServiceListener(); profileCache.subscribe((event, detail) => { @@ -288,6 +333,10 @@ class SubscriptionsManager { profileCache.set("subscriptions", Array.from(this.subscribedPubkeys)); } + on(eventName, handler) { + return this.emitter.on(eventName, handler); + } + async updateFromRelays(userPubkey) { if (!userPubkey) return; @@ -458,6 +507,10 @@ class SubscriptionsManager { } if (changed) { + this.emitter.emit("change", { + action: "sync", + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); devLogger.log("[SubscriptionsManager] Subscription list updated from relays, refreshing feed."); this.refreshActiveFeed({ reason: "subscription-update-background" }).catch((error) => { userLogger.warn( @@ -480,6 +533,7 @@ class SubscriptionsManager { this.lastRunOptions = null; this.lastResult = null; this.hasRenderedOnce = false; + this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] }); } async ensureLoaded(actorHex) { @@ -644,6 +698,11 @@ class SubscriptionsManager { } this.subscribedPubkeys.add(normalizedChannel); this.saveToCache(userPubkey); + this.emitter.emit("change", { + action: "add", + channel: normalizedChannel, + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( @@ -669,6 +728,11 @@ class SubscriptionsManager { } this.subscribedPubkeys.delete(normalizedChannel); this.saveToCache(userPubkey); + this.emitter.emit("change", { + action: "remove", + channel: normalizedChannel, + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index fe57b464..7ab78f04 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1407,6 +1407,24 @@ export class ProfileModalController { this.addAccountButtonState = null; this.adminEmptyMessages = new Map(); this.hashtagPreferencesUnsubscribe = null; + + if ( + this.subscriptionsService && + typeof this.subscriptionsService.on === "function" + ) { + this.subscriptionsService.on("change", () => { + void this.populateSubscriptionsList(); + }); + } + + if ( + this.services.userBlocks && + typeof this.services.userBlocks.on === "function" + ) { + this.services.userBlocks.on("change", () => { + this.populateBlockedList(); + }); + } } async load() { @@ -6765,11 +6783,16 @@ export class ProfileModalController { const shouldStayInMenu = keepMenuView && isMobile; this.setMobileView(shouldStayInMenu ? "menu" : "pane"); + const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); + if (target === "history") { void this.populateProfileWatchHistory(); } else if (target === "relays") { this.populateProfileRelays(); - void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select" }); + void this.refreshRelayHealthPanel({ + forceRefresh: true, + reason: "pane-select", + }); } else if (target === "messages") { this.resumeProfileMessages(); void this.populateProfileMessages({ reason: "pane-select" }); @@ -6781,8 +6804,14 @@ export class ProfileModalController { } else if (target === "hashtags") { this.populateHashtagPreferences(); } else if (target === "subscriptions") { + if (activeHex && this.subscriptionsService) { + this.subscriptionsService.loadSubscriptions(activeHex).catch(noop); + } void this.populateSubscriptionsList(); } else if (target === "blocked") { + if (activeHex && this.services.userBlocks) { + this.services.userBlocks.loadBlocks(activeHex).catch(noop); + } this.populateBlockedList(); } else if (target === "safety") { this.refreshModerationSettingsUi(); @@ -8008,17 +8037,6 @@ export class ProfileModalController { if (Array.isArray(subscriptions) && subscriptions.length) { sourceEntries = subscriptions; } else { - if (typeof service.ensureLoaded === "function") { - try { - await service.ensureLoaded(activeHex); - } catch (error) { - devLogger.warn( - "[profileModal] Failed to ensure subscriptions before populating subscriptions list:", - error, - ); - } - } - if (typeof service.getSubscribedAuthors === "function") { try { sourceEntries = service.getSubscribedAuthors() || []; @@ -12699,14 +12717,21 @@ export class ProfileModalController { this.setActivePubkey(activePubkey); } - await this.hydrateActiveWalletSettings(activePubkey); + const walletPromise = this.hydrateActiveWalletSettings(activePubkey); + const adminPromise = this.refreshAdminPaneState().catch((error) => { + userLogger.warn("Failed to refresh admin pane after login:", error); + }); this.renderSavedProfiles(); - try { - await this.refreshAdminPaneState(); - } catch (error) { - userLogger.warn("Failed to refresh admin pane after login:", error); + // Trigger aggressive parallel fetches + if (activePubkey) { + if (this.services.userBlocks) { + this.services.userBlocks.loadBlocks(activePubkey).catch(noop); + } + if (this.subscriptionsService) { + this.subscriptionsService.loadSubscriptions(activePubkey).catch(noop); + } } this.populateBlockedList(); @@ -12718,8 +12743,10 @@ export class ProfileModalController { this.handleActiveDmIdentityChanged(activePubkey); void this.refreshDmRelayPreferences({ force: true }); - postLoginPromise + // Ensure critical state is settled if possible, but don't block initial rendering + Promise.all([walletPromise, adminPromise, postLoginPromise]) .then(() => { + // Re-run population to ensure any late-arriving data is reflected this.populateBlockedList(); void this.populateSubscriptionsList(); void this.populateFriendsList(); diff --git a/js/userBlocks.js b/js/userBlocks.js index dcaa315e..cbb13a46 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -737,8 +737,6 @@ class UserBlockListManager { blockedPubkeys: Array.from(this.blockedPubkeys), source: "local", }); - emitStatus({ status: "settled" }); - return; } const applyBlockedPubkeys = (nextValues, meta = {}) => { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 5462966c..5a13b993 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -4,12 +4,13 @@ import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; import { nostrClient } from "../js/nostrClientFacade.js"; -import { getActiveSigner, setActiveSigner } from "../js/nostr/client.js"; +import { getActiveSigner, setActiveSigner, clearActiveSigner } from "../js/nostr/client.js"; import { subscriptions } from "../js/subscriptions.js"; import { setApplication } from "../js/applicationContext.js"; import nostrService from "../js/services/nostrService.js"; import moderationService from "../js/services/moderationService.js"; import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js"; +import { profileCache } from "../js/state/profileCache.js"; test("loadSubscriptions aggregates relay results when one rejects", async () => { const SubscriptionsManager = subscriptions.constructor; @@ -71,6 +72,8 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => const decryptCalls = []; globalThis.window.nostr = { + getPublicKey: async () => "0000000000000000000000000000000000000000000000000000000000000123", + signEvent: async () => ({}), nip04: { async decrypt(_pubkey, ciphertext) { decryptCalls.push(ciphertext); @@ -83,7 +86,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => }; try { - await manager.loadSubscriptions("user-pubkey-123"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000123"); assert.deepEqual( Array.from(manager.subscribedPubkeys), @@ -130,6 +133,9 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => delete globalThis.window; } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }); @@ -170,7 +176,7 @@ test("loadSubscriptions queries the correct subscription list kind", async () => nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); try { - await manager.loadSubscriptions("user-pubkey-legacy"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000002"); assert.ok( capturedFilters.length > 0, @@ -210,6 +216,9 @@ test("loadSubscriptions queries the correct subscription list kind", async () => delete globalThis.window; } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }); @@ -226,12 +235,6 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => { const originalPool = nostrClient.pool; const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; - const hadWindow = typeof globalThis.window !== "undefined"; - if (!hadWindow) { - globalThis.window = {}; - } - const originalWindowNostr = globalThis.window.nostr; - const relayUrls = ["wss://relay-nip44.example"]; nostrClient.relays = relayUrls; nostrClient.writeRelays = relayUrls; @@ -252,23 +255,20 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => { nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); const decryptCalls = { nip04: 0, nip44: 0 }; - globalThis.window.nostr = { - nip04: { - async decrypt() { - decryptCalls.nip04 += 1; - throw new Error("nip04 unavailable"); - }, + setActiveSigner({ + pubkey: "0000000000000000000000000000000000000000000000000000000000000005", + async nip04Decrypt() { + decryptCalls.nip04 += 1; + throw new Error("nip04 unavailable"); }, - nip44: { - async decrypt(_pubkey, ciphertext) { - decryptCalls.nip44 += 1; - return JSON.stringify([["p", "pub-nip44", ciphertext]]); - }, + async nip44Decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify([["p", "pub-nip44", ciphertext]]); }, - }; + }); try { - await manager.loadSubscriptions("user-pubkey-123"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000005"); assert.deepEqual( Array.from(manager.subscribedPubkeys), @@ -292,15 +292,10 @@ test("loadSubscriptions falls back to nip44 when hinted", async () => { nostrClient.writeRelays = originalWriteRelays; nostrClient.pool = originalPool; nostrClient.ensureExtensionPermissions = originalEnsurePermissions; - if (typeof originalWindowNostr === "undefined") { - delete globalThis.window.nostr; - } else { - globalThis.window.nostr = originalWindowNostr; - } - if (!hadWindow) { - delete globalThis.window; - } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }); @@ -317,12 +312,6 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { const originalPool = nostrClient.pool; const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; - const hadWindow = typeof globalThis.window !== "undefined"; - if (!hadWindow) { - globalThis.window = {}; - } - const originalWindowNostr = globalThis.window.nostr; - const relayUrls = ["wss://relay-nip44-v2.example"]; nostrClient.relays = relayUrls; nostrClient.writeRelays = relayUrls; @@ -343,19 +332,16 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); const decryptCalls = { nip04: 0, nip44: 0, nip44_v2: 0 }; - globalThis.window.nostr = { - nip44: { - v2: { - async decrypt(_pubkey, ciphertext) { - decryptCalls.nip44_v2 += 1; - return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]); - }, - }, + setActiveSigner({ + pubkey: "0000000000000000000000000000000000000000000000000000000000000006", + async nip44Decrypt(_pubkey, ciphertext) { + decryptCalls.nip44_v2 += 1; + return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]); }, - }; + }); try { - await manager.loadSubscriptions("user-pubkey-123"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000006"); assert.deepEqual( Array.from(manager.subscribedPubkeys), @@ -384,15 +370,10 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { nostrClient.writeRelays = originalWriteRelays; nostrClient.pool = originalPool; nostrClient.ensureExtensionPermissions = originalEnsurePermissions; - if (typeof originalWindowNostr === "undefined") { - delete globalThis.window.nostr; - } else { - globalThis.window.nostr = originalWindowNostr; - } - if (!hadWindow) { - delete globalThis.window; - } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }); @@ -409,12 +390,6 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async const originalPool = nostrClient.pool; const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; - const hadWindow = typeof globalThis.window !== "undefined"; - if (!hadWindow) { - globalThis.window = {}; - } - const originalWindowNostr = globalThis.window.nostr; - const relayUrls = ["wss://relay-nip44-pref.example"]; nostrClient.relays = relayUrls; nostrClient.writeRelays = relayUrls; @@ -435,25 +410,20 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); const decryptCalls = { nip04: 0, nip44: 0 }; - globalThis.window.nostr = { - nip04: { - async decrypt() { - decryptCalls.nip04 += 1; - return JSON.stringify([["p", "pub-nip04"]]); - }, + setActiveSigner({ + pubkey: "0000000000000000000000000000000000000000000000000000000000000003", + async nip04Decrypt() { + decryptCalls.nip04 += 1; + return JSON.stringify([["p", "pub-nip04"]]); }, - nip44: { - v2: { - async decrypt(_pubkey, ciphertext) { - decryptCalls.nip44 += 1; - return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]); - }, - }, + async nip44Decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]); }, - }; + }); try { - await manager.loadSubscriptions("user-pubkey-123"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000003"); assert.deepEqual( Array.from(manager.subscribedPubkeys), @@ -467,15 +437,10 @@ test("loadSubscriptions prefers nip44 decryptors when both are available", async nostrClient.writeRelays = originalWriteRelays; nostrClient.pool = originalPool; nostrClient.ensureExtensionPermissions = originalEnsurePermissions; - if (typeof originalWindowNostr === "undefined") { - delete globalThis.window.nostr; - } else { - globalThis.window.nostr = originalWindowNostr; - } - if (!hadWindow) { - delete globalThis.window; - } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }); @@ -526,7 +491,7 @@ test( const decryptCalls = []; setActiveSigner({ - pubkey: "user-pubkey-123", + pubkey: "0000000000000000000000000000000000000000000000000000000000000004", async nip04Decrypt(pubkey, ciphertext) { decryptCalls.push({ pubkey, ciphertext }); return JSON.stringify([["p", "pub-direct"]]); @@ -534,13 +499,13 @@ test( }); try { - await manager.loadSubscriptions("user-pubkey-123"); + await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000004"); assert.equal(permissionCalls, 0, "should not request extension permissions"); assert.equal(decryptCalls.length, 1, "signer decrypt should be invoked once"); assert.deepEqual( decryptCalls[0], - { pubkey: "user-pubkey-123", ciphertext: "cipher-direct" }, + { pubkey: "0000000000000000000000000000000000000000000000000000000000000004", ciphertext: "cipher-direct" }, "signer decrypt should receive the expected arguments", ); assert.deepEqual( @@ -563,6 +528,9 @@ test( delete globalThis.window; } localStorage.clear(); + profileCache.memoryCache.clear(); + profileCache.activePubkey = null; + clearActiveSigner(); } }, );