From 44bcf9fd2f502aa32551ef8ee9a2eb53082dd264 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:57:50 -0400 Subject: [PATCH 1/2] Add logout controls to saved profiles --- js/app.js | 73 ++++++++++++ js/ui/profileModalController.js | 190 ++++++++++++++++++++++++++++++-- 2 files changed, 256 insertions(+), 7 deletions(-) diff --git a/js/app.js b/js/app.js index 5214016f..280f81b0 100644 --- a/js/app.js +++ b/js/app.js @@ -731,6 +731,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) => @@ -4740,6 +4742,77 @@ class Application { return result; } + 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 bf1f27dc..ac38d044 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -928,6 +928,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, @@ -2069,7 +2070,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); @@ -2079,6 +2081,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; @@ -2090,12 +2095,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"; @@ -2118,11 +2138,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; } @@ -2181,6 +2198,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, @@ -2304,6 +2432,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, From f80c0d0f118d5efc39ffcc441839a82a2b3321e9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:12:35 -0400 Subject: [PATCH 2/2] Wait for login hydration when switching profiles --- js/app.js | 93 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/js/app.js b/js/app.js index 280f81b0..9176d876 100644 --- a/js/app.js +++ b/js/app.js @@ -4685,35 +4685,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) { @@ -4742,6 +4721,64 @@ 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()