diff --git a/js/app.js b/js/app.js index d2efe317..48a5d6f8 100644 --- a/js/app.js +++ b/js/app.js @@ -732,6 +732,8 @@ class Application { onLogout: async () => this.requestLogout(), onChannelLink: (element) => this.handleProfileChannelLink(element), onAddAccount: (payload) => this.handleAddProfile(payload), + onRequestLogoutProfile: (payload) => + this.handleProfileLogoutRequest(payload), onRequestSwitchProfile: (payload) => this.handleProfileSwitchRequest(payload), onRelayOperation: (payload) => @@ -4687,35 +4689,14 @@ class Application { ); } - let refreshPromise = null; - if ( - this.lastIdentityRefreshPromise && - typeof this.lastIdentityRefreshPromise.then === "function" - ) { - refreshPromise = this.lastIdentityRefreshPromise; - } + const refreshCompleted = await this.waitForIdentityRefresh({ + reason: "profile-switch", + }); - if (refreshPromise) { - try { - await refreshPromise; - } catch (error) { - devLogger.error( - "Failed to refresh UI after switching profiles:", - error, - ); - } - } else { - try { - await this.refreshAllVideoGrids({ - reason: "profile-switch", - forceMainReload: true, - }); - } catch (error) { - devLogger.error( - "Failed to refresh video grids after switching profiles:", - error, - ); - } + if (!refreshCompleted) { + devLogger.warn( + "[Application] Fallback identity refresh was required after switching profiles.", + ); } if (this.watchHistoryTelemetry?.resetPlaybackLoggingState) { @@ -4744,6 +4725,135 @@ class Application { return result; } + async waitForIdentityRefresh({ + reason = "identity-refresh", + attempts = 6, + } = {}) { + const maxAttempts = Number.isFinite(attempts) + ? Math.max(1, Math.floor(attempts)) + : 6; + const waitForTick = () => + new Promise((resolve) => { + if (typeof queueMicrotask === "function") { + queueMicrotask(resolve); + } else if (typeof setTimeout === "function") { + setTimeout(resolve, 0); + } else { + resolve(); + } + }); + + let promise = null; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidate = this.lastIdentityRefreshPromise; + if (candidate && typeof candidate.then === "function") { + promise = candidate; + break; + } + // Yield to allow the auth login flow to schedule the refresh promise. + // eslint-disable-next-line no-await-in-loop + await waitForTick(); + } + + if (promise && typeof promise.then === "function") { + try { + await promise; + return true; + } catch (error) { + devLogger.error( + "[Application] Identity refresh promise rejected:", + error, + ); + } + } + + try { + await this.refreshAllVideoGrids({ + reason, + forceMainReload: true, + }); + } catch (error) { + devLogger.error( + "[Application] Failed to refresh video grids after waiting for identity refresh:", + error, + ); + } + + return false; + } + + async handleProfileLogoutRequest({ pubkey, entry } = {}) { + const candidatePubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim() + : typeof entry?.pubkey === "string" && entry.pubkey.trim() + ? entry.pubkey.trim() + : ""; + + if (!candidatePubkey) { + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + const normalizedTarget = + this.normalizeHexPubkey(candidatePubkey) || candidatePubkey; + if (!normalizedTarget) { + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + const activeNormalized = this.normalizeHexPubkey(getActiveProfilePubkey()); + if (activeNormalized && activeNormalized === normalizedTarget) { + const detail = await this.requestLogout(); + return { + loggedOut: true, + reason: "active-profile", + active: true, + detail, + }; + } + + let removalResult; + try { + removalResult = this.authService.removeSavedProfile(candidatePubkey); + } catch (error) { + devLogger.error( + "[Application] Failed to remove saved profile during logout request:", + error, + ); + return { loggedOut: false, reason: "remove-failed", error }; + } + + if (!removalResult?.removed) { + if (removalResult?.error) { + devLogger.warn( + "[Application] removeSavedProfile returned an error during logout request:", + removalResult.error, + ); + } + return { loggedOut: false, reason: "not-found" }; + } + + if ( + this.nwcSettingsService && + typeof this.nwcSettingsService.clearStoredNwcSettings === "function" + ) { + try { + await this.nwcSettingsService.clearStoredNwcSettings(normalizedTarget, { + silent: true, + }); + } catch (error) { + devLogger.warn( + "[Application] Failed to clear wallet settings for logged-out profile:", + error, + ); + } + } + + this.renderSavedProfiles(); + + return { loggedOut: true, removed: true }; + } + async handleProfileRelayOperation({ action, url, diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index ef3755cb..fb34a9e7 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -951,6 +951,7 @@ export class ProfileModalController { onLogout: callbacks.onLogout || noop, onChannelLink: callbacks.onChannelLink || noop, onAddAccount: callbacks.onAddAccount || noop, + onRequestLogoutProfile: callbacks.onRequestLogoutProfile || noop, onSelectPane: callbacks.onSelectPane || noop, onPaneShown: callbacks.onPaneShown || noop, onAddRelay: callbacks.onAddRelay || noop, @@ -3066,7 +3067,8 @@ export class ProfileModalController { metaSpan.className = "flex min-w-0 flex-1 flex-col gap-2"; const topLine = document.createElement("div"); - topLine.className = "flex flex-wrap items-center gap-3"; + topLine.className = + "flex flex-wrap items-center justify-between gap-3"; const providerId = this.getEntryProviderId(entry); const providerInfo = this.resolveEntryProviderMetadata(entry); @@ -3076,6 +3078,9 @@ export class ProfileModalController { providerInfo && providerInfo.badgeVariant, ); + const resolvedProviderId = + providerId || (providerInfo && providerInfo.id) || ""; + const label = document.createElement("span"); label.className = `${PROVIDER_BADGE_BASE_CLASS} ${badgeVariant}`; label.textContent = providerLabel; @@ -3087,12 +3092,27 @@ export class ProfileModalController { label.dataset.providerId = providerInfo.id; } + const actionGroup = document.createElement("div"); + actionGroup.className = "flex flex-wrap items-center gap-2"; + const action = document.createElement("span"); action.className = "text-xs font-medium text-muted"; action.setAttribute("aria-hidden", "true"); action.textContent = isSelected ? "Selected" : "Switch"; - topLine.append(label, action); + actionGroup.appendChild(action); + + const logoutButton = this.createSavedProfileLogoutButton({ + entry, + providerId: resolvedProviderId || null, + cardButton: button, + displayName: cardDisplayName, + }); + if (logoutButton) { + actionGroup.appendChild(logoutButton); + } + + topLine.append(label, actionGroup); const nameSpan = document.createElement("span"); nameSpan.className = "truncate text-sm font-semibold text-primary"; @@ -3115,11 +3135,8 @@ export class ProfileModalController { : `Switch to ${cardDisplayName}`; button.setAttribute("aria-label", ariaLabel); - const datasetProviderId = - providerId || (providerInfo && providerInfo.id) || ""; - - if (datasetProviderId) { - button.dataset.providerId = datasetProviderId; + if (resolvedProviderId) { + button.dataset.providerId = resolvedProviderId; } else { delete button.dataset.providerId; } @@ -3178,6 +3195,117 @@ export class ProfileModalController { } } + async handleSavedProfileLogout({ + entry, + providerId, + triggerButton, + cardButton, + displayName, + } = {}) { + if (!entry || typeof entry !== "object") { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "invalid-entry" }; + } + + const targetPubkey = + typeof entry.pubkey === "string" && entry.pubkey.trim() + ? entry.pubkey.trim() + : ""; + if (!targetPubkey) { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + if ( + !this.callbacks.onRequestLogoutProfile || + this.callbacks.onRequestLogoutProfile === noop + ) { + this.showError("Account logout is not available right now."); + return { loggedOut: false, reason: "logout-unavailable" }; + } + + const revertUiState = () => { + if (triggerButton instanceof HTMLElement) { + triggerButton.disabled = false; + triggerButton.removeAttribute("aria-busy"); + delete triggerButton.dataset.state; + } + + if (cardButton instanceof HTMLElement) { + cardButton.disabled = false; + cardButton.removeAttribute("aria-busy"); + delete cardButton.dataset.loading; + } + }; + + if (triggerButton instanceof HTMLElement) { + triggerButton.dataset.state = "loading"; + triggerButton.disabled = true; + triggerButton.setAttribute("aria-busy", "true"); + } + + if (cardButton instanceof HTMLElement) { + cardButton.dataset.loading = "true"; + cardButton.disabled = true; + cardButton.setAttribute("aria-busy", "true"); + } + + let result; + try { + result = await this.callbacks.onRequestLogoutProfile({ + controller: this, + pubkey: targetPubkey, + entry, + providerId: this.normalizeProviderId(providerId) || null, + }); + } catch (error) { + this.showError("Failed to logout this account. Please try again."); + result = { loggedOut: false, error, reason: "logout-error" }; + } finally { + revertUiState(); + } + + if (!result || typeof result !== "object") { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "unknown" }; + } + + if (result.loggedOut || result.removed) { + const successName = + typeof displayName === "string" && displayName.trim() + ? displayName.trim() + : null; + const message = successName + ? `${successName} logged out.` + : "Account logged out."; + + this.profileSwitcherSelectionPubkey = null; + this.showSuccess(message); + this.renderSavedProfiles(); + + return { ...result, loggedOut: true }; + } + + if (result.reason === "not-found") { + this.profileSwitcherSelectionPubkey = null; + this.showStatus("This account is no longer connected."); + this.renderSavedProfiles(); + return result; + } + + if (result.reason === "active-profile") { + return result; + } + + if (result.error) { + this.showError("Failed to logout this account. Please try again."); + return result; + } + + this.showError("Failed to logout this account. Please try again."); + return result; + } + createCompactProfileSummary({ displayName, displayNpub, @@ -3301,6 +3429,54 @@ export class ProfileModalController { return button; } + createSavedProfileLogoutButton({ + entry, + cardButton, + providerId, + displayName, + } = {}) { + if (this.callbacks.onRequestLogoutProfile === noop) { + return null; + } + + if (!entry || typeof entry !== "object" || !entry.pubkey) { + return null; + } + + const safeName = + typeof displayName === "string" && displayName.trim() + ? displayName.trim() + : "this account"; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-ghost focus-ring text-2xs"; + button.dataset.variant = "critical"; + button.dataset.role = "logout"; + button.textContent = "Logout"; + button.setAttribute("aria-label", `Log out ${safeName}`); + button.title = `Log out ${safeName}`; + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (button.dataset.state === "loading") { + return; + } + + void this.handleSavedProfileLogout({ + entry, + providerId, + triggerButton: button, + cardButton, + displayName: safeName, + }); + }); + + return button; + } + createRemoveButton({ label = "Remove", confirmMessage,