diff --git a/components/login-modal.html b/components/login-modal.html index c284d5ed..a61f402b 100644 --- a/components/login-modal.html +++ b/components/login-modal.html @@ -59,20 +59,8 @@
- - - + + diff --git a/js/app.js b/js/app.js index a6b4bd32..a87850c9 100644 --- a/js/app.js +++ b/js/app.js @@ -33,6 +33,7 @@ import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; import AuthService from "./services/authService.js"; +import { listAuthProviders } from "./services/authProviders/index.js"; import NwcSettingsService from "./services/nwcSettingsService.js"; import nostrService from "./services/nostrService.js"; import DiscussionCountService from "./services/discussionCountService.js"; @@ -40,6 +41,7 @@ import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import WatchHistoryController from "./ui/watchHistoryController.js"; import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js"; +import LoginModalController from "./ui/loginModalController.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js"; @@ -348,6 +350,37 @@ class Application { ) ); + this.loginModalController = null; + + const loginModalElement = document.getElementById("loginModal") || null; + const loginOptionsContainer = + loginModalElement?.querySelector("[data-login-options]") || null; + const closeLoginModalButton = + document.getElementById("closeLoginModal") || null; + + if (loginModalElement) { + try { + this.loginModalController = new LoginModalController({ + modalElement: loginModalElement, + optionsContainer: loginOptionsContainer, + closeButton: closeLoginModalButton, + providers: listAuthProviders(), + authService: this.authService, + callbacks: { + onOpen: () => setGlobalModalState("login", true), + onClose: () => setGlobalModalState("login", false), + onError: (message) => this.showError(message), + }, + }); + } catch (error) { + console.error( + "[app.js] Failed to initialize LoginModalController:", + error, + ); + this.loginModalController = null; + } + } + // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; this.profileAvatar = document.getElementById("profileAvatar") || null; @@ -910,10 +943,6 @@ class Application { this.moreMenuController.attachVideoListView(this.videoListView); } - // NEW: reference to the login modal's close button - this.closeLoginModalBtn = - document.getElementById("closeLoginModal") || null; - // Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties this.blacklistedEventIds = new Set(); for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) { @@ -1659,11 +1688,25 @@ class Application { setLoadingState(true); try { - const { pubkey } = await this.authService.requestLogin({ + const { + pubkey, + authType: loginAuthType, + providerId: selectedProviderId, + } = await this.authService.requestLogin({ allowAccountSelection: true, autoApply: false, }); + const resolvedAuthType = (() => { + if (typeof loginAuthType === "string" && loginAuthType.trim()) { + return loginAuthType.trim(); + } + if (typeof selectedProviderId === "string" && selectedProviderId.trim()) { + return selectedProviderId.trim(); + } + return "nip07"; + })(); + const normalizedPubkey = this.normalizeHexPubkey(pubkey); if (!normalizedPubkey) { throw new Error( @@ -1697,7 +1740,7 @@ class Application { npub, name, picture, - authType: "nip07", + authType: resolvedAuthType, }); persistSavedProfiles({ persistActive: false }); @@ -1847,6 +1890,10 @@ class Application { if (this.loginButton) { this.loginButton.addEventListener("click", () => { console.log("Login button clicked!"); + if (this.loginModalController) { + this.loginModalController.show(); + return; + } const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.remove("hidden"); @@ -1855,103 +1902,7 @@ class Application { }); } - // 5) Close login modal button => hide modal - if (this.closeLoginModalBtn) { - this.closeLoginModalBtn.addEventListener("click", () => { - console.log("[app.js] closeLoginModal button clicked!"); - const loginModal = document.getElementById("loginModal"); - if (loginModal) { - loginModal.classList.add("hidden"); - setGlobalModalState("login", false); - } - }); - } - - // 6) NIP-07 button inside the login modal => call the extension & login - const nip07Button = document.getElementById("loginNIP07"); - if (nip07Button) { - const originalLabel = nip07Button.textContent; - let slowExtensionTimer = null; - const slowExtensionDelayMs = 8_000; - - const clearSlowExtensionTimer = () => { - if (slowExtensionTimer) { - clearTimeout(slowExtensionTimer); - slowExtensionTimer = null; - } - }; - - const setLoadingState = (isLoading) => { - if (isLoading) { - nip07Button.disabled = true; - nip07Button.dataset.loading = "true"; - nip07Button.setAttribute("aria-busy", "true"); - nip07Button.textContent = "Connecting to NIP-07 extension..."; - - clearSlowExtensionTimer(); - slowExtensionTimer = window.setTimeout(() => { - if (nip07Button.dataset.loading === "true") { - nip07Button.textContent = "Waiting for the extension prompt…"; - } - }, slowExtensionDelayMs); - } else { - nip07Button.disabled = false; - nip07Button.dataset.loading = "false"; - nip07Button.setAttribute("aria-busy", "false"); - clearSlowExtensionTimer(); - nip07Button.textContent = originalLabel; - } - }; - - nip07Button.addEventListener("click", async () => { - if (nip07Button.dataset.loading === "true") { - return; - } - - setLoadingState(true); - console.log( - "[app.js] loginNIP07 clicked! Attempting extension login..." - ); - try { - const { pubkey, detail } = await this.authService.requestLogin(); - console.log("[NIP-07] login returned pubkey:", pubkey); - - if (pubkey) { - if ( - detail && - typeof detail === "object" && - detail.__handled !== true - ) { - try { - await this.handleAuthLogin(detail); - } catch (error) { - console.error( - "[NIP-07] handleAuthLogin fallback failed:", - error, - ); - } - } - - const loginModal = document.getElementById("loginModal"); - if (loginModal) { - loginModal.classList.add("hidden"); - setGlobalModalState("login", false); - } - } - } catch (err) { - console.error("[NIP-07 login error]", err); - const message = - err && typeof err.message === "string" && err.message.trim() - ? err.message.trim() - : "Failed to login with NIP-07. Please try again."; - this.showError(message); - } finally { - setLoadingState(false); - } - }); - } - - // 7) Cleanup on page unload + // 5) Cleanup on page unload window.addEventListener("beforeunload", () => { this.flushWatchHistory("session-end", "beforeunload").catch((error) => { if (isDevMode) { diff --git a/js/services/authProviders/nip07.js b/js/services/authProviders/nip07.js index 8fd16bbd..c30cb761 100644 --- a/js/services/authProviders/nip07.js +++ b/js/services/authProviders/nip07.js @@ -1,5 +1,12 @@ const PROVIDER_ID = "nip07"; const PROVIDER_LABEL = "NIP-07 browser extension"; +const PROVIDER_BUTTON_CLASS = + "w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"; +const PROVIDER_BUTTON_LABEL = "Login with Extension (NIP-07)"; +const PROVIDER_LOADING_LABEL = "Connecting to NIP-07 extension..."; +const PROVIDER_SLOW_HINT = "Waiting for the extension prompt…"; +const PROVIDER_ERROR_MESSAGE = + "Failed to login with NIP-07. Please try again."; async function login({ nostrClient, options } = {}) { if (!nostrClient || typeof nostrClient.login !== "function") { @@ -33,4 +40,11 @@ export default Object.freeze({ id: PROVIDER_ID, label: PROVIDER_LABEL, login, + ui: Object.freeze({ + buttonClass: PROVIDER_BUTTON_CLASS, + buttonLabel: PROVIDER_BUTTON_LABEL, + loadingLabel: PROVIDER_LOADING_LABEL, + slowHint: PROVIDER_SLOW_HINT, + errorMessage: PROVIDER_ERROR_MESSAGE, + }), }); diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js new file mode 100644 index 00000000..ca3c4818 --- /dev/null +++ b/js/ui/loginModalController.js @@ -0,0 +1,350 @@ +const DEFAULT_SLOW_EXTENSION_DELAY_MS = 8000; + +const noop = () => {}; + +function isElement(value) { + return value instanceof HTMLElement; +} + +function toArray(value) { + return Array.isArray(value) ? value.slice() : []; +} + +function getTimerHost() { + if (typeof window !== "undefined" && window && window.setTimeout) { + return window; + } + if (typeof globalThis !== "undefined" && globalThis.setTimeout) { + return globalThis; + } + return { + setTimeout: () => null, + clearTimeout: () => {}, + }; +} + +export default class LoginModalController { + constructor({ + modalElement, + optionsContainer, + closeButton, + providers = [], + authService = null, + callbacks = {}, + slowExtensionDelayMs = DEFAULT_SLOW_EXTENSION_DELAY_MS, + } = {}) { + this.modalElement = isElement(modalElement) ? modalElement : null; + this.optionsContainer = isElement(optionsContainer) + ? optionsContainer + : this.modalElement?.querySelector("[data-login-options]") || null; + this.closeButton = isElement(closeButton) ? closeButton : null; + this.authService = authService && typeof authService === "object" + ? authService + : null; + this.callbacks = { + onOpen: noop, + onClose: noop, + onSuccess: noop, + onError: noop, + ...(callbacks && typeof callbacks === "object" ? callbacks : {}), + }; + this.providers = toArray(providers); + this.slowExtensionDelayMs = + Number.isFinite(slowExtensionDelayMs) && slowExtensionDelayMs >= 0 + ? slowExtensionDelayMs + : DEFAULT_SLOW_EXTENSION_DELAY_MS; + this.providerStates = new Map(); + this.timerHost = getTimerHost(); + + this.boundCloseHandler = () => this.hide(); + + if (this.closeButton) { + this.closeButton.addEventListener("click", this.boundCloseHandler); + } + + this.syncAriaHidden(); + this.render(); + } + + destroy() { + if (this.closeButton && this.boundCloseHandler) { + this.closeButton.removeEventListener("click", this.boundCloseHandler); + } + + for (const state of this.providerStates.values()) { + this.teardownProviderState(state); + } + this.providerStates.clear(); + } + + syncAriaHidden() { + if (!this.modalElement) { + return; + } + const isHidden = this.modalElement.classList.contains("hidden"); + this.modalElement.setAttribute("aria-hidden", isHidden ? "true" : "false"); + } + + setProviders(providers) { + this.providers = toArray(providers); + this.render(); + } + + render() { + if (!this.optionsContainer) { + return; + } + + for (const state of this.providerStates.values()) { + this.teardownProviderState(state); + } + this.providerStates.clear(); + + this.optionsContainer.innerHTML = ""; + + if (!this.providers.length) { + const fallbackMessage = document.createElement("p"); + fallbackMessage.className = "text-sm text-gray-400"; + fallbackMessage.textContent = "No login options are available right now."; + this.optionsContainer.appendChild(fallbackMessage); + return; + } + + const fragment = document.createDocumentFragment(); + for (const provider of this.providers) { + const button = this.createProviderButton(provider); + if (button) { + fragment.appendChild(button); + } + } + this.optionsContainer.appendChild(fragment); + } + + createProviderButton(provider) { + if (!provider || typeof provider.id !== "string") { + return null; + } + + const providerId = provider.id.trim(); + if (!providerId) { + return null; + } + + const ui = provider.ui && typeof provider.ui === "object" ? provider.ui : {}; + + const button = document.createElement("button"); + button.type = "button"; + button.dataset.providerId = providerId; + button.className = + typeof ui.buttonClass === "string" && ui.buttonClass.trim() + ? ui.buttonClass + : "w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"; + + const defaultLabel = + typeof ui.buttonLabel === "string" && ui.buttonLabel.trim() + ? ui.buttonLabel.trim() + : provider.label || providerId; + button.textContent = defaultLabel; + button.dataset.loading = "false"; + button.setAttribute("aria-busy", "false"); + + const disabledLabel = + typeof ui.disabledLabel === "string" && ui.disabledLabel.trim() + ? ui.disabledLabel.trim() + : ""; + const isDisabled = ui.disabled === true; + + if (isDisabled) { + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + if (disabledLabel) { + button.textContent = disabledLabel; + } + } + + const state = { + provider, + button, + defaultLabel: defaultLabel, + disabledLabel, + isDisabled, + loadingLabel: + typeof ui.loadingLabel === "string" && ui.loadingLabel.trim() + ? ui.loadingLabel.trim() + : `Connecting to ${provider.label || providerId}...`, + slowHint: + typeof ui.slowHint === "string" && ui.slowHint.trim() + ? ui.slowHint.trim() + : "", + slowTimer: null, + clickHandler: null, + }; + + if (!isDisabled) { + state.clickHandler = (event) => { + event.preventDefault(); + this.handleProviderClick(providerId); + }; + button.addEventListener("click", state.clickHandler); + } + + this.providerStates.set(providerId, state); + return button; + } + + teardownProviderState(state) { + if (!state) { + return; + } + if (state.clickHandler && state.button) { + state.button.removeEventListener("click", state.clickHandler); + } + this.clearSlowTimer(state); + } + + invokeCallback(name, ...args) { + const callback = this.callbacks?.[name]; + if (typeof callback !== "function") { + return; + } + try { + callback(...args); + } catch (error) { + console.error(`[LoginModalController] ${name} callback threw:`, error); + } + } + + clearSlowTimer(state) { + if (!state || !state.slowTimer) { + return; + } + this.timerHost.clearTimeout(state.slowTimer); + state.slowTimer = null; + } + + setLoadingState(providerId, isLoading) { + const state = this.providerStates.get(providerId); + if (!state || !state.button) { + return; + } + + const { button } = state; + + if (isLoading) { + button.disabled = true; + button.dataset.loading = "true"; + button.setAttribute("aria-busy", "true"); + button.setAttribute("aria-disabled", "true"); + button.textContent = state.loadingLabel; + this.clearSlowTimer(state); + if (state.slowHint && this.slowExtensionDelayMs > 0) { + state.slowTimer = this.timerHost.setTimeout(() => { + if (button.dataset.loading === "true") { + button.textContent = state.slowHint; + } + }, this.slowExtensionDelayMs); + } + return; + } + + button.dataset.loading = "false"; + button.setAttribute("aria-busy", "false"); + this.clearSlowTimer(state); + + if (state.isDisabled) { + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + button.textContent = state.disabledLabel || state.defaultLabel; + } else { + button.disabled = false; + button.removeAttribute("aria-disabled"); + button.textContent = state.defaultLabel; + } + } + + resetLoadingStates() { + for (const providerId of this.providerStates.keys()) { + this.setLoadingState(providerId, false); + } + } + + describeError(error, provider) { + if (error && typeof error.message === "string" && error.message.trim()) { + return error.message.trim(); + } + + const ui = provider && typeof provider.ui === "object" ? provider.ui : {}; + + if (ui && typeof ui.errorMessage === "string" && ui.errorMessage.trim()) { + return ui.errorMessage.trim(); + } + + if (provider && typeof provider.label === "string" && provider.label) { + return `Failed to login with ${provider.label}. Please try again.`; + } + + return "Failed to login. Please try again."; + } + + async handleProviderClick(providerId) { + const provider = this.providers.find( + (entry) => entry && entry.id === providerId, + ); + + if (!provider) { + return; + } + + const state = this.providerStates.get(providerId); + if (!state || state.button.dataset.loading === "true") { + return; + } + + if (!this.authService || typeof this.authService.requestLogin !== "function") { + const message = this.describeError( + new Error("Login is not available."), + provider, + ); + this.invokeCallback("onError", message, { provider, error: null }); + return; + } + + this.setLoadingState(providerId, true); + + try { + const result = await this.authService.requestLogin({ providerId }); + this.hide(); + this.invokeCallback("onSuccess", result, { provider }); + } catch (error) { + console.error( + `[LoginModalController] Failed to login with provider "${providerId}":`, + error, + ); + const message = this.describeError(error, provider); + this.invokeCallback("onError", message, { provider, error }); + } finally { + this.setLoadingState(providerId, false); + } + } + + show() { + if (!this.modalElement) { + return; + } + + this.modalElement.classList.remove("hidden"); + this.syncAriaHidden(); + this.invokeCallback("onOpen"); + } + + hide() { + if (!this.modalElement) { + return; + } + + this.modalElement.classList.add("hidden"); + this.resetLoadingStates(); + this.syncAriaHidden(); + this.invokeCallback("onClose"); + } +}