From ffc248ecd395a80cd089fb95cac85b02582a11ae Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:52:04 -0500 Subject: [PATCH] Track auth list sync outcomes and expose retryable auth status --- js/app.js | 89 ++++++++++++ js/app/authSessionCoordinator.js | 231 +++++++++++++++++++++++++------ js/ui/applicationBootstrap.js | 1 + js/ui/profileModalController.js | 85 ++++++++++++ 4 files changed, 366 insertions(+), 40 deletions(-) diff --git a/js/app.js b/js/app.js index 9636cfba..62af99ce 100644 --- a/js/app.js +++ b/js/app.js @@ -3480,6 +3480,95 @@ class Application { this.updatePermissionPromptCta(); } + async handleAuthSyncRetryRequest() { + const activePubkey = this.normalizeHexPubkey(this.pubkey); + if (!activePubkey) { + return; + } + + this.updateAuthLoadingState({ lists: "loading" }); + + const taskResults = await Promise.allSettled([ + Promise.resolve() + .then(() => this.authService?.loadBlocksForPubkey?.(activePubkey, { + allowPermissionPrompt: true, + })) + .then((loaded) => ({ name: "blocks", ok: loaded !== false })) + .catch((error) => ({ name: "blocks", ok: false, error })), + Promise.resolve() + .then(() => subscriptions?.ensureLoaded?.(activePubkey, { + allowPermissionPrompt: true, + })) + .then(() => ({ + name: "subscriptions", + ok: !subscriptions?.lastLoadError, + error: subscriptions?.lastLoadError || null, + })) + .catch((error) => ({ name: "subscriptions", ok: false, error })), + Promise.resolve() + .then(() => this.hashtagPreferences?.load?.(activePubkey, { + allowPermissionPrompt: true, + })) + .then(() => { + const error = this.hashtagPreferences?.lastLoadError || null; + if (!error) { + this.updateCachedHashtagPreferences(); + } + return { + name: "hashtags", + ok: !error, + error, + }; + }) + .catch((error) => ({ name: "hashtags", ok: false, error })), + ]); + + const tasks = taskResults.map((result) => + result.status === "fulfilled" + ? result.value + : { + name: "unknown", + ok: false, + error: result.reason || null, + }, + ); + const hasFailure = tasks.some((task) => !task.ok); + + this.updateAuthLoadingState({ + lists: hasFailure ? "error" : "ready", + listsDetail: { + ready: !hasFailure, + degraded: hasFailure, + error: hasFailure, + retryScheduled: false, + retryCompleted: true, + tasks: tasks.map((task) => ({ + name: task.name, + ok: task.ok, + fromCache: false, + error: task.error || null, + })), + }, + }); + + this.dispatchAuthChange({ + status: "auth-sync-retry", + loggedIn: true, + pubkey: activePubkey, + authLoadingState: this.authLoadingState, + }); + + if (this.profileController) { + this.profileController.populateBlockedList(); + void this.profileController.populateSubscriptionsList(); + this.profileController.populateHashtagPreferences(); + } + + if (!hasFailure) { + await this.onVideosShouldRefresh({ reason: "auth-sync-retry-success" }); + } + } + async handleAuthLogin(...args) { this._initCoordinators(); return this._auth.handleAuthLogin(...args); diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 8aea301a..7d5c9d17 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -239,6 +239,51 @@ export function createAuthSessionCoordinator(deps) { // for a retry timer. const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => { const allowPermissionPrompt = !preAuthSucceeded; + const hasValidBlocksCache = () => { + if (!activePubkey || !userBlocks) { + return false; + } + const activeCachePubkey = this.normalizeHexPubkey(userBlocks.activePubkey); + return userBlocks.loaded === true && activeCachePubkey === activePubkey; + }; + const hasValidSubscriptionsCache = () => { + if (!activePubkey || !subscriptions) { + return false; + } + const activeCachePubkey = this.normalizeHexPubkey( + subscriptions.currentUserPubkey, + ); + return subscriptions.loaded === true && activeCachePubkey === activePubkey; + }; + const hasValidHashtagCache = () => { + if (!activePubkey || !this.hashtagPreferences) { + return false; + } + const activeCachePubkey = this.normalizeHexPubkey( + this.hashtagPreferences.activePubkey, + ); + return this.hashtagPreferences.loaded === true && activeCachePubkey === activePubkey; + }; + + const runListTask = async (name, task, { hasValidCache }) => { + try { + const result = await task(); + return { + name, + ok: Boolean(result?.ok), + error: result?.error || null, + hasCache: hasValidCache(), + }; + } catch (error) { + return { + name, + ok: false, + error, + hasCache: hasValidCache(), + }; + } + }; + const parallelListTasks = []; if ( @@ -246,16 +291,22 @@ export function createAuthSessionCoordinator(deps) { typeof this.authService.loadBlocksForPubkey === "function" ) { parallelListTasks.push( - this.authService - .loadBlocksForPubkey(activePubkey, { - allowPermissionPrompt, - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to load blocks during login:", - error, - ); - }), + runListTask( + "blocks", + async () => { + const loaded = await this.authService.loadBlocksForPubkey(activePubkey, { + allowPermissionPrompt, + }); + const ok = loaded !== false; + if (!ok) { + const error = new Error("Block list sync returned a failed status."); + error.code = "blocks-sync-failed"; + return { ok: false, error }; + } + return { ok: true, error: null }; + }, + { hasValidCache: hasValidBlocksCache }, + ), ); } @@ -265,20 +316,18 @@ export function createAuthSessionCoordinator(deps) { typeof subscriptions.ensureLoaded === "function" ) { parallelListTasks.push( - subscriptions - .ensureLoaded(activePubkey, { allowPermissionPrompt }) - .then(() => { - this.capturePermissionPromptFromError( - subscriptions?.lastLoadError, - ); - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to load subscriptions during login:", - error, - ); + runListTask( + "subscriptions", + async () => { + await subscriptions.ensureLoaded(activePubkey, { + allowPermissionPrompt, + }); + const error = subscriptions?.lastLoadError || null; this.capturePermissionPromptFromError(error); - }), + return { ok: !error, error }; + }, + { hasValidCache: hasValidSubscriptionsCache }, + ), ); } @@ -288,28 +337,130 @@ export function createAuthSessionCoordinator(deps) { typeof this.hashtagPreferences.load === "function" ) { parallelListTasks.push( - this.hashtagPreferences - .load(activePubkey, { allowPermissionPrompt }) - .then(() => { - this.capturePermissionPromptFromError( - this.hashtagPreferences?.lastLoadError, - ); - this.updateCachedHashtagPreferences(); - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to load hashtag preferences during login:", - error, - ); + runListTask( + "hashtags", + async () => { + await this.hashtagPreferences.load(activePubkey, { + allowPermissionPrompt, + }); + const error = this.hashtagPreferences?.lastLoadError || null; this.capturePermissionPromptFromError(error); - }), + this.updateCachedHashtagPreferences(); + return { ok: !error, error }; + }, + { hasValidCache: hasValidHashtagCache }, + ), ); } - await Promise.allSettled(parallelListTasks); + const taskOutcomes = await Promise.all(parallelListTasks); + const requiredTaskOutcomes = taskOutcomes.filter((outcome) => + ["blocks", "subscriptions", "hashtags"].includes(outcome.name), + ); + const failedRequiredOutcomes = requiredTaskOutcomes.filter( + (outcome) => !outcome.ok, + ); + const ready = requiredTaskOutcomes.every( + (outcome) => outcome.ok || outcome.hasCache, + ); + const degraded = failedRequiredOutcomes.length > 0; + const fatal = failedRequiredOutcomes.some((outcome) => !outcome.hasCache); - this.updateAuthLoadingState({ lists: "ready" }); - return true; + const listSyncDetail = { + ready, + degraded, + error: fatal, + retryScheduled: false, + tasks: requiredTaskOutcomes.map((outcome) => ({ + name: outcome.name, + ok: outcome.ok, + fromCache: outcome.hasCache && !outcome.ok, + error: outcome.error || null, + })), + }; + + if (degraded && activePubkey) { + listSyncDetail.retryScheduled = true; + Promise.resolve() + .then(() => + Promise.all([ + this.authService?.loadBlocksForPubkey?.(activePubkey, { + allowPermissionPrompt: true, + }), + subscriptions?.ensureLoaded?.(activePubkey, { + allowPermissionPrompt: true, + }), + this.hashtagPreferences?.load?.(activePubkey, { + allowPermissionPrompt: true, + }), + ]), + ) + .then(() => { + this.updateCachedHashtagPreferences(); + const retryTasks = [ + { + name: "blocks", + ok: hasValidBlocksCache(), + fromCache: false, + error: null, + }, + { + name: "subscriptions", + ok: + hasValidSubscriptionsCache() && + !subscriptions?.lastLoadError, + fromCache: false, + error: subscriptions?.lastLoadError || null, + }, + { + name: "hashtags", + ok: + hasValidHashtagCache() && + !this.hashtagPreferences?.lastLoadError, + fromCache: false, + error: this.hashtagPreferences?.lastLoadError || null, + }, + ]; + const retryReady = retryTasks.every((task) => task.ok); + const retryDegraded = retryTasks.some((task) => !task.ok); + this.updateAuthLoadingState({ + lists: retryReady ? "ready" : "degraded", + listsDetail: { + ready: retryReady, + degraded: retryDegraded, + error: retryDegraded, + retryScheduled: false, + retryCompleted: true, + tasks: retryTasks, + }, + }); + this.dispatchAuthChange({ + status: "login-lists-sync", + loggedIn: true, + pubkey: activePubkey, + authLoadingState: this.authLoadingState, + }); + }) + .catch((error) => { + devLogger.warn( + "[Application] Consolidated list sync retry failed after login:", + error, + ); + }); + } + + const listsState = ready ? (degraded ? "degraded" : "ready") : "error"; + this.updateAuthLoadingState({ + lists: listsState, + listsDetail: listSyncDetail, + }); + this.dispatchAuthChange({ + status: "login-lists-sync", + loggedIn: true, + pubkey: activePubkey, + authLoadingState: this.authLoadingState, + }); + return listSyncDetail; }); // DMs can wait for profile since they need encryption context. diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index d3d614e5..b518a079 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -605,6 +605,7 @@ export default class ApplicationBootstrap { onPublishDmRelayPreferences: (payload) => app.handleProfilePublishDmRelayPreferences(payload), onRequestPermissionPrompt: () => app.handlePermissionPromptRequest(), + onRetryAuthSync: () => app.handleAuthSyncRetryRequest(), }; app.profileController = new ProfileModalController({ diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index cd4fcfcd..d2f01aa9 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1199,6 +1199,7 @@ export class ProfileModalController { onOpenRelays: callbacks.onOpenRelays || noop, onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop, onRequestPermissionPrompt: callbacks.onRequestPermissionPrompt || noop, + onRetryAuthSync: callbacks.onRetryAuthSync || noop, }; this.profileModal = null; @@ -1337,7 +1338,9 @@ export class ProfileModalController { message: "", buttonLabel: "Enable permissions", busy: false, + action: "permission", }; + this.authLoadingStateListener = null; this.profileSubscriptionsRefreshBtn = null; this.profileFriendsRefreshBtn = null; this.profileBlockedRefreshBtn = null; @@ -1501,6 +1504,15 @@ export class ProfileModalController { this.setupLayoutBreakpointObserver(); this.applyModalStackingOverrides(); this.registerEventListeners(); + if (!this.authLoadingStateListener && typeof window !== "undefined") { + this.authLoadingStateListener = (event) => { + this.handleAuthLoadingStateChange(event?.detail || {}); + }; + window.addEventListener( + "bitvid:auth-loading-state", + this.authLoadingStateListener, + ); + } this.populateHashtagPreferences(); this.refreshModerationSettingsUi(); const preserveMenu = this.isMobileLayoutActive(); @@ -5445,6 +5457,10 @@ export class ProfileModalController { if (this.permissionPromptCtaButton instanceof HTMLElement) { this.permissionPromptCtaButton.addEventListener("click", () => { + if (this.permissionPromptCtaButton?.dataset?.action === "retry-auth-sync") { + this.callbacks.onRetryAuthSync({ controller: this }); + return; + } this.callbacks.onRequestPermissionPrompt({ controller: this }); }); } @@ -8093,6 +8109,7 @@ export class ProfileModalController { message, buttonLabel, busy, + action, } = this.permissionPromptCtaState || {}; const normalizedMessage = typeof message === "string" && message.trim() @@ -8117,6 +8134,74 @@ export class ProfileModalController { "aria-busy", busy ? "true" : "false", ); + this.permissionPromptCtaButton.dataset.action = + action === "retry-auth-sync" ? "retry-auth-sync" : "permission"; + } + } + + handleAuthLoadingStateChange(detail = {}) { + const listsState = + typeof detail?.lists === "string" ? detail.lists.trim().toLowerCase() : ""; + const listsDetail = detail?.listsDetail && typeof detail.listsDetail === "object" + ? detail.listsDetail + : null; + if (!listsDetail || !listsState || listsState === "loading" || listsState === "idle") { + return; + } + + const failedTasks = Array.isArray(listsDetail.tasks) + ? listsDetail.tasks.filter((task) => task && task.ok === false) + : []; + const loadedFromCacheTasks = failedTasks.filter((task) => task.fromCache === true); + const hardFailTasks = failedTasks.filter((task) => task.fromCache !== true); + + if (loadedFromCacheTasks.length > 0 && hardFailTasks.length === 0) { + this.setSubscriptionsStatus( + "Subscriptions loaded from cache; sync is retrying in background.", + "warning", + ); + this.setHashtagStatus( + "Hashtag preferences loaded from cache; sync is retrying in background.", + "warning", + ); + this.setBlockListLoadingState("error", { + message: "Blocked creators loaded from cache; syncing latest in background.", + }); + } else if (hardFailTasks.length > 0) { + this.setSubscriptionsStatus( + "Some profile lists failed to sync. Use Retry sync.", + "warning", + ); + this.setHashtagStatus( + "Some profile lists failed to sync. Use Retry sync.", + "warning", + ); + this.setBlockListLoadingState("error", { + message: "Blocked creators failed to sync. Retry to fetch latest lists.", + }); + } + + if (failedTasks.length > 0) { + this.setPermissionPromptCtaState({ + visible: true, + message: + loadedFromCacheTasks.length > 0 + ? "Some lists are currently from cache. Retry now to sync with relays." + : "Failed to sync required profile lists. Retry now.", + buttonLabel: "Retry list sync", + action: "retry-auth-sync", + busy: false, + }); + return; + } + + if (listsState === "ready") { + this.setPermissionPromptCtaState({ + visible: false, + message: "", + action: "permission", + busy: false, + }); } }