diff --git a/js/app.js b/js/app.js index aaffd597..6374f47b 100644 --- a/js/app.js +++ b/js/app.js @@ -177,7 +177,14 @@ import { createPlaybackCoordinator } from "./app/playbackCoordinator.js"; import { createAuthSessionCoordinator } from "./app/authSessionCoordinator.js"; import { createModalCoordinator } from "./app/modalCoordinator.js"; import { createModerationCoordinator } from "./app/moderationCoordinator.js"; +import { createRouterCoordinator } from "./app/routerCoordinator.js"; +import { createUiCoordinator } from "./app/uiCoordinator.js"; import { HEX64_REGEX } from "./utils/hex.js"; +import { + safeEncodeNpub, + safeDecodeNpub, + normalizeHexPubkey, +} from "./utils/nostrHelpers.js"; const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args); @@ -640,6 +647,22 @@ class Application { writable: true, configurable: true, }); + + Object.defineProperty(this, "_router", { + value: bindCoordinator(this, createRouterCoordinator({ + devLogger, + })), + writable: true, + configurable: true, + }); + + Object.defineProperty(this, "_ui", { + value: bindCoordinator(this, createUiCoordinator({ + devLogger, + })), + writable: true, + configurable: true, + }); } get modalVideo() { @@ -1266,56 +1289,14 @@ class Application { } } - goToProfile(pubkey) { - if (typeof pubkey !== "string") { - this.showError("No creator info available."); - return; - } - - let candidate = pubkey.trim(); - if (!candidate) { - this.showError("No creator info available."); - return; - } - - if (candidate.startsWith("nostr:")) { - candidate = candidate.slice("nostr:".length); - } - - const normalizedHex = this.normalizeHexPubkey(candidate); - const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null; - - if (!npub) { - devLogger.warn( - "[Application] Invalid pubkey for profile navigation:", - candidate, - ); - this.showError("Invalid creator profile."); - return; - } - - window.location.hash = `#view=channel-profile&npub=${npub}`; + goToProfile(...args) { + this._initCoordinators(); + return this._router.goToProfile(...args); } - openCreatorChannel() { - if (!this.currentVideo || !this.currentVideo.pubkey) { - this.showError("No creator info available."); - return; - } - - try { - // Encode the hex pubkey to npub - const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); - - // Close the video modal - this.hideModal(); - - // Switch to channel profile view - window.location.hash = `#view=channel-profile&npub=${npub}`; - } catch (err) { - devLogger.error("Failed to open creator channel:", err); - this.showError("Could not open channel."); - } + openCreatorChannel(...args) { + this._initCoordinators(); + return this._router.openCreatorChannel(...args); } openWalletPane() { @@ -1359,41 +1340,14 @@ class Application { } } - dispatchAuthLoadingState(detail = {}) { - if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") { - return false; - } - - try { - const payload = { ...(typeof detail === "object" && detail ? detail : {}) }; - window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload })); - return true; - } catch (error) { - devLogger.warn("[Application] Failed to dispatch auth loading state:", error); - return false; - } + dispatchAuthLoadingState(...args) { + this._initCoordinators(); + return this._ui.dispatchAuthLoadingState(...args); } - updateAuthLoadingState(partial = {}) { - const baseState = - this.authLoadingState && typeof this.authLoadingState === "object" - ? this.authLoadingState - : { profile: "idle", lists: "idle", dms: "idle" }; - const nextState = { - ...baseState, - ...(partial && typeof partial === "object" ? partial : {}), - }; - this.authLoadingState = nextState; - - const root = typeof document !== "undefined" ? document.documentElement : null; - if (root) { - root.dataset.authProfileLoading = nextState.profile || "idle"; - root.dataset.authListsLoading = nextState.lists || "idle"; - root.dataset.authDmsLoading = nextState.dms || "idle"; - } - - this.dispatchAuthLoadingState(nextState); - return nextState; + updateAuthLoadingState(...args) { + this._initCoordinators(); + return this._ui.updateAuthLoadingState(...args); } normalizeModalTrigger(...args) { @@ -1685,23 +1639,9 @@ class Application { this.closeAllMoreMenus(); } - handleProfileChannelLink(element) { - if (!(element instanceof HTMLElement)) { - return; - } - - const targetNpub = - typeof element.dataset.targetNpub === "string" - ? element.dataset.targetNpub - : ""; - if (this.profileController) { - this.profileController.hide(); - } - if (targetNpub) { - window.location.hash = `#view=channel-profile&npub=${encodeURIComponent( - targetNpub, - )}`; - } + handleProfileChannelLink(...args) { + this._initCoordinators(); + return this._router.handleProfileChannelLink(...args); } async handleLoginModalSuccess(payload = {}) { @@ -3282,36 +3222,9 @@ class Application { return this._moderation.refreshVisibleModerationUi(...args); } - updateActiveProfileUI(pubkey, profile = {}) { - if (this.profileController) { - this.profileController.handleProfileUpdated({ - pubkey, - profile, - }); - return; - } - - const picture = profile.picture || "assets/svg/default-profile.svg"; - - if (this.profileAvatar) { - this.profileAvatar.src = picture; - } - - const channelLink = document.getElementById("profileChannelLink"); - if (channelLink instanceof HTMLElement) { - const targetNpub = this.safeEncodeNpub(pubkey); - if (targetNpub) { - channelLink.href = `#view=channel-profile&npub=${targetNpub}`; - channelLink.dataset.targetNpub = targetNpub; - channelLink.classList.remove("hidden"); - } else { - channelLink.removeAttribute("href"); - if (channelLink.dataset) { - delete channelLink.dataset.targetNpub; - } - channelLink.classList.add("hidden"); - } - } + updateActiveProfileUI(...args) { + this._initCoordinators(); + return this._ui.updateActiveProfileUI(...args); } async hydrateNwcSettingsForPubkey(pubkey) { @@ -3392,226 +3305,49 @@ class Application { return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options); } - applyAuthenticatedUiState() { - if (this.loginButton) { - this.loginButton.classList.add("hidden"); - this.loginButton.setAttribute("hidden", ""); - } - - if (this.logoutButton) { - this.logoutButton.classList.remove("hidden"); - } - - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - - if (this.uploadButton) { - this.uploadButton.classList.remove("hidden"); - this.uploadButton.removeAttribute("hidden"); - } - - if (this.profileButton) { - this.profileButton.classList.remove("hidden"); - this.profileButton.removeAttribute("hidden"); - } - - const subscriptionsLink = this.resolveSubscriptionsLink(); - if (subscriptionsLink) { - subscriptionsLink.classList.remove("hidden"); - } - - const forYouLink = this.resolveForYouLink(); - if (forYouLink) { - forYouLink.classList.remove("hidden"); - } - - const exploreLink = this.resolveExploreLink(); - if (exploreLink) { - exploreLink.classList.remove("hidden"); - } + applyAuthenticatedUiState(...args) { + this._initCoordinators(); + return this._ui.applyAuthenticatedUiState(...args); } - applyLoggedOutUiState() { - if (this.loginButton) { - this.loginButton.classList.remove("hidden"); - this.loginButton.removeAttribute("hidden"); - } - - if (this.logoutButton) { - this.logoutButton.classList.add("hidden"); - } - - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - - if (this.userPubKey) { - this.userPubKey.textContent = ""; - } - - if (this.uploadButton) { - this.uploadButton.classList.add("hidden"); - this.uploadButton.setAttribute("hidden", ""); - } - - if (this.profileButton) { - this.profileButton.classList.add("hidden"); - this.profileButton.setAttribute("hidden", ""); - } - - const subscriptionsLink = this.resolveSubscriptionsLink(); - if (subscriptionsLink) { - subscriptionsLink.classList.add("hidden"); - } - - const forYouLink = this.resolveForYouLink(); - if (forYouLink) { - forYouLink.classList.add("hidden"); - } - - const exploreLink = this.resolveExploreLink(); - if (exploreLink) { - exploreLink.classList.add("hidden"); - } + applyLoggedOutUiState(...args) { + this._initCoordinators(); + return this._ui.applyLoggedOutUiState(...args); } - syncAuthUiState() { - if (this.isUserLoggedIn()) { - this.applyAuthenticatedUiState(); - } else { - this.applyLoggedOutUiState(); - } + syncAuthUiState(...args) { + this._initCoordinators(); + return this._ui.syncAuthUiState(...args); } - refreshChromeElements() { - const doc = typeof document !== "undefined" ? document : null; - const findElement = (id) => { - if (!doc || typeof doc.getElementById !== "function") { - return null; - } - const element = doc.getElementById(id); - if (!element || typeof element.addEventListener !== "function") { - return null; - } - return element; - }; - - this.loginButton = findElement("loginButton"); - this.logoutButton = findElement("logoutButton"); - this.uploadButton = findElement("uploadButton"); - this.profileButton = findElement("profileButton"); - this.closeLoginModalBtn = findElement("closeLoginModal"); - - return { - loginButton: this.loginButton, - logoutButton: this.logoutButton, - uploadButton: this.uploadButton, - profileButton: this.profileButton, - closeLoginModalButton: this.closeLoginModalBtn, - }; + refreshChromeElements(...args) { + this._initCoordinators(); + return this._ui.refreshChromeElements(...args); } - resolveSubscriptionsLink() { - if ( - this.subscriptionsLink instanceof HTMLElement && - this.subscriptionsLink.isConnected - ) { - return this.subscriptionsLink; - } - - const linkCandidate = document.getElementById("subscriptionsLink"); - if (linkCandidate instanceof HTMLElement) { - this.subscriptionsLink = linkCandidate; - return this.subscriptionsLink; - } - - this.subscriptionsLink = null; - return null; + resolveSubscriptionsLink(...args) { + this._initCoordinators(); + return this._ui.resolveSubscriptionsLink(...args); } - resolveForYouLink() { - if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) { - return this.forYouLink; - } - - const linkCandidate = document.getElementById("forYouLink"); - if (linkCandidate instanceof HTMLElement) { - this.forYouLink = linkCandidate; - return this.forYouLink; - } - - this.forYouLink = null; - return null; + resolveForYouLink(...args) { + this._initCoordinators(); + return this._ui.resolveForYouLink(...args); } - resolveExploreLink() { - if ( - this.exploreLink instanceof HTMLElement && - this.exploreLink.isConnected - ) { - return this.exploreLink; - } - - const linkCandidate = document.getElementById("exploreLink"); - if (linkCandidate instanceof HTMLElement) { - this.exploreLink = linkCandidate; - return this.exploreLink; - } - - this.exploreLink = null; - return null; + resolveExploreLink(...args) { + this._initCoordinators(); + return this._ui.resolveExploreLink(...args); } - hydrateSidebarNavigation() { - const chromeElements = this.refreshChromeElements(); - this.resolveSubscriptionsLink(); - this.resolveForYouLink(); - this.resolveExploreLink(); - - if (this.appChromeController) { - if (typeof this.appChromeController.setElements === "function") { - this.appChromeController.setElements(chromeElements); - } else { - if (this.appChromeController.elements) { - Object.assign(this.appChromeController.elements, chromeElements); - } - this.appChromeController.initialize(); - } - } - - this.syncAuthUiState(); + hydrateSidebarNavigation(...args) { + this._initCoordinators(); + return this._ui.hydrateSidebarNavigation(...args); } - maybeShowExperimentalLoginWarning(provider) { - const normalizedProvider = - typeof provider === "string" ? provider.trim().toLowerCase() : ""; - - if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") { - return; - } - - const now = Date.now(); - if ( - this.lastExperimentalWarningKey === normalizedProvider && - typeof this.lastExperimentalWarningAt === "number" && - now - this.lastExperimentalWarningAt < 2000 - ) { - return; - } - - this.lastExperimentalWarningKey = normalizedProvider; - this.lastExperimentalWarningAt = now; - - const providerLabel = - normalizedProvider === "nsec" - ? "Direct nsec or seed" - : "NIP-46 remote signer"; - - this.showStatus( - `${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`, - { showSpinner: false, autoHideMs: 5000 }, - ); + maybeShowExperimentalLoginWarning(...args) { + this._initCoordinators(); + return this._ui.maybeShowExperimentalLoginWarning(...args); } async handlePermissionPromptRequest() { @@ -4908,46 +4644,11 @@ class Application { * Simple helper to safely encode an npub. */ safeEncodeNpub(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("npub1")) { - return trimmed; - } - - try { - return window.NostrTools.nip19.npubEncode(trimmed); - } catch (err) { - return null; - } + return safeEncodeNpub(pubkey); } safeDecodeNpub(npub) { - if (typeof npub !== "string") { - return null; - } - - const trimmed = npub.trim(); - if (!trimmed) { - return null; - } - - try { - const decoded = window.NostrTools.nip19.decode(trimmed); - if (decoded.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (err) { - return null; - } - - return null; + return safeDecodeNpub(npub); } selectPreferredCreatorName(candidates = []) { @@ -5379,27 +5080,7 @@ class Application { } normalizeHexPubkey(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - if (trimmed.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(trimmed); - if (decoded && HEX64_REGEX.test(decoded)) { - return decoded.toLowerCase(); - } - } - - return null; + return normalizeHexPubkey(pubkey); } /** diff --git a/js/app/routerCoordinator.js b/js/app/routerCoordinator.js new file mode 100644 index 00000000..92c5d713 --- /dev/null +++ b/js/app/routerCoordinator.js @@ -0,0 +1,88 @@ +// js/app/routerCoordinator.js + +/** + * Routing logic for the application. + * + * All module-level dependencies are injected from the Application + * composition root rather than imported at module scope. + * + * Methods use `this` which is bound to the Application instance. + */ +export function createRouterCoordinator(deps) { + const { + devLogger, + } = deps; + + return { + goToProfile(pubkey) { + if (typeof pubkey !== "string") { + this.showError("No creator info available."); + return; + } + + let candidate = pubkey.trim(); + if (!candidate) { + this.showError("No creator info available."); + return; + } + + if (candidate.startsWith("nostr:")) { + candidate = candidate.slice("nostr:".length); + } + + const normalizedHex = this.normalizeHexPubkey(candidate); + const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null; + + if (!npub) { + devLogger.warn( + "[Application] Invalid pubkey for profile navigation:", + candidate, + ); + this.showError("Invalid creator profile."); + return; + } + + window.location.hash = `#view=channel-profile&npub=${npub}`; + }, + + openCreatorChannel() { + if (!this.currentVideo || !this.currentVideo.pubkey) { + this.showError("No creator info available."); + return; + } + + try { + // Encode the hex pubkey to npub + const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); + + // Close the video modal + this.hideModal(); + + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + devLogger.error("Failed to open creator channel:", err); + this.showError("Could not open channel."); + } + }, + + handleProfileChannelLink(element) { + if (!(element instanceof HTMLElement)) { + return; + } + + const targetNpub = + typeof element.dataset.targetNpub === "string" + ? element.dataset.targetNpub + : ""; + if (this.profileController) { + this.profileController.hide(); + } + if (targetNpub) { + window.location.hash = `#view=channel-profile&npub=${encodeURIComponent( + targetNpub, + )}`; + } + } + }; +} diff --git a/js/app/uiCoordinator.js b/js/app/uiCoordinator.js new file mode 100644 index 00000000..2679346f --- /dev/null +++ b/js/app/uiCoordinator.js @@ -0,0 +1,308 @@ +// js/app/uiCoordinator.js + +/** + * UI State and Chrome management. + * + * All module-level dependencies are injected from the Application + * composition root rather than imported at module scope. + * + * Methods use `this` which is bound to the Application instance. + */ +export function createUiCoordinator(deps) { + const { + devLogger, + } = deps; + + return { + dispatchAuthLoadingState(detail = {}) { + if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") { + return false; + } + + try { + const payload = { ...(typeof detail === "object" && detail ? detail : {}) }; + window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload })); + return true; + } catch (error) { + devLogger.warn("[Application] Failed to dispatch auth loading state:", error); + return false; + } + }, + + updateAuthLoadingState(partial = {}) { + const baseState = + this.authLoadingState && typeof this.authLoadingState === "object" + ? this.authLoadingState + : { profile: "idle", lists: "idle", dms: "idle" }; + const nextState = { + ...baseState, + ...(partial && typeof partial === "object" ? partial : {}), + }; + this.authLoadingState = nextState; + + const root = typeof document !== "undefined" ? document.documentElement : null; + if (root) { + root.dataset.authProfileLoading = nextState.profile || "idle"; + root.dataset.authListsLoading = nextState.lists || "idle"; + root.dataset.authDmsLoading = nextState.dms || "idle"; + } + + this.dispatchAuthLoadingState(nextState); + return nextState; + }, + + updateActiveProfileUI(pubkey, profile = {}) { + if (this.profileController) { + this.profileController.handleProfileUpdated({ + pubkey, + profile, + }); + return; + } + + const picture = profile.picture || "assets/svg/default-profile.svg"; + + if (this.profileAvatar) { + this.profileAvatar.src = picture; + } + + const channelLink = document.getElementById("profileChannelLink"); + if (channelLink instanceof HTMLElement) { + const targetNpub = this.safeEncodeNpub(pubkey); + if (targetNpub) { + channelLink.href = `#view=channel-profile&npub=${targetNpub}`; + channelLink.dataset.targetNpub = targetNpub; + channelLink.classList.remove("hidden"); + } else { + channelLink.removeAttribute("href"); + if (channelLink.dataset) { + delete channelLink.dataset.targetNpub; + } + channelLink.classList.add("hidden"); + } + } + }, + + applyAuthenticatedUiState() { + if (this.loginButton) { + this.loginButton.classList.add("hidden"); + this.loginButton.setAttribute("hidden", ""); + } + + if (this.logoutButton) { + this.logoutButton.classList.remove("hidden"); + } + + if (this.userStatus) { + this.userStatus.classList.add("hidden"); + } + + if (this.uploadButton) { + this.uploadButton.classList.remove("hidden"); + this.uploadButton.removeAttribute("hidden"); + } + + if (this.profileButton) { + this.profileButton.classList.remove("hidden"); + this.profileButton.removeAttribute("hidden"); + } + + const subscriptionsLink = this.resolveSubscriptionsLink(); + if (subscriptionsLink) { + subscriptionsLink.classList.remove("hidden"); + } + + const forYouLink = this.resolveForYouLink(); + if (forYouLink) { + forYouLink.classList.remove("hidden"); + } + + const exploreLink = this.resolveExploreLink(); + if (exploreLink) { + exploreLink.classList.remove("hidden"); + } + }, + + applyLoggedOutUiState() { + if (this.loginButton) { + this.loginButton.classList.remove("hidden"); + this.loginButton.removeAttribute("hidden"); + } + + if (this.logoutButton) { + this.logoutButton.classList.add("hidden"); + } + + if (this.userStatus) { + this.userStatus.classList.add("hidden"); + } + + if (this.userPubKey) { + this.userPubKey.textContent = ""; + } + + if (this.uploadButton) { + this.uploadButton.classList.add("hidden"); + this.uploadButton.setAttribute("hidden", ""); + } + + if (this.profileButton) { + this.profileButton.classList.add("hidden"); + this.profileButton.setAttribute("hidden", ""); + } + + const subscriptionsLink = this.resolveSubscriptionsLink(); + if (subscriptionsLink) { + subscriptionsLink.classList.add("hidden"); + } + + const forYouLink = this.resolveForYouLink(); + if (forYouLink) { + forYouLink.classList.add("hidden"); + } + + const exploreLink = this.resolveExploreLink(); + if (exploreLink) { + exploreLink.classList.add("hidden"); + } + }, + + syncAuthUiState() { + if (this.isUserLoggedIn()) { + this.applyAuthenticatedUiState(); + } else { + this.applyLoggedOutUiState(); + } + }, + + refreshChromeElements() { + const doc = typeof document !== "undefined" ? document : null; + const findElement = (id) => { + if (!doc || typeof doc.getElementById !== "function") { + return null; + } + const element = doc.getElementById(id); + if (!element || typeof element.addEventListener !== "function") { + return null; + } + return element; + }; + + this.loginButton = findElement("loginButton"); + this.logoutButton = findElement("logoutButton"); + this.uploadButton = findElement("uploadButton"); + this.profileButton = findElement("profileButton"); + this.closeLoginModalBtn = findElement("closeLoginModal"); + + return { + loginButton: this.loginButton, + logoutButton: this.logoutButton, + uploadButton: this.uploadButton, + profileButton: this.profileButton, + closeLoginModalButton: this.closeLoginModalBtn, + }; + }, + + resolveSubscriptionsLink() { + if ( + this.subscriptionsLink instanceof HTMLElement && + this.subscriptionsLink.isConnected + ) { + return this.subscriptionsLink; + } + + const linkCandidate = document.getElementById("subscriptionsLink"); + if (linkCandidate instanceof HTMLElement) { + this.subscriptionsLink = linkCandidate; + return this.subscriptionsLink; + } + + this.subscriptionsLink = null; + return null; + }, + + resolveForYouLink() { + if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) { + return this.forYouLink; + } + + const linkCandidate = document.getElementById("forYouLink"); + if (linkCandidate instanceof HTMLElement) { + this.forYouLink = linkCandidate; + return this.forYouLink; + } + + this.forYouLink = null; + return null; + }, + + resolveExploreLink() { + if ( + this.exploreLink instanceof HTMLElement && + this.exploreLink.isConnected + ) { + return this.exploreLink; + } + + const linkCandidate = document.getElementById("exploreLink"); + if (linkCandidate instanceof HTMLElement) { + this.exploreLink = linkCandidate; + return this.exploreLink; + } + + this.exploreLink = null; + return null; + }, + + hydrateSidebarNavigation() { + const chromeElements = this.refreshChromeElements(); + this.resolveSubscriptionsLink(); + this.resolveForYouLink(); + this.resolveExploreLink(); + + if (this.appChromeController) { + if (typeof this.appChromeController.setElements === "function") { + this.appChromeController.setElements(chromeElements); + } else { + if (this.appChromeController.elements) { + Object.assign(this.appChromeController.elements, chromeElements); + } + this.appChromeController.initialize(); + } + } + + this.syncAuthUiState(); + }, + + maybeShowExperimentalLoginWarning(provider) { + const normalizedProvider = + typeof provider === "string" ? provider.trim().toLowerCase() : ""; + + if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") { + return; + } + + const now = Date.now(); + if ( + this.lastExperimentalWarningKey === normalizedProvider && + typeof this.lastExperimentalWarningAt === "number" && + now - this.lastExperimentalWarningAt < 2000 + ) { + return; + } + + this.lastExperimentalWarningKey = normalizedProvider; + this.lastExperimentalWarningAt = now; + + const providerLabel = + normalizedProvider === "nsec" + ? "Direct nsec or seed" + : "NIP-46 remote signer"; + + this.showStatus( + `${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`, + { showSpinner: false, autoHideMs: 5000 }, + ); + } + }; +} diff --git a/js/services/authService.js b/js/services/authService.js index de969405..9ba8994c 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -29,6 +29,11 @@ import getDefaultAuthProvider, { } from "./authProviders/index.js"; import { fetchProfileMetadata } from "./profileMetadataService.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { + safeEncodeNpub, + safeDecodeNpub, + normalizeHexPubkey, +} from "../utils/nostrHelpers.js"; class SimpleEventEmitter { constructor(logger = null) { @@ -267,70 +272,15 @@ export default class AuthService { } safeEncodeNpub(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("npub1")) { - return trimmed; - } - - try { - return window?.NostrTools?.nip19?.npubEncode(trimmed) || null; - } catch (error) { - return null; - } + return safeEncodeNpub(pubkey); } safeDecodeNpub(npub) { - if (typeof npub !== "string") { - return null; - } - - const trimmed = npub.trim(); - if (!trimmed) { - return null; - } - - try { - const decoded = window?.NostrTools?.nip19?.decode(trimmed); - if (decoded?.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (error) { - return null; - } - - return null; + return safeDecodeNpub(npub); } normalizeHexPubkey(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - if (trimmed.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(trimmed); - if (decoded && HEX64_REGEX.test(decoded)) { - return decoded.toLowerCase(); - } - } - - return null; + return normalizeHexPubkey(pubkey); } async requestLogin(rawOptions = {}) { diff --git a/js/utils/nostrHelpers.js b/js/utils/nostrHelpers.js new file mode 100644 index 00000000..d4d4a301 --- /dev/null +++ b/js/utils/nostrHelpers.js @@ -0,0 +1,102 @@ +import { HEX64_REGEX } from "./hex.js"; + +/** + * Safely encode a hex pubkey to npub. + * + * @param {string} pubkey - The hex public key. + * @returns {string|null} The npub string or null if encoding failed. + */ +export function safeEncodeNpub(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("npub1")) { + return trimmed; + } + + try { + if ( + typeof window !== "undefined" && + window.NostrTools && + window.NostrTools.nip19 && + typeof window.NostrTools.nip19.npubEncode === "function" + ) { + return window.NostrTools.nip19.npubEncode(trimmed); + } + } catch (err) { + // ignore + } + + return null; +} + +/** + * Safely decode an npub to hex pubkey. + * + * @param {string} npub - The npub string. + * @returns {string|null} The hex public key or null if decoding failed. + */ +export function safeDecodeNpub(npub) { + if (typeof npub !== "string") { + return null; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + + try { + if ( + typeof window !== "undefined" && + window.NostrTools && + window.NostrTools.nip19 && + typeof window.NostrTools.nip19.decode === "function" + ) { + const decoded = window.NostrTools.nip19.decode(trimmed); + if (decoded.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } + } catch (err) { + // ignore + } + + return null; +} + +/** + * Normalize a public key to hex format, handling npub inputs. + * + * @param {string} pubkey - The public key (hex or npub). + * @returns {string|null} The normalized hex public key or null. + */ +export function normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1")) { + const decoded = safeDecodeNpub(trimmed); + if (decoded && HEX64_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + } + + return null; +}