From 2e30087ed1cd6937c3bb131a6b594defc02175a9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 22 Oct 2025 22:38:36 -0400 Subject: [PATCH] Refactor login modal flow for profile addition --- js/app.js | 319 ++++++--------------------- js/ui/loginModalController.js | 380 +++++++++++++++++++++++++++++++- js/ui/profileModalController.js | 241 +++++++++++++++++++- 3 files changed, 680 insertions(+), 260 deletions(-) diff --git a/js/app.js b/js/app.js index 06368ed0..d7e5d512 100644 --- a/js/app.js +++ b/js/app.js @@ -415,7 +415,6 @@ class Application { // Profile and login modal controller state this.profileController = null; this.loginModalController = null; - this.loginModalPendingTask = null; this.currentUserNpub = null; this.initializeLoginModalController({ logIfMissing: true }); @@ -567,6 +566,10 @@ class Application { persistSavedProfiles: (options) => persistSavedProfiles(options), watchHistoryService, authService: this.authService, + requestAddProfileLogin: (options) => + this.requestProfileAdditionLogin(options), + describeLoginError: (error, fallbackMessage) => + this.describeLoginError(error, fallbackMessage), log: (...args) => this.log(...args), closeAllMoreMenus: (options) => this.closeAllMoreMenus(options), clipboard: @@ -595,7 +598,7 @@ class Application { onClose: () => this.handleProfileModalClosed(), onLogout: async () => this.requestLogout(), onChannelLink: (element) => this.handleProfileChannelLink(element), - onAddAccount: (controller) => this.handleAddProfile(controller), + onAddAccount: (payload) => this.handleAddProfile(payload), onRequestSwitchProfile: (payload) => this.handleProfileSwitchRequest(payload), onRelayOperation: (payload) => @@ -1905,17 +1908,6 @@ class Application { devLogger.log("[LoginModal] Login result returned pubkey:", pubkey); - const pendingTask = - this.loginModalPendingTask && - this.loginModalPendingTask.type === "add-profile" - ? this.loginModalPendingTask - : null; - - if (pendingTask) { - pendingTask.resolve(result); - return; - } - if (!result || typeof result !== "object") { return; } @@ -1959,36 +1951,6 @@ class Application { ? fallbackMessage.trim() : "Failed to login. Please try again."; - const pendingTask = - this.loginModalPendingTask && - this.loginModalPendingTask.type === "add-profile" - ? this.loginModalPendingTask - : null; - - if (pendingTask) { - const rejectionError = - error instanceof Error - ? error - : new Error(normalizedMessage || "Failed to login."); - if ( - normalizedMessage && - rejectionError && - normalizedMessage !== rejectionError.message - ) { - rejectionError.message = normalizedMessage; - } - if ( - error && - typeof error === "object" && - error.code && - !rejectionError.code - ) { - rejectionError.code = error.code; - } - pendingTask.reject(rejectionError); - return; - } - userLogger.warn( provider && provider.id ? `[LoginModal] Login failed for provider ${provider.id}.` @@ -2001,12 +1963,15 @@ class Application { this.initializeLoginModalController(); if ( this.loginModalController && - typeof this.loginModalController.setNextRequestLoginOptions === - "function" + typeof this.loginModalController.requestAddProfileLogin === "function" ) { try { - return await this.openLoginModalForProfileAddition({ + return await this.loginModalController.requestAddProfileLogin({ triggerElement, + requestOptions: { + allowAccountSelection: true, + autoApply: false, + }, }); } catch (error) { if ( @@ -2035,213 +2000,18 @@ class Application { }); } - async openLoginModalForProfileAddition({ triggerElement } = {}) { - const controller = this.loginModalController; - if (!controller) { - const error = new Error("Login modal unavailable."); - error.code = "modal-unavailable"; - throw error; - } + async handleAddProfile(payload = {}) { + const loginResult = + payload && typeof payload === "object" ? payload.loginResult : null; - if (typeof controller.setNextRequestLoginOptions === "function") { - controller.setNextRequestLoginOptions({ - allowAccountSelection: true, - autoApply: false, - }); - } - - const loginModal = - prepareStaticModal({ id: "loginModal" }) || - document.getElementById("loginModal"); - - if (!loginModal) { - const error = new Error("Login modal unavailable."); - error.code = "modal-unavailable"; - throw error; - } - - if (this.loginModalPendingTask) { - const error = new Error( - "Another login operation is already in progress.", + if (!loginResult) { + devLogger.warn( + "[profileModal] Ignoring add profile callback without an authentication result.", ); - error.code = "login-in-progress"; - throw error; - } - - const isModalOpen = () => - loginModal.getAttribute("data-open") === "true" && - !loginModal.classList.contains("hidden"); - - return new Promise((resolve, reject) => { - let observer = null; - let pollTimer = null; - let settled = false; - - const pendingTask = { type: "add-profile" }; - - const finalize = - (handler) => - (value) => { - if (settled) { - return; - } - settled = true; - if (observer) { - observer.disconnect(); - observer = null; - } - if ( - pollTimer !== null && - typeof window !== "undefined" && - typeof window.clearInterval === "function" - ) { - window.clearInterval(pollTimer); - } - pollTimer = null; - if (this.loginModalPendingTask === pendingTask) { - this.loginModalPendingTask = null; - } - if (!isModalOpen() && typeof setGlobalModalState === "function") { - setGlobalModalState("login", false); - } - handler(value); - }; - - pendingTask.resolve = finalize((value) => resolve(value)); - pendingTask.reject = finalize((errorValue) => reject(errorValue)); - this.loginModalPendingTask = pendingTask; - - if (typeof MutationObserver === "function") { - observer = new MutationObserver(() => { - if (!isModalOpen()) { - const cancelError = new Error("Login cancelled."); - cancelError.code = "login-cancelled"; - pendingTask.reject(cancelError); - } - }); - - observer.observe(loginModal, { - attributes: true, - attributeFilter: ["data-open", "class"], - }); - } else if ( - typeof window !== "undefined" && - typeof window.setInterval === "function" - ) { - pollTimer = window.setInterval(() => { - if (!isModalOpen()) { - const cancelError = new Error("Login cancelled."); - cancelError.code = "login-cancelled"; - pendingTask.reject(cancelError); - } - }, 200); - } - - const opened = openStaticModal(loginModal, { triggerElement }); - const currentlyOpen = isModalOpen(); - - if (!opened && !currentlyOpen) { - const openError = new Error("Unable to open login modal."); - openError.code = "modal-open-failed"; - pendingTask.reject(openError); - return; - } - - if (typeof setGlobalModalState === "function") { - setGlobalModalState("login", true); - } - }); - } - - async handleAddProfile(controller) { - const button = - (controller && controller.addAccountButton) || - this.profileController?.addAccountButton || - null; - if (!(button instanceof HTMLElement)) { return; } - if (button.dataset.state === "loading") { - return; - } - - const titleEl = button.querySelector('[data-profile-add-title]'); - const hintEl = button.querySelector('[data-profile-add-hint]'); - const originalTitle = titleEl ? titleEl.textContent : ""; - const originalHint = hintEl ? hintEl.textContent : ""; - const originalAriaLabel = button.getAttribute("aria-label"); - const originalDisabled = button.disabled; - - const setLoadingState = (isLoading) => { - button.disabled = isLoading ? true : originalDisabled; - if (isLoading) { - button.dataset.state = "loading"; - } else { - delete button.dataset.state; - } - button.setAttribute("aria-busy", isLoading ? "true" : "false"); - if (isLoading) { - button.setAttribute("aria-disabled", "true"); - } else if (originalDisabled) { - button.setAttribute("aria-disabled", "true"); - } else { - button.removeAttribute("aria-disabled"); - } - if (isLoading) { - if (titleEl) { - titleEl.textContent = "Connecting..."; - } - if (hintEl) { - hintEl.textContent = "Complete the login prompt from your provider."; - } - button.setAttribute( - "aria-label", - "Connecting to your Nostr account", - ); - } else { - if (titleEl) { - titleEl.textContent = originalTitle; - } - if (hintEl) { - hintEl.textContent = originalHint; - } - if (originalAriaLabel === null) { - button.removeAttribute("aria-label"); - } else { - button.setAttribute("aria-label", originalAriaLabel); - } - } - }; - - setLoadingState(true); try { - let loginResult; - try { - loginResult = await this.requestProfileAdditionLogin({ - triggerElement: button, - }); - } catch (requestError) { - const cancellationCodes = new Set([ - "login-cancelled", - "user-cancelled", - "modal-dismissed", - ]); - if ( - requestError && - typeof requestError === "object" && - typeof requestError.code === "string" && - cancellationCodes.has(requestError.code) - ) { - devLogger.log( - "[profileModal] Add profile login cancelled by user.", - requestError, - ); - return; - } - throw requestError; - } - const { pubkey, authType: loginAuthType, providerId } = typeof loginResult === "object" && loginResult ? loginResult @@ -2257,7 +2027,12 @@ class Application { : null; const resolvedAuthType = (() => { - const candidates = [detailAuthType, loginAuthType, detailProviderId, providerId]; + const candidates = [ + detailAuthType, + loginAuthType, + detailProviderId, + providerId, + ]; for (const candidate of candidates) { if (typeof candidate !== "string") { continue; @@ -2292,8 +2067,7 @@ class Application { } const alreadySaved = this.savedProfiles.some( - (entry) => - this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey, + (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey, ); if (alreadySaved) { this.showSuccess("That profile is already saved on this device."); @@ -2345,13 +2119,31 @@ class Application { "[profileModal] Failed to add profile via authentication provider:", error, ); + const message = this.describeLoginError( error, "Couldn't add that profile. Please try again.", ); - this.showError(message); - } finally { - setLoadingState(false); + + const rejectionError = + error instanceof Error + ? error + : new Error(message || "Couldn't add that profile. Please try again."); + + if (message && rejectionError.message !== message) { + rejectionError.message = message; + } + + if ( + error && + typeof error === "object" && + error.code && + !rejectionError.code + ) { + rejectionError.code = error.code; + } + + throw rejectionError; } } @@ -2401,12 +2193,26 @@ class Application { return false; } + const preparedModal = + prepareStaticModal({ root: loginModalElement }) || loginModalElement; + + loginModalElement = preparedModal; + const closeLoginModal = () => { - if (closeStaticModal("loginModal")) { + if (closeStaticModal(loginModalElement)) { setGlobalModalState("login", false); } }; + const openLoginModal = ({ modal, triggerElement } = {}) => { + const target = + modal instanceof HTMLElement ? modal : loginModalElement; + return openStaticModal(target, { triggerElement }); + }; + + const prepareLoginModal = (modal) => + prepareStaticModal({ root: modal || loginModalElement }); + try { this.loginModalController = new LoginModalController({ modalElement: loginModalElement, @@ -2444,6 +2250,11 @@ class Application { }, helpers: { closeModal: closeLoginModal, + openModal: ({ modal, triggerElement } = {}) => + openLoginModal({ modal, triggerElement }), + prepareModal: (modal) => prepareLoginModal(modal), + setModalState: (_, isOpen) => + setGlobalModalState("login", isOpen === undefined ? false : !!isOpen), describeLoginError: (error, fallbackMessage) => this.describeLoginError( error, diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index b6dfc0f7..3c315500 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -3,6 +3,15 @@ import { renderQrCode } from "../utils/qrcode.js"; const noop = () => {}; const SLOW_PROVIDER_DELAY_MS = 8_000; +const MODAL_CLOSE_POLL_INTERVAL_MS = 200; + +function createError(code, message) { + const error = new Error(message); + if (code) { + error.code = code; + } + return error; +} function toArray(input) { if (!input) { @@ -247,6 +256,12 @@ export default class LoginModalController { typeof helpers.describeLoginError === "function" ? helpers.describeLoginError : (_, fallbackMessage) => fallbackMessage, + openModal: + typeof helpers.openModal === "function" ? helpers.openModal : null, + prepareModal: + typeof helpers.prepareModal === "function" ? helpers.prepareModal : null, + setModalState: + typeof helpers.setModalState === "function" ? helpers.setModalState : null, }; this.providerContainer = null; @@ -267,6 +282,8 @@ export default class LoginModalController { this.lastRemoteSignerStatus = null; this.nextRequestLoginOptions = null; this.nextRequestLoginOptionsResolver = null; + this.pendingTask = null; + this.modalPrepared = false; this.initializeRemoteSignerStatus(); this.initialized = false; @@ -299,6 +316,309 @@ export default class LoginModalController { this.initialized = true; } + ensureModalPrepared() { + if (!(this.modalElement instanceof HTMLElement)) { + return null; + } + + if (!this.modalPrepared && typeof this.helpers.prepareModal === "function") { + try { + const prepared = this.helpers.prepareModal(this.modalElement); + if (prepared instanceof HTMLElement) { + this.modalElement = prepared; + } + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to prepare login modal for accessibility:", + error, + ); + } + } + + this.modalPrepared = true; + return this.modalElement instanceof HTMLElement ? this.modalElement : null; + } + + isModalOpen() { + const modal = this.modalElement; + if (!(modal instanceof HTMLElement)) { + return false; + } + + if (typeof modal.dataset?.open === "string") { + return modal.dataset.open === "true"; + } + + const attr = modal.getAttribute("data-open"); + if (typeof attr === "string") { + return attr === "true"; + } + + return !modal.classList.contains("hidden"); + } + + openModal({ triggerElement } = {}) { + const modal = this.ensureModalPrepared(); + if (!(modal instanceof HTMLElement)) { + throw this.createModalUnavailableError(); + } + + if (typeof this.helpers.openModal === "function") { + try { + const result = this.helpers.openModal({ + modal, + triggerElement, + }); + return result !== false; + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to open login modal via helper:", + error, + ); + return false; + } + } + + try { + modal.classList.remove("hidden"); + modal.setAttribute("data-open", "true"); + return true; + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to toggle login modal visibility:", + error, + ); + } + + return false; + } + + createModalUnavailableError() { + return createError("modal-unavailable", "Login modal unavailable."); + } + + createLoginInProgressError() { + return createError( + "login-in-progress", + "Another login operation is already in progress.", + ); + } + + createModalOpenFailedError() { + return createError("modal-open-failed", "Unable to open login modal."); + } + + createCancellationError() { + return createError("login-cancelled", "Login cancelled."); + } + + startModalCloseWatch(task) { + const modal = this.modalElement; + if (!(modal instanceof HTMLElement)) { + return; + } + + const handleClose = () => { + if (task.settled) { + return; + } + task.reject(this.createCancellationError()); + }; + + if (typeof MutationObserver === "function") { + try { + const observer = new MutationObserver(() => { + if (!this.isModalOpen()) { + handleClose(); + } + }); + observer.observe(modal, { + attributes: true, + attributeFilter: ["data-open", "class"], + }); + task.observer = observer; + return; + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to observe login modal close state:", + error, + ); + } + } + + if ( + this.window && + typeof this.window.setInterval === "function" && + typeof this.window.clearInterval === "function" + ) { + const timerId = this.window.setInterval(() => { + if (!this.isModalOpen()) { + handleClose(); + } + }, MODAL_CLOSE_POLL_INTERVAL_MS); + task.pollTimer = timerId; + } + } + + stopModalCloseWatch(task) { + if (!task) { + return; + } + + if (task.observer && typeof task.observer.disconnect === "function") { + try { + task.observer.disconnect(); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to disconnect login modal observer:", + error, + ); + } + } + + task.observer = null; + + if ( + typeof task.pollTimer === "number" && + this.window && + typeof this.window.clearInterval === "function" + ) { + try { + this.window.clearInterval(task.pollTimer); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to clear login modal polling timer:", + error, + ); + } + } + + task.pollTimer = null; + } + + createPendingModalTask(type, { triggerElement } = {}) { + const modal = this.ensureModalPrepared(); + if (!(modal instanceof HTMLElement)) { + throw this.createModalUnavailableError(); + } + + if (this.pendingTask) { + throw this.createLoginInProgressError(); + } + + return new Promise((resolve, reject) => { + const task = { + type, + resolve: null, + reject: null, + settled: false, + observer: null, + pollTimer: null, + }; + + const finalize = (handler) => (value) => { + if (task.settled) { + return; + } + task.settled = true; + this.stopModalCloseWatch(task); + if (this.pendingTask === task) { + this.pendingTask = null; + } + if ( + typeof this.helpers.setModalState === "function" && + !this.isModalOpen() + ) { + try { + this.helpers.setModalState("login", false); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to synchronize login modal state:", + error, + ); + } + } + handler(value); + }; + + task.resolve = finalize((value) => resolve(value)); + task.reject = finalize((errorValue) => reject(errorValue)); + + this.pendingTask = task; + + this.startModalCloseWatch(task); + + let opened = false; + try { + opened = this.openModal({ triggerElement }); + } catch (error) { + devLogger.warn( + "[LoginModalController] Opening login modal threw:", + error, + ); + } + + if (!opened && !this.isModalOpen()) { + task.reject(this.createModalOpenFailedError()); + return; + } + + if (typeof this.helpers.setModalState === "function") { + try { + this.helpers.setModalState("login", true); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to mark login modal as open in app state:", + error, + ); + } + } + }); + } + + resolvePendingTask(result, { type } = {}) { + const task = this.pendingTask; + if (!task || (type && task.type !== type) || typeof task.resolve !== "function") { + return false; + } + task.resolve(result); + return true; + } + + rejectPendingTask(error, { type } = {}) { + const task = this.pendingTask; + if (!task || (type && task.type !== type) || typeof task.reject !== "function") { + return false; + } + task.reject(error); + return true; + } + + requestAddProfileLogin({ triggerElement, requestOptions } = {}) { + const options = { + allowAccountSelection: true, + autoApply: false, + ...(requestOptions && typeof requestOptions === "object" + ? requestOptions + : {}), + }; + + let promise; + + try { + promise = this.createPendingModalTask("add-profile", { triggerElement }); + } catch (error) { + this.setNextRequestLoginOptions(null); + throw error; + } + + this.setNextRequestLoginOptions(() => ({ ...options })); + + return promise.finally(() => { + this.setNextRequestLoginOptions(null); + }); + } + getNostrClient() { const direct = this.services?.nostrClient; if (direct && typeof direct === "object") { @@ -1742,14 +2062,25 @@ export default class LoginModalController { result?.pubkey, ); + const consumed = this.resolvePendingTask(result, { type: "add-profile" }); + const shouldClose = + result && + typeof result === "object" && + typeof result.pubkey === "string" && + result.pubkey.trim(); + + if (shouldClose) { + this.helpers.closeModal(); + } + + if (consumed) { + return; + } + await safeInvokeAsync(this.callbacks.onLoginSuccess, { provider: entry.provider.source || entry.provider, result, }); - - if (result && typeof result.pubkey === "string" && result.pubkey.trim()) { - this.helpers.closeModal(); - } } catch (error) { devLogger.error( `[LoginModalController] Login failed for ${providerId}:`, @@ -1770,9 +2101,35 @@ export default class LoginModalController { ); } + const rejectionError = + error instanceof Error + ? error + : new Error(message || "Failed to login. Please try again."); + + if (message && message !== rejectionError.message) { + rejectionError.message = message; + } + + if ( + error && + typeof error === "object" && + typeof error.code === "string" && + !rejectionError.code + ) { + rejectionError.code = error.code; + } + + const consumed = this.rejectPendingTask(rejectionError, { + type: "add-profile", + }); + + if (consumed) { + return; + } + await safeInvokeAsync(this.callbacks.onLoginError, { provider: entry.provider.source || entry.provider, - error, + error: rejectionError, message, }); } finally { @@ -1836,6 +2193,19 @@ export default class LoginModalController { } destroy() { + if (this.pendingTask && typeof this.pendingTask.reject === "function") { + try { + this.pendingTask.reject(this.createCancellationError()); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to cancel pending login task during destroy:", + error, + ); + } + } + + this.pendingTask = null; + if (this.providerContainer && this.boundClickHandler) { this.providerContainer.removeEventListener("click", this.boundClickHandler); } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index a6ab4010..83e3488f 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -25,6 +25,12 @@ const SECRET_PLACEHOLDER = "*****"; const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; +const ADD_PROFILE_CANCELLATION_CODES = new Set([ + "login-cancelled", + "user-cancelled", + "modal-dismissed", +]); + const PROVIDER_BADGE_BASE_CLASS = "text-3xs font-semibold uppercase tracking-extra-wide"; const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({ @@ -154,6 +160,25 @@ const SERVICE_CONTRACT = [ "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).", fallback: () => (value) => (typeof value === "string" ? value : ""), }, + { + key: "requestAddProfileLogin", + type: "function", + description: + "Opens the login modal and resolves with the authentication result for the add-profile flow.", + fallback: () => async () => { + throw new Error("Login service unavailable."); + }, + }, + { + key: "describeLoginError", + type: "function", + description: + "Maps authentication errors to human-readable strings for add-profile messaging.", + fallback: () => (_, fallbackMessage) => + typeof fallbackMessage === "string" && fallbackMessage.trim() + ? fallbackMessage.trim() + : "Failed to login. Please try again.", + }, { key: "getProfileCacheEntry", type: "function", @@ -766,6 +791,14 @@ export class ProfileModalController { this.describeAdminErrorService = this.services.describeAdminError; this.describeNotificationErrorService = this.services.describeNotificationError; + this.describeLoginErrorService = this.services.describeLoginError; + this.requestAddProfileLoginService = this.services.requestAddProfileLogin; + this.log = + typeof this.services.log === "function" + ? this.services.log + : (...args) => { + devLogger.log(...args); + }; this.callbacks = { onClose: callbacks.onClose || noop, @@ -920,6 +953,7 @@ export class ProfileModalController { this.lastMobileViewState = "menu"; this.setActivePane(this.getActivePane()); this.setWalletPaneBusy(this.isWalletBusy()); + this.addAccountButtonState = null; this.adminEmptyMessages = new Map(); } @@ -1223,7 +1257,7 @@ export class ProfileModalController { if (this.addAccountButton instanceof HTMLElement) { this.addAccountButton.addEventListener("click", () => { - this.callbacks.onAddAccount(this); + void this.handleAddAccountRequest(); }); } @@ -1382,6 +1416,211 @@ export class ProfileModalController { } } + resolveAddAccountLoginError(error, fallbackMessage = "") { + const describe = this.describeLoginErrorService; + const fallback = + typeof fallbackMessage === "string" && fallbackMessage.trim() + ? fallbackMessage.trim() + : "Couldn't add that profile. Please try again."; + + if (typeof describe === "function") { + try { + const message = describe(error, fallback); + if (typeof message === "string" && message.trim()) { + return message.trim(); + } + } catch (describeError) { + devLogger.warn( + "[ProfileModalController] describeLoginError service threw:", + describeError, + ); + } + } + + return fallback; + } + + setAddAccountLoading(isLoading) { + if (!(this.addAccountButton instanceof HTMLElement)) { + return; + } + + const button = this.addAccountButton; + const titleEl = button.querySelector("[data-profile-add-title]"); + const hintEl = button.querySelector("[data-profile-add-hint]"); + + if (isLoading) { + this.addAccountButtonState = { + originalDisabled: button.disabled, + originalAriaLabel: button.getAttribute("aria-label"), + titleElement: titleEl instanceof HTMLElement ? titleEl : null, + hintElement: hintEl instanceof HTMLElement ? hintEl : null, + originalTitle: + titleEl instanceof HTMLElement ? titleEl.textContent || "" : "", + originalHint: + hintEl instanceof HTMLElement ? hintEl.textContent || "" : "", + }; + + button.disabled = true; + button.dataset.state = "loading"; + button.setAttribute("aria-busy", "true"); + button.setAttribute("aria-disabled", "true"); + + if (this.addAccountButtonState.titleElement) { + this.addAccountButtonState.titleElement.textContent = "Connecting..."; + } + + if (this.addAccountButtonState.hintElement) { + this.addAccountButtonState.hintElement.textContent = + "Complete the login prompt from your provider."; + } + + button.setAttribute( + "aria-label", + "Connecting to your Nostr account", + ); + + return; + } + + const state = this.addAccountButtonState; + + if (state) { + button.disabled = !!state.originalDisabled; + if (state.originalDisabled) { + button.setAttribute("aria-disabled", "true"); + } else { + button.removeAttribute("aria-disabled"); + } + + if (state.titleElement) { + state.titleElement.textContent = state.originalTitle || ""; + } + + if (state.hintElement) { + state.hintElement.textContent = state.originalHint || ""; + } + + if (state.originalAriaLabel === null) { + button.removeAttribute("aria-label"); + } else if (typeof state.originalAriaLabel === "string") { + button.setAttribute("aria-label", state.originalAriaLabel); + } + } else { + if (button.disabled) { + button.setAttribute("aria-disabled", "true"); + } else { + button.removeAttribute("aria-disabled"); + } + } + + button.setAttribute("aria-busy", "false"); + delete button.dataset.state; + + this.addAccountButtonState = null; + } + + isAddAccountCancellationError(error) { + if (!error || typeof error !== "object") { + return false; + } + + const code = + typeof error.code === "string" && error.code.trim() + ? error.code.trim() + : ""; + + if (!code) { + return false; + } + + return ADD_PROFILE_CANCELLATION_CODES.has(code); + } + + async handleAddAccountRequest() { + if (!(this.addAccountButton instanceof HTMLElement)) { + return; + } + + if (this.addAccountButton.dataset.state === "loading") { + return; + } + + const requestLogin = this.requestAddProfileLoginService; + if (typeof requestLogin !== "function") { + devLogger.warn( + "[ProfileModalController] requestAddProfileLogin service unavailable.", + ); + this.showError("Login is unavailable right now. Please try again later."); + return; + } + + this.setAddAccountLoading(true); + + try { + const loginResult = await requestLogin({ + controller: this, + triggerElement: this.addAccountButton, + }); + + if (loginResult === undefined) { + return; + } + + await this.invokeAddAccountCallback(loginResult); + } catch (error) { + if (this.isAddAccountCancellationError(error)) { + try { + this.log( + "[ProfileModalController] Add profile flow cancelled by user.", + error, + ); + } catch (logError) { + devLogger.warn( + "[ProfileModalController] Failed to log cancellation event:", + logError, + ); + } + return; + } + + devLogger.error( + "[ProfileModalController] Failed to complete add profile authentication:", + error, + ); + + const message = this.resolveAddAccountLoginError( + error, + "Couldn't add that profile. Please try again.", + ); + + if (message) { + this.showError(message); + } + } finally { + this.setAddAccountLoading(false); + } + } + + async invokeAddAccountCallback(loginResult) { + if (typeof this.callbacks.onAddAccount !== "function") { + return; + } + + try { + await this.callbacks.onAddAccount({ + controller: this, + loginResult, + }); + } catch (error) { + devLogger.warn( + "[ProfileModalController] onAddAccount callback threw:", + error, + ); + throw error; + } + } + renderSavedProfiles() { const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey()); const entriesNeedingFetch = new Set();