// js/app/authSessionCoordinator.js /** * Signer, profile, and session transition orchestration. * * 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. */ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { FEED_TYPES } from "../constants.js"; /** * @param {object} deps - Injected dependencies. * @returns {object} Methods to be bound to the Application instance. */ export function createAuthSessionCoordinator(deps) { const { devLogger, userLogger, nostrClient, accessControl, userBlocks, subscriptions, hashtagPreferences, storageService, relayManager, torrentClient, getHashViewName, setHashView, DEFAULT_NIP07_PERMISSION_METHODS, RELAY_UI_BATCH_DELAY_MS, sanitizeRelayList, buildDmRelayListEvent, publishEventToRelays, assertAnyRelayAccepted, queueSignEvent, bootstrapTrustedSeeds, getModerationSettings, getActiveProfilePubkey, } = deps; return { async handleAuthLogin(detail = {}) { const postLoginPromise = detail && typeof detail.postLoginPromise?.then === "function" ? detail.postLoginPromise : Promise.resolve(detail?.postLogin ?? null); const postLoginResult = detail?.postLogin ?? null; // relaysReadyPromise resolves as soon as the user's relay list is loaded, // before profile fetching completes. Lists can start loading immediately // after relays are available — they don't depend on profile data. const relaysReadyPromise = detail && typeof detail.relaysReadyPromise?.then === "function" ? detail.relaysReadyPromise : postLoginPromise; if (detail && typeof detail === "object") { try { detail.__handled = true; } catch (error) { // Ignore attempts to mutate read-only descriptors. } } if (detail?.identityChanged) { this.resetViewLoggingState(); } this.resetPermissionPromptState(); // Stop existing feed subscription to prioritize user data sync if ( this.videoSubscription && typeof this.videoSubscription.unsub === "function" ) { this.videoSubscription.unsub(); this.videoSubscription = null; } this.applyAuthenticatedUiState(); this.commentController?.refreshAuthState?.(); this.updateShareNostrAuthState({ reason: "auth-login" }); if (typeof this.refreshUnreadDmIndicator === "function") { void this.refreshUnreadDmIndicator({ reason: "auth-login" }); } const currentView = getHashViewName(); const normalizedView = typeof currentView === "string" ? currentView.toLowerCase() : ""; const urlParams = new URLSearchParams(window.location.search); const hasVideoParam = urlParams.has("v"); const rawProviderId = typeof detail?.providerId === "string" ? detail.providerId.trim() : ""; const rawAuthType = typeof detail?.authType === "string" ? detail.authType.trim() : ""; const normalizedProvider = (rawProviderId || rawAuthType).toLowerCase() || ""; this.maybeShowExperimentalLoginWarning(normalizedProvider); const loginContext = { pubkey: detail?.pubkey || this.pubkey, previousPubkey: detail?.previousPubkey, identityChanged: Boolean(detail?.identityChanged), }; const activePubkey = detail?.pubkey || this.pubkey; const initialLoadingState = { profile: activePubkey ? "loading" : "idle", lists: activePubkey ? "loading" : "idle", dms: activePubkey ? "loading" : "idle", }; this.updateAuthLoadingState(initialLoadingState); this.dispatchAuthChange({ status: "login", loggedIn: true, pubkey: loginContext.pubkey || null, previousPubkey: loginContext.previousPubkey || null, authLoadingState: this.authLoadingState, }); const cachedProfile = detail?.postLogin && typeof detail.postLogin === "object" ? detail.postLogin.profile || null : null; if (activePubkey && cachedProfile) { try { this.updateActiveProfileUI(activePubkey, cachedProfile); } catch (error) { devLogger.warn( "[Application] Failed to apply cached profile during login:", error, ); } } if (this.profileController) { try { const maybePromise = this.profileController.handleAuthLogin(detail); if (maybePromise && typeof maybePromise.then === "function") { maybePromise.catch((error) => { devLogger.error( "Failed to process login within the profile controller:", error, ); }); } } catch (error) { devLogger.error( "Failed to process login within the profile controller:", error, ); } } else { this.renderSavedProfiles(); } const accessControlReadyPromise = accessControl && typeof accessControl.ensureReady === "function" ? Promise.resolve() .then(() => accessControl.ensureReady()) .catch((error) => { userLogger.error( "[Application] Failed to refresh admin lists after login:", error, ); throw error; }) : null; const profileStatePromise = Promise.resolve(postLoginPromise) .then(async (postLogin) => { // 1. Relays and Profile are now loaded (sequentially or efficiently by authService) const nextProfile = postLogin?.profile || cachedProfile; if (activePubkey && nextProfile) { this.updateActiveProfileUI(activePubkey, nextProfile); } this.forceRefreshAllProfiles(); this.updateAuthLoadingState({ profile: nextProfile ? "ready" : "error", }); return postLogin; }) .catch((error) => { devLogger.error("Post-login hydration failed:", error); this.updateAuthLoadingState({ profile: "error" }); return null; }); // PRE-AUTH: Request full permissions once so subsequent services don't // spam individual prompts. This runs immediately (not gated behind // relaysReadyPromise) so the user can approve while relays connect. const permissionPromise = activePubkey ? Promise.resolve() .then(async () => { const signer = await nostrClient.ensureActiveSignerForPubkey( activePubkey, ); if (signer?.type === "extension" || signer?.type === "nip07") { await nostrClient.ensureExtensionPermissions( DEFAULT_NIP07_PERMISSION_METHODS, ); } }) .catch((error) => { devLogger.warn("[Application] Pre-authorization failed:", error); }) : Promise.resolve(); // Start list loading as soon as relays are ready — lists don't depend on // profile data, only on relay URLs. This runs in parallel with profile // fetching instead of sequentially after it. // // PERF: Load blocks, subscriptions, and hashtag preferences ALL in // parallel. They have no data dependencies on each other — only the feed // render needs all three to be settled. Loading them concurrently shaves // 20-30 seconds off the critical login path. // // Lists wait for the permission pre-grant to settle before attempting // decryption. During auto-login (page refresh), the NIP-07 extension // may not be ready yet — without waiting, all three services would fail // with "permission-required" and schedule 3-second retry delays, adding // 6-10 seconds to the critical login path. By waiting for the pre-grant // (which itself waits for the extension and requests permissions), the // signer is guaranteed to be available when decryption starts. This does // NOT slow down fresh logins because the pre-grant resolves immediately // when permissions were already granted during loginWithExtension(). const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => { const parallelListTasks = []; if ( activePubkey && typeof this.authService.loadBlocksForPubkey === "function" ) { parallelListTasks.push( this.authService .loadBlocksForPubkey(activePubkey, { allowPermissionPrompt: false, }) .catch((error) => { devLogger.warn( "[Application] Failed to load blocks during login:", error, ); }), ); } if ( activePubkey && subscriptions && typeof subscriptions.ensureLoaded === "function" ) { parallelListTasks.push( subscriptions .ensureLoaded(activePubkey, { allowPermissionPrompt: false }) .then(() => { this.capturePermissionPromptFromError( subscriptions?.lastLoadError, ); }) .catch((error) => { devLogger.warn( "[Application] Failed to load subscriptions during login:", error, ); this.capturePermissionPromptFromError(error); }), ); } if ( activePubkey && this.hashtagPreferences && typeof this.hashtagPreferences.load === "function" ) { parallelListTasks.push( this.hashtagPreferences .load(activePubkey, { allowPermissionPrompt: false }) .then(() => { this.capturePermissionPromptFromError( this.hashtagPreferences?.lastLoadError, ); this.updateCachedHashtagPreferences(); }) .catch((error) => { devLogger.warn( "[Application] Failed to load hashtag preferences during login:", error, ); this.capturePermissionPromptFromError(error); }), ); } await Promise.allSettled(parallelListTasks); this.updateAuthLoadingState({ lists: "ready" }); return true; }); // DMs can wait for profile since they need encryption context. const dmStatePromise = profileStatePromise.then(() => { if ( activePubkey && this.nostrService && typeof this.nostrService.loadDirectMessages === "function" ) { return this.nostrService .loadDirectMessages({ actorPubkey: activePubkey, limit: 50, initialLoad: true, }) .then(() => { this.updateAuthLoadingState({ dms: "ready" }); }) .catch((error) => { devLogger.warn( "[Application] Failed to load direct messages during login:", error, ); this.updateAuthLoadingState({ dms: "error" }); }); } this.updateAuthLoadingState({ dms: "idle" }); return Promise.resolve(); }); const nwcPromise = profileStatePromise.then(() => { if ( activePubkey && this.nwcSettingsService && typeof this.nwcSettingsService.hydrateNwcSettingsForPubkey === "function" ) { return this.nwcSettingsService .hydrateNwcSettingsForPubkey(activePubkey) .catch((error) => { devLogger.warn( "[Application] Failed to hydrate NWC settings during login:", error, ); }); } return Promise.resolve(); }); // Ensure DMs are tracked for cleanup. void dmStatePromise; void nwcPromise; if (activePubkey) { const seedBlacklist = () => { const aggregatedBlacklist = accessControl.getBlacklist(); return userBlocks.seedWithNpubs( activePubkey, Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : [], ); }; // Background the seeding process because it involves publishing events, // which can block the login flow significantly. Promise.resolve(accessControlReadyPromise) .catch(() => null) .then(() => seedBlacklist()) .catch((error) => { if ( error?.code === "extension-permission-denied" || error?.code === "nip04-missing" || error?.name === "RelayPublishError" ) { userLogger.error( "[Application] Failed to seed shared block list after login:", error, ); } else { devLogger.error( "[Application] Unexpected error while seeding shared block list:", error, ); } }); } // Show loading state immediately while lists are syncing. try { this.reinitializeVideoListView({ reason: "login", postLoginResult }); } catch (error) { devLogger.warn("Failed to reinitialize video list view after login:", error); } // Wait for the critical feed-filtering lists (blocks, subscriptions, // hashtag preferences) to settle before rendering the video grid. This // prevents the feed from briefly showing unfiltered content. We use a // time-boxed wait so the UI isn't blocked indefinitely if decryption stalls. const FEED_SYNC_TIMEOUT_MS = 12000; const feedSyncPromise = Promise.race([ Promise.allSettled([ listStatePromise, profileStatePromise, ]), new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)), ]).catch(() => null); // Chain the grid refresh after the feed data is available. this.lastIdentityRefreshPromise = feedSyncPromise .then(() => this.refreshAllVideoGrids({ reason: "auth-login", forceMainReload: true, }), ); this.lastIdentityRefreshPromise .catch((error) => { devLogger.error("Failed to refresh video grids after login:", error); }) .finally(() => { this.lastIdentityRefreshPromise = null; }); this.forceRefreshAllProfiles(); if (this.uploadModal?.refreshCloudflareBucketPreview) { Promise.resolve() .then(() => this.uploadModal.refreshCloudflareBucketPreview()) .catch((error) => { devLogger.warn( "[Application] Failed to refresh cloudflare bucket preview after login:", error, ); }); } }, handleBlocksLoaded(detail = {}) { if (detail?.blocksLoaded !== true) { return; } if (this.profileController) { try { this.profileController.populateBlockedList(); } catch (error) { devLogger.warn( "[Application] Failed to refresh blocked list after blocks loaded:", error, ); } } try { void this.onVideosShouldRefresh({ reason: "blocks-loaded" }); } catch (error) { devLogger.warn( "[Application] Failed to refresh videos after blocks loaded:", error, ); } }, handleRelaysLoaded(detail = {}) { if (detail?.relaysLoaded !== true) { return; } this.scheduleRelayUiRefresh(); }, scheduleRelayUiRefresh() { if (this.relayUiRefreshTimeout) { return; } const scheduleTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout; this.relayUiRefreshTimeout = scheduleTimeout(() => { this.relayUiRefreshTimeout = null; this.flushRelayUiRefresh(); }, RELAY_UI_BATCH_DELAY_MS); }, flushRelayUiRefresh() { if (!this.profileController) { return; } try { this.profileController.populateProfileRelays(); } catch (error) { devLogger.warn( "[Application] Failed to refresh profile relays after relays loaded:", error, ); } try { void this.profileController.refreshDmRelayPreferences({ force: true }); } catch (error) { devLogger.warn( "[Application] Failed to refresh DM relay preferences after relays loaded:", error, ); } }, async requestLogout() { const detail = await this.authService.logout(); if (detail && typeof detail === "object") { if (detail.__handled === true) { return detail; } try { detail.__handled = true; } catch (error) { devLogger.warn("Failed to mark logout detail as handled:", error); } } await this.handleAuthLogout(detail); return detail ?? null; }, async handleAuthLogout(detail = {}) { if (detail && typeof detail === "object") { try { detail.__handled = true; } catch (error) { devLogger.warn("Failed to mark logout detail as handled:", error); } } this.resetViewLoggingState(); this.pendingModalZapOpen = false; this.resetHashtagPreferencesState(); this.resetPermissionPromptState(); clearDecryptionSchemeCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); await this.nwcSettingsService.onLogout({ pubkey: detail?.pubkey || this.pubkey, previousPubkey: detail?.previousPubkey, }); if (this.profileController) { try { await this.profileController.handleAuthLogout(detail); } catch (error) { devLogger.error( "Failed to process logout within the profile controller:", error, ); } } else { this.renderSavedProfiles(); } this.applyLoggedOutUiState(); this.updateShareNostrAuthState({ reason: "auth-logout" }); if (typeof this.refreshUnreadDmIndicator === "function") { void this.refreshUnreadDmIndicator({ reason: "auth-logout" }); } else if (this.appChromeController?.setUnreadDmIndicator) { this.appChromeController.setUnreadDmIndicator(false); } const logoutView = getHashViewName(); if ( typeof logoutView === "string" && logoutView.trim().toLowerCase() === FEED_TYPES.FOR_YOU ) { setHashView(FEED_TYPES.RECENT); } const activeModalVideo = typeof this.videoModal?.getCurrentVideo === "function" ? this.videoModal.getCurrentVideo() : this.commentController?.currentVideo || null; if (this.commentController && activeModalVideo) { // Regression guard: ensure logout refreshes the modal thread so comments stay visible without reopening. this.commentController.load(activeModalVideo); } else { this.commentController?.refreshAuthState?.(); } try { await this.handleModerationSettingsChange({ settings: getModerationSettings(), skipRefresh: true, }); } catch (error) { devLogger.warn( "Failed to reset moderation settings after logout:", error, ); } if (this.videoModal?.closeZapDialog) { try { this.videoModal.closeZapDialog({ silent: true, restoreFocus: false }); } catch (error) { devLogger.warn("Failed to close zap dialog during logout:", error); } } if (this.zapController) { try { this.zapController.resetState(); this.zapController.setVisibility(Boolean(this.currentVideo?.lightningAddress)); } catch (error) { devLogger.warn("Failed to reset zap controller during logout:", error); } } if (typeof this.nostrService?.stopDirectMessageSubscription === "function") { try { this.nostrService.stopDirectMessageSubscription(); } catch (error) { devLogger.warn("Failed to stop DM subscription during logout:", error); } } if (typeof this.nostrService?.clearDirectMessages === "function") { try { this.nostrService.clearDirectMessages({ emit: true }); } catch (error) { devLogger.warn("Failed to clear cached DMs during logout:", error); } } if (typeof this.nostrService?.clearVideoSubscription === "function") { try { this.nostrService.clearVideoSubscription(); } catch (error) { devLogger.warn("Failed to clear video subscription during logout:", error); } } if (typeof this.nostrService?.resetVideosCache === "function") { try { this.nostrService.resetVideosCache(); } catch (error) { devLogger.warn("Failed to reset cached videos during logout:", error); } } await this.renderVideoList({ videos: [], metadata: { reason: "auth:logout" }, }); this.dispatchAuthChange({ status: "logout", loggedIn: false, pubkey: detail?.pubkey || null, previousPubkey: detail?.previousPubkey || null, }); try { await this.loadVideos(true); } catch (error) { devLogger.error("Failed to refresh videos after logout:", error); } this.forceRefreshAllProfiles(); if (this.uploadModal?.refreshCloudflareBucketPreview) { await this.uploadModal.refreshCloudflareBucketPreview(); } }, handleProfileUpdated(detail = {}) { if (this.profileController) { this.profileController.handleProfileUpdated(detail); } else 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.profileController && this.normalizeHexPubkey(this.pubkey) === normalizedPubkey ) { this.updateActiveProfileUI(normalizedPubkey, profile); } } }, /** * Cleanup resources on unload or modal close. * * When `preserveModals` is true the modal infrastructure is kept alive so the * next playback session can reuse the existing controllers without * reinitializing DOM bindings. */ async cleanup({ preserveSubscriptions = false, preserveObservers = false, preserveModals = false, } = {}) { this.log( `[cleanup] Requested (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})` ); // Serialise teardown so overlapping calls (e.g. close button spam) don't // race each other and clobber a fresh playback setup. if (this.cleanupPromise) { this.log("[cleanup] Waiting for in-flight cleanup to finish before starting a new run."); try { await this.cleanupPromise; } catch (err) { devLogger.warn("Previous cleanup rejected:", err); } } const runCleanup = async () => { this.log( `[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})` ); try { this.cancelPendingViewLogging(); await this.flushWatchHistory("session-end", "cleanup").catch( (error) => { const message = error && typeof error.message === "string" ? error.message : String(error ?? "unknown error"); this.log(`[cleanup] Watch history flush failed: ${message}`); } ); this.clearActiveIntervals(); if (this.playbackService) { this.playbackService.cleanupWatchdog(); } this.teardownModalViewCountSubscription(); if (this.reactionController) { this.reactionController.unsubscribe(); } if (!preserveObservers && this.mediaLoader) { this.mediaLoader.disconnect(); } if (!preserveObservers) { this.teardownAllViewCountSubscriptions(); } else { this.pruneDetachedViewCountElements(); } if (!preserveSubscriptions) { this.nostrService.clearVideoSubscription(); this.videoSubscription = this.nostrService.getVideoSubscription() || null; } // If there's a small inline player if (this.videoElement) { this.videoElement = this.teardownVideoElement(this.videoElement); } if ( this.videoModal && typeof this.videoModal.clearPosterCleanup === "function" ) { try { this.videoModal.clearPosterCleanup(); } catch (err) { devLogger.warn("[cleanup] video modal poster cleanup threw:", err); } } const modalVideoEl = this.modalVideo; if (modalVideoEl) { const refreshedModal = this.teardownVideoElement(modalVideoEl, { replaceNode: true, }); if (refreshedModal) { this.modalVideo = refreshedModal; if ( this.videoModal && typeof this.videoModal.setVideoElement === "function" ) { try { this.videoModal.setVideoElement(refreshedModal); } catch (err) { devLogger.warn( "[cleanup] Failed to sync video modal element after replacement:", err ); } } } } this.commentController?.dispose({ resetUi: false }); if (!preserveModals) { if (this.modalManager) { try { this.modalManager.teardown(); } catch (error) { devLogger.warn("[cleanup] Modal teardown failed:", error); } this.modalManager = null; } if (this.bootstrapper) { try { this.bootstrapper.teardown(); } catch (error) { devLogger.warn("[cleanup] Bootstrap teardown failed:", error); } } } // Tell webtorrent to cleanup await torrentClient.cleanup(); this.log("[cleanup] WebTorrent cleanup resolved."); try { if (navigator.serviceWorker && navigator.serviceWorker.controller) { await fetch("/webtorrent/cancel/", { mode: "no-cors" }); } } catch (err) { // Ignore errors when cancelling the service worker stream; it may not be active. } } catch (err) { devLogger.error("Cleanup error:", err); } finally { this.log("[cleanup] Finished."); } }; const cleanupPromise = runCleanup(); this.cleanupPromise = cleanupPromise; try { await cleanupPromise; } finally { if (this.cleanupPromise === cleanupPromise) { this.cleanupPromise = null; } } }, async waitForCleanup() { if (!this.cleanupPromise) { return; } try { this.log("[waitForCleanup] Awaiting previous cleanup before continuing."); await this.cleanupPromise; this.log("[waitForCleanup] Previous cleanup completed."); } catch (err) { devLogger.warn("waitForCleanup observed a rejected cleanup:", err); } }, clearActiveIntervals() { if (Array.isArray(this.activeIntervals)) { this.activeIntervals.forEach((id) => clearInterval(id)); } this.activeIntervals = []; this.torrentStatusIntervalId = null; }, cacheTorrentStatusNodes() { const doc = (this.videoModal && this.videoModal.document) || (typeof document !== "undefined" ? document : null); if (!doc || typeof doc.getElementById !== "function") { this.torrentStatusNodes = null; return; } this.torrentStatusNodes = { status: doc.getElementById("status"), progress: doc.getElementById("progress"), peers: doc.getElementById("peers"), speed: doc.getElementById("speed"), downloaded: doc.getElementById("downloaded"), }; }, clearTorrentStatusNodes() { this.torrentStatusNodes = null; }, removeActiveInterval(intervalId) { if (!intervalId || !Array.isArray(this.activeIntervals)) { return; } this.activeIntervals = this.activeIntervals.filter((id) => id !== intervalId); }, addTorrentStatusVisibilityHandlers({ onPause, onResume, onClose } = {}) { this.removeTorrentStatusVisibilityHandlers(); const handleVisibilityChange = () => { if (!document.body.contains(this.modalVideo)) { if (typeof onClose === "function") { onClose(); } this.removeTorrentStatusVisibilityHandlers(); return; } if (document.visibilityState === "hidden") { if (typeof onPause === "function") { onPause(); } return; } if (typeof onResume === "function") { onResume(); } }; const handlePageHide = () => { if (typeof onPause === "function") { onPause(); } }; this.torrentStatusVisibilityHandler = handleVisibilityChange; this.torrentStatusPageHideHandler = handlePageHide; document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("pagehide", handlePageHide); }, removeTorrentStatusVisibilityHandlers() { if (this.torrentStatusVisibilityHandler) { document.removeEventListener("visibilitychange", this.torrentStatusVisibilityHandler); this.torrentStatusVisibilityHandler = null; } if (this.torrentStatusPageHideHandler) { window.removeEventListener("pagehide", this.torrentStatusPageHideHandler); this.torrentStatusPageHideHandler = null; } }, async handleProfileSwitchRequest({ pubkey, providerId } = {}) { if (!pubkey) { throw new Error("Missing pubkey for profile switch request."); } const result = await this.authService.switchProfile(pubkey, { providerId }); if (result?.switched) { const detail = result.detail || null; if ( detail?.postLoginPromise && typeof detail.postLoginPromise.then === "function" ) { try { await detail.postLoginPromise; } catch (error) { devLogger.warn( "Failed to complete post-login hydration before continuing after profile switch:", error, ); } } try { await this.handleModerationSettingsChange({ settings: getModerationSettings(), skipRefresh: true, }); } catch (error) { devLogger.warn( "Failed to sync moderation settings after profile switch:", error, ); } const refreshCompleted = await this.waitForIdentityRefresh({ reason: "profile-switch", }); if (!refreshCompleted) { devLogger.warn( "[Application] Fallback identity refresh was required after switching profiles.", ); } if (this.watchHistoryTelemetry?.resetPlaybackLoggingState) { try { this.watchHistoryTelemetry.resetPlaybackLoggingState(); } catch (error) { devLogger.warn( "Failed to reset watch history telemetry after profile switch:", error, ); } } if (this.watchHistoryTelemetry?.refreshPreferenceSettings) { try { this.watchHistoryTelemetry.refreshPreferenceSettings(); } catch (error) { devLogger.warn( "Failed to refresh watch history preferences after profile switch:", error, ); } } } 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, ); } const activePubkey = this.pubkey; if (activePubkey) { try { await this.nostrService.loadDirectMessages({ actorPubkey: activePubkey, limit: 50, initialLoad: true, }); } catch (error) { devLogger.warn( "[Application] Failed to sync direct messages during login:", error, ); } } } try { await this.refreshAllVideoGrids({ reason, forceMainReload: true, }); } catch (error) { devLogger.error( "[Application] Failed to refresh video grids after waiting for identity refresh:", error, ); } return false; }, async handleProfileLogoutRequest({ pubkey, entry } = {}) { const candidatePubkey = typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : typeof entry?.pubkey === "string" && entry.pubkey.trim() ? entry.pubkey.trim() : ""; if (!candidatePubkey) { return { loggedOut: false, reason: "invalid-pubkey" }; } const normalizedTarget = this.normalizeHexPubkey(candidatePubkey) || candidatePubkey; if (!normalizedTarget) { return { loggedOut: false, reason: "invalid-pubkey" }; } const activeNormalized = this.normalizeHexPubkey(getActiveProfilePubkey()); if (activeNormalized && activeNormalized === normalizedTarget) { const detail = await this.requestLogout(); return { loggedOut: true, reason: "active-profile", active: true, detail, }; } let removalResult; try { removalResult = this.authService.removeSavedProfile(candidatePubkey); } catch (error) { devLogger.error( "[Application] Failed to remove saved profile during logout request:", error, ); return { loggedOut: false, reason: "remove-failed", error }; } if (!removalResult?.removed) { if (removalResult?.error) { devLogger.warn( "[Application] removeSavedProfile returned an error during logout request:", removalResult.error, ); } return { loggedOut: false, reason: "not-found" }; } if ( this.nwcSettingsService && typeof this.nwcSettingsService.clearStoredNwcSettings === "function" ) { try { await this.nwcSettingsService.clearStoredNwcSettings(normalizedTarget, { silent: true, }); } catch (error) { devLogger.warn( "[Application] Failed to clear wallet settings for logged-out profile:", error, ); } } this.renderSavedProfiles(); return { loggedOut: true, removed: true }; }, async handleProfileRelayOperation({ action, url, activePubkey, skipPublishIfUnchanged = true, } = {}) { const context = { action, url, ok: false, changed: false, reason: null, error: null, publishResult: null, operationResult: null, }; if (!activePubkey) { context.reason = "no-active-pubkey"; return context; } const previous = relayManager.snapshot(); let operationResult; try { switch (action) { case "add": operationResult = relayManager.addRelay(url); break; case "remove": operationResult = relayManager.removeRelay(url); break; case "restore": operationResult = relayManager.restoreDefaults(); break; case "mode-toggle": operationResult = relayManager.cycleRelayMode(url); break; default: { const error = Object.assign(new Error("Unknown relay operation."), { code: "invalid-operation", }); throw error; } } } catch (error) { context.reason = error?.code || "operation-error"; context.error = error; return context; } context.operationResult = operationResult; context.changed = Boolean(operationResult?.changed); if (!context.changed && skipPublishIfUnchanged) { context.reason = operationResult?.reason || "unchanged"; return context; } try { const publishResult = await relayManager.publishRelayList(activePubkey); if (!publishResult?.ok) { throw Object.assign(new Error("No relays accepted the update."), { code: "publish-failed", }); } context.ok = true; context.publishResult = publishResult; const refreshReason = `relay-${action || "update"}`; try { await this.onVideosShouldRefresh({ reason: refreshReason }); } catch (refreshError) { devLogger.warn( "[Profile] Failed to refresh videos after relay update:", refreshError, ); } return context; } catch (error) { context.reason = error?.code || "publish-failed"; context.error = error; try { if (Array.isArray(previous)) { relayManager.setEntries(previous, { allowEmpty: false }); } } catch (restoreError) { devLogger.warn( "[Profile] Failed to restore relay preferences after publish error:", restoreError, ); } return context; } }, handleProfileRelayModeToggle(payload = {}) { return payload?.context || null; }, handleProfileRelayRestore(payload = {}) { return payload?.context || null; }, async handleProfileBlocklistMutation({ action, actorHex, targetHex, } = {}) { const context = { ok: false, reason: null, error: null }; if (!actorHex || !targetHex) { context.reason = "invalid-target"; return context; } try { await userBlocks.ensureLoaded(actorHex); const isBlocked = userBlocks.isBlocked(targetHex); if (action === "add") { if (isBlocked) { context.reason = "already-blocked"; return context; } await userBlocks.addBlock(targetHex, actorHex); context.ok = true; context.reason = "blocked"; } else if (action === "remove") { if (!isBlocked) { context.reason = "not-blocked"; return context; } await userBlocks.removeBlock(targetHex, actorHex); context.ok = true; context.reason = "unblocked"; } else { context.reason = "invalid-action"; return context; } if (context.ok) { try { await this.onVideosShouldRefresh({ reason: `blocklist-${action}` }); } catch (refreshError) { devLogger.error( "Failed to refresh videos after blocklist mutation:", refreshError, ); } } return context; } catch (error) { context.error = error; context.reason = error?.code || "service-error"; return context; } }, async handleProfileAdminMutation(payload = {}) { const action = payload?.action; const context = { ok: false, error: null, result: null }; try { switch (action) { case "ensure-ready": await accessControl.waitForReady(); context.ok = true; break; case "add-moderator": context.result = await accessControl.addModerator( payload.actorNpub, payload.targetNpub, ); context.ok = !!context.result?.ok; break; case "remove-moderator": context.result = await accessControl.removeModerator( payload.actorNpub, payload.targetNpub, ); context.ok = !!context.result?.ok; break; case "list-mutation": if (payload.listType === "whitelist") { context.result = payload.mode === "add" ? await accessControl.addToWhitelist( payload.actorNpub, payload.targetNpub, ) : await accessControl.removeFromWhitelist( payload.actorNpub, payload.targetNpub, ); } else { context.result = payload.mode === "add" ? await accessControl.addToBlacklist( payload.actorNpub, payload.targetNpub, ) : await accessControl.removeFromBlacklist( payload.actorNpub, payload.targetNpub, ); } context.ok = !!context.result?.ok; break; default: context.error = Object.assign( new Error("Unknown admin mutation."), { code: "invalid-action" }, ); } } catch (error) { context.error = error; return context; } return context; }, async handleProfileWalletPersist(options = {}) { return this.nwcSettingsService.handleProfileWalletPersist(options); }, async handleProfileWalletTest({ nwcUri, defaultZap } = {}) { return this.nwcSettingsService.ensureWallet({ nwcUri, defaultZap }); }, async handleProfileWalletDisconnect() { return this.nwcSettingsService.updateActiveNwcSettings( this.nwcSettingsService.createDefaultNwcSettings(), ); }, handleProfileAdminNotifyError({ error } = {}) { if (!error) { return; } devLogger.warn("[admin] Notification dispatch issue:", error); }, handleProfileHistoryEvent() { return null; }, }; }