diff --git a/js/app.js b/js/app.js index 087168e2..2ba99973 100644 --- a/js/app.js +++ b/js/app.js @@ -22,6 +22,7 @@ import { relayManager } from "./relayManager.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; +import AuthService from "./services/authService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; @@ -57,12 +58,6 @@ import { setActiveProfilePubkey as setStoredActiveProfilePubkey, setSavedProfiles, persistSavedProfiles, - loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage, - syncSavedProfileFromCache as syncSavedProfileFromCacheState, - loadProfileCacheFromStorage as hydrateProfileCacheFromStorage, - persistProfileCacheToStorage as persistProfileCacheState, - getProfileCacheEntry as getCachedProfileEntry, - setProfileCacheEntry as setCachedProfileEntry, getProfileCacheMap, getCachedUrlHealth as readCachedUrlHealth, storeUrlHealth as persistUrlHealth, @@ -393,6 +388,18 @@ class bitvidApp { }); this.activePlaybackSession = null; + this.authService = new AuthService({ + nostrClient, + userBlocks, + relayManager, + logger: (message, ...args) => this.log(message, ...args), + }); + this.authService.on("auth:login", (detail) => this.handleAuthLogin(detail)); + this.authService.on("auth:logout", (detail) => this.handleAuthLogout(detail)); + this.authService.on("profile:updated", (detail) => + this.handleProfileUpdated(detail) + ); + // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; this.profileAvatar = document.getElementById("profileAvatar") || null; @@ -671,15 +678,15 @@ class bitvidApp { } loadSavedProfilesFromStorage() { - const { profiles } = hydrateSavedProfilesFromStorage(); - if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) { - setStoredActiveProfilePubkey(profiles[0].pubkey, { persist: true }); - } + const result = this.authService.loadSavedProfilesFromStorage(); this.renderSavedProfiles(); + return result; } syncSavedProfileFromCache(pubkey, { persist = false } = {}) { - const updated = syncSavedProfileFromCacheState(pubkey, { persist }); + const updated = this.authService.syncSavedProfileFromCache(pubkey, { + persist, + }); if (updated) { this.renderSavedProfiles(); } @@ -687,31 +694,19 @@ class bitvidApp { } loadProfileCacheFromStorage() { - hydrateProfileCacheFromStorage(); + this.authService.loadProfileCacheFromStorage(); } persistProfileCacheToStorage() { - persistProfileCacheState(); + this.authService.persistProfileCache(); } getProfileCacheEntry(pubkey) { - return getCachedProfileEntry(pubkey); + return this.authService.getProfileCacheEntry(pubkey); } setProfileCacheEntry(pubkey, profile) { - const entry = setCachedProfileEntry(pubkey, profile); - if (!entry) { - return null; - } - - const normalizedPubkey = this.normalizeHexPubkey(pubkey); - const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, { - persist: true, - }); - if (!didUpdateSavedProfile) { - this.renderSavedProfiles(); - } - return entry; + return this.authService.setProfileCacheEntry(pubkey, profile); } persistActiveProfileSelection(pubkey, { persist = true } = {}) { @@ -754,7 +749,7 @@ class bitvidApp { // 3) For each author, fetchAndRenderProfile with forceRefresh = true for (const authorPubkey of uniqueAuthors) { - this.fetchAndRenderProfile(authorPubkey, true); + this.authService.fetchAndRenderProfile(authorPubkey, true); } } @@ -767,8 +762,8 @@ class bitvidApp { }); } - this.loadProfileCacheFromStorage(); - this.loadSavedProfilesFromStorage(); + this.authService.hydrateFromStorage(); + this.renderSavedProfiles(); // 1. Initialize the video modal (components/video-modal.html) await this.initModal(); @@ -821,7 +816,7 @@ class bitvidApp { if (savedPubKey) { // Auto-login if a pubkey was saved try { - await this.login(savedPubKey, { persistActive: false }); + await this.authService.login(savedPubKey, { persistActive: false }); } catch (error) { console.error("Auto-login failed:", error); } @@ -3364,8 +3359,13 @@ class bitvidApp { } if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) { this.profileLogoutBtn.dataset.bound = "true"; - this.profileLogoutBtn.addEventListener("click", () => { - this.logout(); + this.profileLogoutBtn.addEventListener("click", async () => { + try { + await this.authService.logout(); + } catch (error) { + console.error("Logout failed:", error); + this.showError("Failed to logout. Please try again."); + } this.hideProfileModal(); }); } @@ -4251,8 +4251,9 @@ class bitvidApp { setLoadingState(true); try { - const pubkey = await nostrClient.login({ + const { pubkey } = await this.authService.requestLogin({ allowAccountSelection: true, + autoApply: false, }); const normalizedPubkey = this.normalizeHexPubkey(pubkey); @@ -4275,7 +4276,7 @@ class bitvidApp { let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile; if (!profileMeta) { - await this.loadOwnProfile(normalizedPubkey); + await this.authService.loadOwnProfile(normalizedPubkey); profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile; } @@ -5030,8 +5031,13 @@ class bitvidApp { setupEventListeners() { // 1) Logout button if (this.logoutButton) { - this.logoutButton.addEventListener("click", () => { - this.logout(); + this.logoutButton.addEventListener("click", async () => { + try { + await this.authService.logout(); + } catch (error) { + console.error("Logout failed:", error); + this.showError("Failed to logout. Please try again."); + } }); } @@ -5101,15 +5107,15 @@ class bitvidApp { "[app.js] loginNIP07 clicked! Attempting extension login..." ); try { - const pubkey = await nostrClient.login(); // call the extension + const { pubkey } = await this.authService.requestLogin(); console.log("[NIP-07] login returned pubkey:", pubkey); - await this.login(pubkey, { persistActive: true }); - // Hide the login modal - const loginModal = document.getElementById("loginModal"); - if (loginModal) { - loginModal.classList.add("hidden"); - setGlobalModalState("login", false); + if (pubkey) { + const loginModal = document.getElementById("loginModal"); + if (loginModal) { + loginModal.classList.add("hidden"); + setGlobalModalState("login", false); + } } } catch (err) { console.error("[NIP-07 login error]", err); @@ -5244,87 +5250,11 @@ class bitvidApp { * Attempt to load the user's own profile from Nostr (kind:0). */ async loadOwnProfile(pubkey) { - try { - const events = await nostrClient.pool.list(nostrClient.relays, [ - { kinds: [0], authors: [pubkey], limit: 1 }, - ]); - let displayName = "User"; - let picture = "assets/svg/default-profile.svg"; - - if (events.length && events[0].content) { - const data = JSON.parse(events[0].content); - displayName = data.name || data.display_name || "User"; - picture = data.picture || "assets/svg/default-profile.svg"; - } - - this.setProfileCacheEntry(pubkey, { - name: displayName, - picture, - }); - - // If you have a top-bar avatar (profileAvatar) - if (this.profileAvatar) { - this.profileAvatar.src = picture; - } - // If you want to show name somewhere - if (this.profileModalName) { - this.profileModalName.textContent = displayName; - } - if (this.profileModalAvatar) { - this.profileModalAvatar.src = picture; - } - if (this.profileModalNpub) { - const encoded = this.safeEncodeNpub(pubkey); - this.profileModalNpub.textContent = encoded - ? truncateMiddle(encoded, 48) - : "Not signed in"; - } - if (this.profileChannelLink) { - const targetNpub = this.safeEncodeNpub(pubkey); - if (targetNpub) { - this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`; - this.profileChannelLink.dataset.targetNpub = targetNpub; - this.profileChannelLink.classList.remove("hidden"); - } else { - this.profileChannelLink.removeAttribute("href"); - delete this.profileChannelLink.dataset.targetNpub; - this.profileChannelLink.classList.add("hidden"); - } - } - } catch (error) { - console.error("loadOwnProfile error:", error); - } + return this.authService.loadOwnProfile(pubkey); } async fetchAndRenderProfile(pubkey, forceRefresh = false) { - const cacheEntry = this.getProfileCacheEntry(pubkey); - if (cacheEntry) { - this.updateProfileInDOM(pubkey, cacheEntry.profile); - if (!forceRefresh) { - return; - } - } - - // 2) Otherwise, fetch from Nostr - try { - const userEvents = await nostrClient.pool.list(nostrClient.relays, [ - { kinds: [0], authors: [pubkey], limit: 1 }, - ]); - if (userEvents.length > 0 && userEvents[0].content) { - const data = JSON.parse(userEvents[0].content); - const profile = { - name: data.name || data.display_name || "Unknown", - picture: data.picture || "assets/svg/default-profile.svg", - }; - - // Cache it - this.setProfileCacheEntry(pubkey, profile); - // Update DOM - this.updateProfileInDOM(pubkey, profile); - } - } catch (err) { - console.error("Profile fetch error:", err); - } + return this.authService.fetchAndRenderProfile(pubkey, forceRefresh); } async batchFetchProfiles(authorSet) { @@ -5628,39 +5558,41 @@ class bitvidApp { const privEl = document.getElementById("uploadIsPrivate"); const commentsEl = document.getElementById("uploadEnableComments"); - const title = titleEl?.value.trim() || ""; - const url = urlEl?.value.trim() || ""; - const magnet = magnetEl?.value.trim() || ""; - const ws = wsEl?.value.trim() || ""; - const xs = xsEl?.value.trim() || ""; - const thumbnail = thumbEl?.value.trim() || ""; - const description = descEl?.value.trim() || ""; - const enableComments = commentsEl ? commentsEl.checked : true; - const payload = { - title, - url, - magnet, - thumbnail, - description, - ws, - xs, - enableComments, + title: titleEl?.value.trim() || "", + url: urlEl?.value.trim() || "", + magnet: magnetEl?.value.trim() || "", + ws: wsEl?.value.trim() || "", + xs: xsEl?.value.trim() || "", + thumbnail: thumbEl?.value.trim() || "", + description: descEl?.value.trim() || "", + enableComments: commentsEl ? commentsEl.checked : true, }; - await this.publishVideoNote(payload, { - onSuccess: () => { - if (titleEl) titleEl.value = ""; - if (urlEl) urlEl.value = ""; - if (magnetEl) magnetEl.value = ""; - if (wsEl) wsEl.value = ""; - if (xsEl) xsEl.value = ""; - if (thumbEl) thumbEl.value = ""; - if (descEl) descEl.value = ""; - if (privEl) privEl.checked = false; - if (commentsEl) commentsEl.checked = true; - }, - }); + try { + await this.authService.handleUploadSubmit(payload, { + publish: (data) => + this.publishVideoNote(data, { + onSuccess: () => { + if (titleEl) titleEl.value = ""; + if (urlEl) urlEl.value = ""; + if (magnetEl) magnetEl.value = ""; + if (wsEl) wsEl.value = ""; + if (xsEl) xsEl.value = ""; + if (thumbEl) thumbEl.value = ""; + if (descEl) descEl.value = ""; + if (privEl) privEl.checked = false; + if (commentsEl) commentsEl.checked = true; + }, + }), + }); + } catch (error) { + const message = + error && typeof error.message === "string" && error.message.trim() + ? error.message.trim() + : "Login required to publish videos."; + this.showError(message); + } } /** @@ -5669,60 +5601,21 @@ class bitvidApp { * accounts without touching cached avatars. */ removeSavedProfile(pubkey) { - const normalized = this.normalizeHexPubkey(pubkey); - const target = normalized || (typeof pubkey === "string" ? pubkey.trim() : ""); - if (!target) { - return; + const { removed } = this.authService.removeSavedProfile(pubkey); + if (removed) { + this.renderSavedProfiles(); } - - const nextProfiles = this.savedProfiles.filter( - (entry) => entry.pubkey !== target - ); - if (nextProfiles.length === this.savedProfiles.length) { - return; - } - - this.savedProfiles = nextProfiles; - - if (this.activeProfilePubkey === target) { - this.activeProfilePubkey = null; - } - - persistSavedProfiles(); - this.renderSavedProfiles(); } async switchProfile(pubkey) { - const normalizedTarget = this.normalizeHexPubkey(pubkey); - if (!normalizedTarget) { - this.showError("Unable to switch profiles: invalid account."); - return; - } - - const normalizedActive = this.normalizeHexPubkey(this.activeProfilePubkey); - if (normalizedActive && normalizedActive === normalizedTarget) { - this.hideProfileModal(); - return; - } - try { - await nostrClient.login({ - allowAccountSelection: true, - expectPubkey: normalizedTarget, - }); + const result = await this.authService.switchProfile(pubkey); + if (!result?.switched) { + this.hideProfileModal(); + return; + } } catch (error) { - const message = - error && typeof error.message === "string" && error.message.trim() - ? error.message.trim() - : "Profile switch was cancelled."; - this.showError(message); - return; - } - - try { - await this.login(normalizedTarget, { persistActive: true }); - } catch (error) { - console.error("Failed to finalize profile switch:", error); + console.error("Failed to switch profiles:", error); const message = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() @@ -5732,42 +5625,58 @@ class bitvidApp { } this.profileSwitcherSelectionPubkey = null; - - let reordered = false; - const currentIndex = this.savedProfiles.findIndex( - (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedTarget - ); - if (currentIndex > 0) { - const [moved] = this.savedProfiles.splice(currentIndex, 1); - this.savedProfiles.unshift(moved); - reordered = true; - } - - if (reordered) { - persistSavedProfiles({ persistActive: true }); - } - this.renderSavedProfiles(); this.hideProfileModal(); } - async applyPostLoginState() { - await this.refreshAdminPaneState(); + updateActiveProfileUI(pubkey, profile = {}) { + const displayName = profile.name || "User"; + const picture = profile.picture || "assets/svg/default-profile.svg"; + + if (this.profileAvatar) { + this.profileAvatar.src = picture; + } + if (this.profileModalName) { + this.profileModalName.textContent = displayName; + } + if (this.profileModalAvatar) { + this.profileModalAvatar.src = picture; + } + if (this.profileModalNpub) { + const encoded = this.safeEncodeNpub(pubkey); + this.profileModalNpub.textContent = encoded + ? truncateMiddle(encoded, 48) + : "Not signed in"; + } + + if (this.profileChannelLink) { + const targetNpub = this.safeEncodeNpub(pubkey); + if (targetNpub) { + this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`; + this.profileChannelLink.dataset.targetNpub = targetNpub; + this.profileChannelLink.classList.remove("hidden"); + } else { + this.profileChannelLink.removeAttribute("href"); + delete this.profileChannelLink.dataset.targetNpub; + this.profileChannelLink.classList.add("hidden"); + } + } + } + + async handleAuthLogin(detail = {}) { + if (detail?.identityChanged) { + this.resetViewLoggingState(); + } + + this.renderSavedProfiles(); try { - await userBlocks.loadBlocks(this.pubkey); + await this.refreshAdminPaneState(); } catch (error) { - console.warn("Failed to load personal block list:", error); + console.warn("Failed to refresh admin pane after login:", error); } this.populateBlockedList(); - - try { - await relayManager.loadRelayList(this.pubkey); - } catch (error) { - console.warn("Failed to load relay list:", error); - } - this.populateProfileRelays(); if (this.loginButton) { @@ -5800,218 +5709,93 @@ class bitvidApp { this.subscriptionsLink.classList.remove("hidden"); } - if (this.pubkey) { - this.loadOwnProfile(this.pubkey); + const activePubkey = detail?.pubkey || this.pubkey; + if (activePubkey && detail?.postLogin?.profile) { + this.updateActiveProfileUI(activePubkey, detail.postLogin.profile); } await this.loadVideos(); - this.forceRefreshAllProfiles(); - await this.refreshCloudflareBucketPreview(); } - /** - * Called upon successful login. - */ - async login(pubkey, options = {}) { - console.log("[app.js] login() called with pubkey =", pubkey); - - let normalizedOptions = options; - if (typeof normalizedOptions === "boolean") { - normalizedOptions = { persistActive: normalizedOptions }; - } else if (!normalizedOptions || typeof normalizedOptions !== "object") { - normalizedOptions = {}; - } - - const { persistActive = true } = normalizedOptions; - - const previousActivePubkey = - this.normalizeHexPubkey(this.pubkey) || - (typeof this.pubkey === "string" ? this.pubkey.trim() : ""); - - const normalizedPubkey = this.normalizeHexPubkey(pubkey); - const nextActivePubkey = - normalizedPubkey || (typeof pubkey === "string" ? pubkey.trim() : ""); - - const identityChanged = previousActivePubkey !== nextActivePubkey; - if (identityChanged) { - this.resetViewLoggingState(); - } - - const trimmedPubkey = - typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; - - if (normalizedPubkey) { - this.pubkey = normalizedPubkey; - if (nostrClient && typeof nostrClient === "object") { - nostrClient.pubkey = normalizedPubkey; - } - } else { - this.pubkey = pubkey; - if (nostrClient && typeof nostrClient === "object") { - nostrClient.pubkey = trimmedPubkey ? trimmedPubkey.toLowerCase() : ""; - } - } - this.currentUserNpub = this.safeEncodeNpub(this.pubkey); - - if ( - identityChanged && - typeof window !== "undefined" && - typeof window.dispatchEvent === "function" - ) { - window.dispatchEvent( - new CustomEvent("bitvid:auth-changed", { - detail: { - pubkey: nextActivePubkey || null, - previousPubkey: previousActivePubkey || null, - }, - }) - ); - } - - let savedProfilesMutated = false; - if (normalizedPubkey) { - const cacheEntry = this.profileCache.get(normalizedPubkey); - const cachedProfile = cacheEntry?.profile || {}; - const existingIndex = this.savedProfiles.findIndex( - (candidate) => candidate.pubkey === normalizedPubkey - ); - const existingEntry = - existingIndex >= 0 ? this.savedProfiles[existingIndex] : null; - const npub = this.safeEncodeNpub(normalizedPubkey); - const nextEntry = { - pubkey: normalizedPubkey, - npub: npub || existingEntry?.npub || null, - name: cachedProfile.name || existingEntry?.name || "", - picture: cachedProfile.picture || existingEntry?.picture || "", - authType: "nip07", - }; - - if (existingIndex >= 0) { - const currentEntry = this.savedProfiles[existingIndex]; - const changed = - currentEntry.npub !== nextEntry.npub || - currentEntry.name !== nextEntry.name || - currentEntry.picture !== nextEntry.picture || - currentEntry.authType !== nextEntry.authType; - if (changed) { - this.savedProfiles[existingIndex] = nextEntry; - savedProfilesMutated = true; - } - } else { - this.savedProfiles.push(nextEntry); - savedProfilesMutated = true; - } - - this.persistActiveProfileSelection(normalizedPubkey, { - persist: persistActive, - }); - - if (!persistActive && savedProfilesMutated) { - persistSavedProfiles({ persistActive: false }); - } - } else { - if (persistActive) { - console.warn( - "[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update." - ); - } - } - - if (!normalizedPubkey && savedProfilesMutated) { - persistSavedProfiles({ persistActive: false }); - } - - await this.applyPostLoginState(); - } - - /** - * Logout logic - */ - async logout() { - nostrClient.logout(); - const previousPubkey = - this.normalizeHexPubkey(this.pubkey) || - (typeof this.pubkey === "string" ? this.pubkey.trim() : ""); + async handleAuthLogout(detail = {}) { this.resetViewLoggingState(); - this.pubkey = null; - this.currentUserNpub = null; - if ( - previousPubkey && - typeof window !== "undefined" && - typeof window.dispatchEvent === "function" - ) { - window.dispatchEvent( - new CustomEvent("bitvid:auth-changed", { - detail: { - pubkey: null, - previousPubkey, - }, - }) - ); - } + this.renderSavedProfiles(); - this.persistActiveProfileSelection(null, { persist: true }); - - userBlocks.reset(); - this.populateBlockedList(); - - relayManager.reset(); - this.populateProfileRelays(); - - // Show the login button again if (this.loginButton) { this.loginButton.classList.remove("hidden"); this.loginButton.removeAttribute("hidden"); this.loginButton.style.display = ""; } - // Hide logout or userStatus if (this.logoutButton) { this.logoutButton.classList.add("hidden"); } + if (this.userStatus) { this.userStatus.classList.add("hidden"); } + if (this.userPubKey) { this.userPubKey.textContent = ""; } - // Hide upload & profile if (this.uploadButton) { this.uploadButton.classList.add("hidden"); this.uploadButton.setAttribute("hidden", ""); this.uploadButton.style.display = "none"; } + if (this.profileButton) { this.profileButton.classList.add("hidden"); this.profileButton.setAttribute("hidden", ""); this.profileButton.style.display = "none"; } + if (this.profileChannelLink) { this.profileChannelLink.classList.add("hidden"); this.profileChannelLink.removeAttribute("href"); delete this.profileChannelLink.dataset.targetNpub; } - // Hide the Subscriptions link if (this.subscriptionsLink) { this.subscriptionsLink.classList.add("hidden"); } - await this.refreshAdminPaneState(); + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.warn("Failed to refresh admin pane after logout:", error); + } + + this.populateBlockedList(); + this.populateProfileRelays(); - // Refresh the video list so user sees only public videos again await this.loadVideos(); - - // Force a fresh fetch of all profile pictures/names (public ones in this case) this.forceRefreshAllProfiles(); - await this.refreshCloudflareBucketPreview(); } + handleProfileUpdated(detail = {}) { + if (Array.isArray(detail?.savedProfiles)) { + this.renderSavedProfiles(); + } + + const normalizedPubkey = detail?.pubkey + ? this.normalizeHexPubkey(detail.pubkey) + : null; + const profile = detail?.profile; + + if (normalizedPubkey && profile) { + this.updateProfileInDOM(normalizedPubkey, profile); + if (this.normalizeHexPubkey(this.pubkey) === normalizedPubkey) { + this.updateActiveProfileUI(normalizedPubkey, profile); + } + } + } + /** * Cleanup resources on unload or modal close. */ diff --git a/js/services/authService.js b/js/services/authService.js new file mode 100644 index 00000000..37589ec1 --- /dev/null +++ b/js/services/authService.js @@ -0,0 +1,652 @@ +// js/services/authService.js + +import { + setPubkey, + setCurrentUserNpub, + getPubkey, + getCurrentUserNpub, +} from "../state/appState.js"; +import { + getSavedProfiles, + getActiveProfilePubkey, + setActiveProfilePubkey, + mutateSavedProfiles, + persistSavedProfiles, + loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage, + syncSavedProfileFromCache as syncSavedProfileFromCacheState, + loadProfileCacheFromStorage as hydrateProfileCacheFromStorage, + persistProfileCacheToStorage as persistProfileCacheState, + getProfileCacheEntry as getCachedProfileEntry, + setProfileCacheEntry as setCachedProfileEntry, +} from "../state/cache.js"; + +class SimpleEventEmitter { + constructor(logger = null) { + this.logger = typeof logger === "function" ? logger : null; + this.listeners = new Map(); + } + + on(eventName, handler) { + if (typeof handler !== "function") { + return () => {}; + } + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + const handlers = this.listeners.get(eventName); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, detail) { + const handlers = this.listeners.get(eventName); + if (!handlers || !handlers.size) { + return; + } + + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch (error) { + if (this.logger) { + this.logger(`AuthService listener for "${eventName}" threw`, error); + } + } + } + } +} + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const FALLBACK_PROFILE = { + name: "Unknown", + picture: "assets/svg/default-profile.svg", +}; + +export default class AuthService { + constructor({ nostrClient, userBlocks, relayManager, logger } = {}) { + this.nostrClient = nostrClient || null; + this.userBlocks = userBlocks || null; + this.relayManager = relayManager || null; + + if (typeof logger === "function") { + this.logger = logger; + } else if (logger && typeof logger.log === "function") { + this.logger = (...args) => logger.log(...args); + } else { + this.logger = () => {}; + } + + this.emitter = new SimpleEventEmitter((message, error) => { + try { + this.logger(message, error); + } catch (logError) { + console.warn("[AuthService] logger threw", logError); + } + }); + } + + log(...args) { + try { + this.logger(...args); + } catch (error) { + console.warn("[AuthService] logger threw", error); + } + } + + on(eventName, handler) { + return this.emitter.on(eventName, handler); + } + + emit(eventName, detail) { + this.emitter.emit(eventName, detail); + } + + hydrateFromStorage() { + hydrateProfileCacheFromStorage(); + const { profiles } = hydrateSavedProfilesFromStorage(); + if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) { + const first = profiles[0]?.pubkey || null; + if (first) { + setActiveProfilePubkey(first, { persist: true }); + } + } + this.emitProfileList("hydrate"); + } + + persistProfileCache() { + persistProfileCacheState(); + } + + getProfileCacheEntry(pubkey) { + return getCachedProfileEntry(pubkey); + } + + setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) { + const entry = setCachedProfileEntry(pubkey, profile, { persist }); + if (entry) { + const normalized = this.normalizeHexPubkey(pubkey); + if (normalized) { + const updated = syncSavedProfileFromCacheState(normalized, { + persist, + }); + if (updated) { + this.emitProfileList("cache-sync"); + } + this.emit("profile:updated", { + pubkey: normalized, + profile: entry.profile || FALLBACK_PROFILE, + reason, + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + }); + } + } + return entry; + } + + cloneSavedProfiles() { + const saved = getSavedProfiles(); + return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : []; + } + + emitProfileList(reason = "update") { + this.emit("profile:updated", { + reason, + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + }); + } + + 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; + } + } + + 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; + } + + 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; + } + + async requestLogin(options = {}) { + if (!this.nostrClient || typeof this.nostrClient.login !== "function") { + throw new Error("Nostr login is not available."); + } + + const result = await this.nostrClient.login(options); + const pubkey = + typeof result === "string" + ? result + : result && typeof result === "object" + ? result.pubkey || result.publicKey || "" + : ""; + + const trimmed = typeof pubkey === "string" ? pubkey.trim() : ""; + if (!trimmed) { + return { pubkey: null }; + } + + if (options?.autoApply === false) { + return { pubkey: trimmed }; + } + + await this.login(trimmed, { + persistActive: options?.persistActive !== false, + }); + + return { pubkey: trimmed }; + } + + async handleUploadSubmit(payload, { publish } = {}) { + if (!this.getActivePubkey()) { + await this.requestLogin({ allowAccountSelection: true }); + if (!this.getActivePubkey()) { + throw new Error("Login required to publish videos."); + } + } + + if (typeof publish !== "function") { + throw new Error("A publish callback must be provided to handleUploadSubmit."); + } + + return publish(payload); + } + + getActivePubkey() { + return getPubkey(); + } + + async login(pubkey, options = {}) { + const normalizedOptions = + options && typeof options === "object" ? options : { persistActive: true }; + const persistActive = + normalizedOptions.persistActive === false ? false : true; + + const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); + const normalized = this.normalizeHexPubkey(pubkey); + const trimmed = typeof pubkey === "string" ? pubkey.trim() : ""; + const nextPubkey = normalized || trimmed || null; + if (!nextPubkey) { + throw new Error("A valid pubkey is required for login."); + } + + const identityChanged = previousPubkey !== nextPubkey; + + if (normalized) { + setPubkey(normalized); + if (this.nostrClient && typeof this.nostrClient === "object") { + this.nostrClient.pubkey = normalized; + } + } else { + setPubkey(nextPubkey); + if (this.nostrClient && typeof this.nostrClient === "object") { + this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : ""; + } + } + + const npub = this.safeEncodeNpub(nextPubkey); + setCurrentUserNpub(npub); + + let savedProfilesMutated = false; + mutateSavedProfiles((profiles) => { + const draft = Array.isArray(profiles) ? profiles.slice() : []; + const existingIndex = draft.findIndex((entry) => { + const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey); + return normalizedEntry && normalizedEntry === normalized; + }); + + const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey); + const cachedProfile = cacheEntry?.profile || {}; + + const nextEntry = { + pubkey: normalized || nextPubkey, + npub: npub || (cachedProfile.npub ?? null), + name: cachedProfile.name || draft[existingIndex]?.name || "", + picture: cachedProfile.picture || draft[existingIndex]?.picture || "", + authType: "nip07", + }; + + if (existingIndex >= 0) { + const currentEntry = draft[existingIndex] || {}; + const changed = + currentEntry.npub !== nextEntry.npub || + currentEntry.name !== nextEntry.name || + currentEntry.picture !== nextEntry.picture || + currentEntry.authType !== nextEntry.authType; + if (changed) { + draft[existingIndex] = nextEntry; + savedProfilesMutated = true; + } + } else { + draft.push(nextEntry); + savedProfilesMutated = true; + } + + return draft; + }, { persist: false, persistActive: false }); + + if (persistActive) { + setActiveProfilePubkey(normalized || nextPubkey, { persist: true }); + } + + if (!persistActive && savedProfilesMutated) { + persistSavedProfiles({ persistActive: false }); + } else if (persistActive || savedProfilesMutated) { + persistSavedProfiles({ persistActive: true }); + } + + if (savedProfilesMutated) { + this.emitProfileList("login"); + } + + const postLogin = await this.applyPostLoginState(); + + const detail = { + pubkey: getPubkey(), + npub: getCurrentUserNpub(), + previousPubkey: previousPubkey || null, + identityChanged, + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + postLogin, + }; + + this.emit("auth:login", detail); + return detail; + } + + async applyPostLoginState() { + const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); + if (!activePubkey) { + return { pubkey: null }; + } + + let blocksLoaded = false; + if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") { + try { + await this.userBlocks.loadBlocks(activePubkey); + blocksLoaded = true; + } catch (error) { + this.log("[AuthService] Failed to load block list", error); + } + } + + let relaysLoaded = false; + if (this.relayManager && typeof this.relayManager.loadRelayList === "function") { + try { + await this.relayManager.loadRelayList(activePubkey); + relaysLoaded = true; + } catch (error) { + this.log("[AuthService] Failed to load relay list", error); + } + } + + let profile = null; + try { + profile = await this.loadOwnProfile(activePubkey); + } catch (error) { + this.log("[AuthService] Failed to load own profile", error); + } + + return { + pubkey: activePubkey, + blocksLoaded, + relaysLoaded, + profile, + }; + } + + async logout() { + if (this.nostrClient && typeof this.nostrClient.logout === "function") { + try { + this.nostrClient.logout(); + } catch (error) { + this.log("[AuthService] nostrClient.logout threw", error); + } + } + + const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey(); + + setPubkey(null); + setCurrentUserNpub(null); + setActiveProfilePubkey(null, { persist: true }); + + if (this.userBlocks && typeof this.userBlocks.reset === "function") { + try { + this.userBlocks.reset(); + } catch (error) { + this.log("[AuthService] userBlocks.reset threw", error); + } + } + + if (this.relayManager && typeof this.relayManager.reset === "function") { + try { + this.relayManager.reset(); + } catch (error) { + this.log("[AuthService] relayManager.reset threw", error); + } + } + + persistSavedProfiles({ persistActive: true }); + + const detail = { + previousPubkey: previousPubkey || null, + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + }; + + this.emit("auth:logout", detail); + return detail; + } + + removeSavedProfile(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey.trim() : ""); + if (!normalized) { + return { removed: false }; + } + + const { changed } = mutateSavedProfiles((profiles) => + profiles.filter((entry) => entry?.pubkey !== normalized), + { + persist: true, + persistActive: true, + }); + + if (changed && getActiveProfilePubkey() === normalized) { + setActiveProfilePubkey(null, { persist: true }); + } + + if (changed) { + this.emitProfileList("remove-saved-profile"); + } + + return { removed: changed }; + } + + async switchProfile(pubkey) { + const normalizedTarget = this.normalizeHexPubkey(pubkey); + if (!normalizedTarget) { + throw new Error("Unable to switch profiles: invalid account."); + } + + const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey()); + if (normalizedActive && normalizedActive === normalizedTarget) { + return { switched: false, reason: "already-active" }; + } + + try { + await this.requestLogin({ + allowAccountSelection: true, + expectPubkey: normalizedTarget, + }); + } catch (error) { + throw error; + } + + const detail = await this.login(normalizedTarget, { persistActive: true }); + + mutateSavedProfiles((profiles) => { + const draft = profiles.slice(); + const index = draft.findIndex( + (entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget + ); + if (index > 0) { + const [moved] = draft.splice(index, 1); + draft.unshift(moved); + } + return draft; + }, { persist: true, persistActive: true }); + + this.emitProfileList("switch-profile"); + + return { switched: true, detail }; + } + + async loadOwnProfile(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey.trim() : ""); + if (!normalized) { + return FALLBACK_PROFILE; + } + + if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { + return FALLBACK_PROFILE; + } + + const events = await this.nostrClient.pool.list(this.nostrClient.relays, [ + { kinds: [0], authors: [normalized], limit: 1 }, + ]); + + let profile = FALLBACK_PROFILE; + if (Array.isArray(events) && events.length && events[0]?.content) { + try { + const data = JSON.parse(events[0].content); + profile = { + name: data.name || data.display_name || FALLBACK_PROFILE.name, + picture: data.picture || FALLBACK_PROFILE.picture, + }; + } catch (error) { + this.log("[AuthService] Failed to parse profile metadata", error); + } + } + + this.setProfileCacheEntry(normalized, profile, { + persist: true, + reason: "load-own-profile", + }); + + return profile; + } + + async fetchProfile(pubkey, { forceRefresh = false } = {}) { + const normalized = this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey.trim() : ""); + if (!normalized) { + return null; + } + + const cacheEntry = this.getProfileCacheEntry(normalized); + if (cacheEntry && !forceRefresh) { + this.emit("profile:updated", { + pubkey: normalized, + profile: cacheEntry.profile || FALLBACK_PROFILE, + reason: "cache-hit", + savedProfiles: this.cloneSavedProfiles(), + activeProfilePubkey: getActiveProfilePubkey(), + }); + return cacheEntry.profile; + } + + if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) { + return cacheEntry?.profile || FALLBACK_PROFILE; + } + + try { + const results = await Promise.all( + this.nostrClient.relays.map((relayUrl) => + this.nostrClient.pool.list([relayUrl], [ + { kinds: [0], authors: [normalized], limit: 1 }, + ]) + ) + ); + + const events = results.flat(); + let newest = null; + for (const event of events) { + if (!event || event.pubkey !== normalized || !event.content) { + continue; + } + if (!newest || event.created_at > newest.created_at) { + newest = event; + } + } + + if (newest?.content) { + const data = JSON.parse(newest.content); + const profile = { + name: data.name || data.display_name || FALLBACK_PROFILE.name, + picture: data.picture || FALLBACK_PROFILE.picture, + }; + this.setProfileCacheEntry(normalized, profile, { + persist: true, + reason: forceRefresh ? "force-refresh" : "fetch-profile", + }); + return profile; + } + } catch (error) { + this.log("[AuthService] Failed to fetch profile", error); + } + + return cacheEntry?.profile || FALLBACK_PROFILE; + } + + async fetchAndRenderProfile(pubkey, forceRefresh = false) { + return this.fetchProfile(pubkey, { forceRefresh }); + } + + syncSavedProfileFromCache(pubkey, { persist = false } = {}) { + const normalized = this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey.trim() : ""); + if (!normalized) { + return false; + } + + const updated = syncSavedProfileFromCacheState(normalized, { persist }); + if (updated) { + this.emitProfileList("cache-sync"); + } + return updated; + } + + loadProfileCacheFromStorage() { + hydrateProfileCacheFromStorage(); + } + + loadSavedProfilesFromStorage() { + const result = hydrateSavedProfilesFromStorage(); + this.emitProfileList("hydrate"); + return result; + } +}