Merge pull request #1994 from PR0M3TH3AN/codex/handle-auth-login-failures-and-permissions

Add permission CTA and retry flow for subscriptions & hashtag preferences
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 15:40:12 -05:00
commit deec2cd573
6 files changed
+300 -7

No files matched your search

+18
View File
@@ -381,6 +381,24 @@
data-profile-mobile-pane
aria-hidden="true"
>
<div
id="profilePermissionPrompt"
class="card hidden flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
role="status"
aria-live="polite"
>
<p
id="profilePermissionPromptMessage"
class="text-sm text-muted"
></p>
<button
id="profilePermissionPromptButton"
type="button"
class="btn focus-ring"
>
Enable permissions
</button>
</div>
<div id="profileModalPanes" class="profile-modal__panes">
<section
id="profilePaneAccount"
+197 -7
View File
@@ -274,6 +274,10 @@ class Application {
this.dmRecipientPubkey = null;
this.dmRelayHints = new Map();
this.authLoadingState = { profile: "idle", lists: "idle", dms: "idle" };
this.permissionPromptShownForSession = false;
this.permissionPromptVisible = false;
this.permissionPromptInFlight = false;
this.permissionPromptPending = new Set();
this.relayUiRefreshTimeout = null;
this.lastRelayHealthWarningAt = 0;
this.appStartedAt = Date.now();
@@ -2093,6 +2097,84 @@ class Application {
return fallback;
}
resetPermissionPromptState() {
this.permissionPromptShownForSession = false;
this.permissionPromptVisible = false;
this.permissionPromptInFlight = false;
if (this.permissionPromptPending instanceof Set) {
this.permissionPromptPending.clear();
} else {
this.permissionPromptPending = new Set();
}
this.updatePermissionPromptCta();
}
capturePermissionPromptRequirement(source) {
const normalized =
typeof source === "string" ? source.trim().toLowerCase() : "";
if (!normalized) {
return;
}
this.permissionPromptPending.add(normalized);
if (!this.permissionPromptShownForSession) {
this.permissionPromptShownForSession = true;
this.permissionPromptVisible = true;
}
this.updatePermissionPromptCta();
}
capturePermissionPromptFromError(error) {
const code =
error && typeof error.code === "string" ? error.code.trim() : "";
switch (code) {
case "subscriptions-permission-required":
this.capturePermissionPromptRequirement("subscriptions");
break;
case "hashtag-preferences-permission-required":
this.capturePermissionPromptRequirement("hashtag-preferences");
break;
default:
break;
}
}
resolvePermissionPromptMessage() {
const needsSubscriptions = this.permissionPromptPending.has("subscriptions");
const needsHashtags = this.permissionPromptPending.has("hashtag-preferences");
if (needsSubscriptions && needsHashtags) {
return "Enable permissions to load your subscriptions and hashtag preferences.";
}
if (needsSubscriptions) {
return "Enable permissions to load your subscriptions.";
}
if (needsHashtags) {
return "Enable permissions to load your hashtag preferences.";
}
return "";
}
updatePermissionPromptCta() {
if (!this.profileController?.setPermissionPromptCtaState) {
return;
}
const shouldShow =
this.permissionPromptVisible && this.permissionPromptPending.size > 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({
+4
View File
@@ -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,
+5
View File
@@ -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;
+1
View File
@@ -604,6 +604,7 @@ export default class ApplicationBootstrap {
app.handleProfilePrivacyToggle(payload),
onPublishDmRelayPreferences: (payload) =>
app.handleProfilePublishDmRelayPreferences(payload),
onRequestPermissionPrompt: () => app.handlePermissionPromptRequest(),
};
app.profileController = new ProfileModalController({
+75
View File
@@ -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;