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 [];
@@ -4039,6 +4121,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"
@@ -4058,6 +4232,8 @@ class Application {
this.resetViewLoggingState();
}
+ this.resetPermissionPromptState();
+
// Stop existing feed subscription to prioritize user data sync
if (
this.videoSubscription &&
@@ -4202,13 +4378,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;
+ }),
);
}
@@ -4283,11 +4466,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();
@@ -4474,6 +4663,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 bccb873e..16cf18ec 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;