diff --git a/components/profile-modal.html b/components/profile-modal.html index 5a43fe6a..301b7d0c 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -381,6 +381,24 @@ data-profile-mobile-pane aria-hidden="true" > +
0; + const message = shouldShow ? this.resolvePermissionPromptMessage() : ""; + + this.profileController.setPermissionPromptCtaState({ + visible: shouldShow, + message, + buttonLabel: "Enable permissions", + busy: this.permissionPromptInFlight, + }); + } + normalizeHashtagPreferenceList(list) { if (!Array.isArray(list)) { return []; @@ -4040,6 +4122,98 @@ class Application { ); } + async handlePermissionPromptRequest() { + if (this.permissionPromptInFlight) { + return; + } + + const activePubkey = this.normalizeHexPubkey(this.pubkey); + if (!activePubkey) { + return; + } + + const needsSubscriptions = this.permissionPromptPending.has("subscriptions"); + const needsHashtags = this.permissionPromptPending.has("hashtag-preferences"); + + if (!needsSubscriptions && !needsHashtags) { + return; + } + + this.permissionPromptInFlight = true; + this.updatePermissionPromptCta(); + + const tasks = []; + + if (needsSubscriptions && subscriptions?.ensureLoaded) { + const subscriptionTask = subscriptions + .ensureLoaded(activePubkey, { allowPermissionPrompt: true }) + .then(() => { + this.capturePermissionPromptFromError(subscriptions?.lastLoadError); + if ( + subscriptions?.lastLoadError?.code !== + "subscriptions-permission-required" + ) { + this.permissionPromptPending.delete("subscriptions"); + if (this.profileController) { + this.profileController.populateSubscriptionsList(); + } + if (typeof subscriptions?.refreshActiveFeed === "function") { + return subscriptions.refreshActiveFeed({ + reason: "permission-prompt", + }); + } + } + return null; + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to refresh subscriptions after permission prompt:", + error, + ); + this.capturePermissionPromptFromError(error); + }); + tasks.push(subscriptionTask); + } + + if (needsHashtags && this.hashtagPreferences?.load) { + const hashtagTask = this.hashtagPreferences + .load(activePubkey, { allowPermissionPrompt: true }) + .then(() => { + this.capturePermissionPromptFromError( + this.hashtagPreferences?.lastLoadError, + ); + if ( + this.hashtagPreferences?.lastLoadError?.code !== + "hashtag-preferences-permission-required" + ) { + this.permissionPromptPending.delete("hashtag-preferences"); + this.updateCachedHashtagPreferences(); + this.refreshTagPreferenceUi(); + if (this.profileController) { + this.profileController.populateHashtagPreferences(); + } + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to refresh hashtag preferences after permission prompt:", + error, + ); + this.capturePermissionPromptFromError(error); + }); + tasks.push(hashtagTask); + } + + await Promise.allSettled(tasks); + + if (this.permissionPromptPending.size === 0) { + this.permissionPromptVisible = false; + } + + this.permissionPromptInFlight = false; + this.updatePermissionPromptCta(); + } + async handleAuthLogin(detail = {}) { const postLoginPromise = detail && typeof detail.postLoginPromise?.then === "function" @@ -4059,6 +4233,8 @@ class Application { this.resetViewLoggingState(); } + this.resetPermissionPromptState(); + // Stop existing feed subscription to prioritize user data sync if ( this.videoSubscription && @@ -4203,13 +4379,20 @@ class Application { tasks.push( subscriptions.ensureLoaded(activePubkey, { allowPermissionPrompt: false, - }).catch((error) => { - devLogger.warn( - "[Application] Failed to load subscriptions during login:", - error, - ); - throw error; - }), + }) + .then(() => { + this.capturePermissionPromptFromError( + subscriptions?.lastLoadError, + ); + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to load subscriptions during login:", + error, + ); + this.capturePermissionPromptFromError(error); + throw error; + }), ); } @@ -4284,11 +4467,17 @@ class Application { .load(activePubkey, { allowPermissionPrompt: false, }) + .then(() => { + this.capturePermissionPromptFromError( + this.hashtagPreferences?.lastLoadError, + ); + }) .catch((error) => { devLogger.warn( "[Application] Failed to load hashtag preferences during login:", error, ); + this.capturePermissionPromptFromError(error); }); } return Promise.resolve(); @@ -4475,6 +4664,7 @@ class Application { this.pendingModalZapOpen = false; this.resetHashtagPreferencesState(); + this.resetPermissionPromptState(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); await this.nwcSettingsService.onLogout({ diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index ec6c1de1..bc481a51 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -242,6 +242,7 @@ class HashtagPreferencesService { this.loadPromise = null; this.loadingPubkey = null; this.subscriptionKey = null; + this.lastLoadError = null; profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -271,6 +272,7 @@ class HashtagPreferencesService { this.loaded = false; this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; + this.lastLoadError = null; if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; @@ -539,6 +541,7 @@ class HashtagPreferencesService { async loadInternal(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + this.lastLoadError = null; let wasLoadedForUser = this.activePubkey && this.activePubkey === normalized && @@ -790,6 +793,7 @@ class HashtagPreferencesService { } if (!decryptResult.ok) { + this.lastLoadError = decryptResult.error || null; userLogger.warn( `${LOG_PREFIX} Failed to decrypt hashtag preferences`, decryptResult.error, diff --git a/js/subscriptions.js b/js/subscriptions.js index 9af01a36..10c7a7ec 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -339,6 +339,7 @@ class SubscriptionsManager { this.lastRunOptions = null; this.lastResult = null; this.lastContainerId = null; + this.lastLoadError = null; this.unsubscribeFromNostrUpdates = null; this.pendingRefreshPromise = null; this.scheduledRefreshDetail = null; @@ -453,6 +454,7 @@ class SubscriptionsManager { const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const wasBackgroundLoading = this.backgroundLoading; const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; + this.lastLoadError = null; const readRelays = relayManager.getReadRelayUrls(); const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); @@ -603,6 +605,7 @@ class SubscriptionsManager { } if (!decryptResult.ok) { + this.lastLoadError = decryptResult.error || null; if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { if (!this.loaded && !cachedSnapshot.hasSnapshot) { this.loaded = true; @@ -686,6 +689,7 @@ class SubscriptionsManager { } } catch (err) { + this.lastLoadError = err || null; userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err); } } @@ -701,6 +705,7 @@ class SubscriptionsManager { this.lastRunOptions = null; this.lastResult = null; this.hasRenderedOnce = false; + this.lastLoadError = null; if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 6030f3a5..b8fdeceb 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -604,6 +604,7 @@ export default class ApplicationBootstrap { app.handleProfilePrivacyToggle(payload), onPublishDmRelayPreferences: (payload) => app.handleProfilePublishDmRelayPreferences(payload), + onRequestPermissionPrompt: () => app.handlePermissionPromptRequest(), }; app.profileController = new ProfileModalController({ diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index ee6d7687..678e27eb 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1195,6 +1195,7 @@ export class ProfileModalController { onTogglePrivacy: callbacks.onTogglePrivacy || noop, onOpenRelays: callbacks.onOpenRelays || noop, onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop, + onRequestPermissionPrompt: callbacks.onRequestPermissionPrompt || noop, }; this.profileModal = null; @@ -1331,6 +1332,15 @@ export class ProfileModalController { this.profileHashtagDisinterestRefreshBtn = null; this.subscriptionsStatusText = null; this.subscriptionsBackgroundLoading = false; + this.permissionPromptCta = null; + this.permissionPromptCtaMessage = null; + this.permissionPromptCtaButton = null; + this.permissionPromptCtaState = { + visible: false, + message: "", + buttonLabel: "Enable permissions", + busy: false, + }; this.profileSubscriptionsRefreshBtn = null; this.profileFriendsRefreshBtn = null; this.profileBlockedRefreshBtn = null; @@ -1512,6 +1522,12 @@ export class ProfileModalController { this.profileModalPaneWrapper = this.profileModalRoot?.querySelector("[data-profile-mobile-pane]") || null; this.profileModal = this.profileModalRoot; + this.permissionPromptCta = + document.getElementById("profilePermissionPrompt") || null; + this.permissionPromptCtaMessage = + document.getElementById("profilePermissionPromptMessage") || null; + this.permissionPromptCtaButton = + document.getElementById("profilePermissionPromptButton") || null; this.closeButton = document.getElementById("closeProfileModal") || null; this.profileModalBackButton = document.getElementById("profileModalBack") || null; @@ -1735,6 +1751,7 @@ export class ProfileModalController { document.getElementById("profileHashtagDisinterestRefreshBtn") || null; this.subscriptionsStatusText = document.getElementById("subscriptionsStatus") || null; + this.applyPermissionPromptCtaState(); this.profileRelayList = this.relayList; this.profileRelayInput = this.relayInput; @@ -5334,6 +5351,12 @@ export class ProfileModalController { }); } + if (this.permissionPromptCtaButton instanceof HTMLElement) { + this.permissionPromptCtaButton.addEventListener("click", () => { + this.callbacks.onRequestPermissionPrompt({ controller: this }); + }); + } + Object.entries(this.navButtons).forEach(([name, button]) => { if (button instanceof HTMLElement) { button.addEventListener("click", () => { @@ -7855,6 +7878,58 @@ export class ProfileModalController { }; } + setPermissionPromptCtaState(nextState = {}) { + if (!nextState || typeof nextState !== "object") { + return; + } + + this.permissionPromptCtaState = { + ...this.permissionPromptCtaState, + ...nextState, + }; + + this.applyPermissionPromptCtaState(); + } + + applyPermissionPromptCtaState() { + if (!(this.permissionPromptCta instanceof HTMLElement)) { + return; + } + + const fallbackMessage = + "Enable permissions to load your subscriptions and hashtag preferences."; + const { + visible, + message, + buttonLabel, + busy, + } = this.permissionPromptCtaState || {}; + const normalizedMessage = + typeof message === "string" && message.trim() + ? message.trim() + : fallbackMessage; + + this.permissionPromptCta.classList.toggle("hidden", !visible); + this.permissionPromptCta.setAttribute("aria-hidden", (!visible).toString()); + + if (this.permissionPromptCtaMessage instanceof HTMLElement) { + this.permissionPromptCtaMessage.textContent = normalizedMessage; + } + + if (this.permissionPromptCtaButton instanceof HTMLButtonElement) { + const normalizedLabel = + typeof buttonLabel === "string" && buttonLabel.trim() + ? buttonLabel.trim() + : "Enable permissions"; + this.permissionPromptCtaButton.textContent = normalizedLabel; + this.permissionPromptCtaButton.disabled = Boolean(busy); + this.permissionPromptCtaButton.setAttribute( + "aria-busy", + busy ? "true" : "false", + ); + } + } + setHashtagStatus(message = "", tone = "muted") { if (!(this.hashtagStatusText instanceof HTMLElement)) { return;