diff --git a/js/channelProfile.js b/js/channelProfile.js index f303119f..a251ca80 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3448,7 +3448,7 @@ export async function initChannelProfileView() { // 3) Load subscription state when logged in, but always render the toggle UI if (app?.pubkey) { subscriptionsTask = subscriptions - .loadSubscriptions(app.pubkey) + .ensureLoaded(app.pubkey) .catch((error) => { userLogger.error("Failed to load subscriptions for channel view:", error); }) @@ -3833,6 +3833,14 @@ function renderSubscribeButton(channelHex) { renderSubscribeButton(channelHex); } catch (err) { userLogger.error("Failed to update subscription:", err); + const permissionErrorCodes = new Set([ + "extension-permission-denied", + "nip04-missing", + ]); + const message = permissionErrorCodes.has(err?.code) + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your subscriptions. Please try again."; + currentApp?.showError?.(message); } }); } diff --git a/js/subscriptions.js b/js/subscriptions.js index e2b6abe4..bbba8338 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -132,6 +132,7 @@ class SubscriptionsManager { this.subscribedPubkeys = new Set(); this.subsEventId = null; this.loaded = false; + this.loadingPromise = null; this.subscriptionListView = null; this.lastRunOptions = null; this.lastResult = null; @@ -297,6 +298,35 @@ class SubscriptionsManager { } } + async ensureLoaded(actorHex) { + if (this.loaded) { + return; + } + + const normalizedActor = normalizeHexPubkey(actorHex) || actorHex; + if (!normalizedActor) { + return; + } + + if (this.loadingPromise) { + try { + await this.loadingPromise; + } catch (error) { + throw error; + } + return; + } + + const loader = this.loadSubscriptions(normalizedActor); + this.loadingPromise = loader; + + try { + await loader; + } finally { + this.loadingPromise = null; + } + } + isSubscribed(channelHex) { const normalized = normalizeHexPubkey(channelHex); if (!normalized) { @@ -409,6 +439,7 @@ class SubscriptionsManager { if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } + await this.ensureLoaded(userPubkey); if (!normalizedChannel) { devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex); return; @@ -432,6 +463,7 @@ class SubscriptionsManager { if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } + await this.ensureLoaded(userPubkey); if (!normalizedChannel) { devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex); return; @@ -627,15 +659,13 @@ class SubscriptionsManager { return null; } - if (!this.loaded) { - try { - await this.loadSubscriptions(userPubkey); - } catch (error) { - userLogger.error( - "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", - error, - ); - } + try { + await this.ensureLoaded(userPubkey); + } catch (error) { + userLogger.error( + "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", + error, + ); } const channelHexes = this.getSubscribedAuthors(); diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 747ddf50..6a751196 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -540,6 +540,28 @@ test( }, ); +test("ensureLoaded memoizes concurrent loads", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + let loadCount = 0; + manager.loadSubscriptions = async () => { + loadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 0)); + manager.loaded = true; + }; + + const actorHex = "f".repeat(64); + + await Promise.all([ + manager.ensureLoaded(actorHex), + manager.ensureLoaded(actorHex), + ]); + + assert.equal(loadCount, 1, "loadSubscriptions should only run once"); + assert.equal(manager.loaded, true, "manager should be marked as loaded"); +}); + test( "publishSubscriptionList succeeds with direct signer without requesting extension permissions", async () => {