diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index f6511407..a710b732 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -650,6 +650,26 @@ export function createAuthSessionCoordinator(deps) { authLoadingState: this.authLoadingState, }); + // Now that the NEW identity's lists (blocks/subscriptions/hashtags) have + // settled, backfill the profile modal if it is open — so switching + // accounts with the modal visible refreshes the pane the user is viewing + // with the freshly-loaded data instead of leaving the previous account's + // list on screen until a manual refresh. No-op when the modal is closed. + if ( + this.profileController && + typeof this.profileController.refreshOpenPanesForActiveIdentity === + "function" + ) { + try { + this.profileController.refreshOpenPanesForActiveIdentity(); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh profile panes after lists settled:", + error, + ); + } + } + // Warm the watch history in the background now that the feed-driving // lists (blocks/subscriptions/hashtags) have finished decrypting. This // runs AFTER the required lists — never concurrently — so it does not diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 4cfe80bb..da27d4f4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1258,6 +1258,39 @@ export class ProfileModalController { void this.refreshDmRelayPreferences({ force: true }); } + // Run one identity-switch reset step in isolation. A failure in a single step + // must not skip the ones after it — notably the DM reset that clears the + // previous account's conversation on a switch. + runIdentityResetStep(label, fn) { + try { + fn(); + } catch (error) { + userLogger.warn( + `[profileModal] Identity reset step "${label}" failed:`, + error, + ); + } + } + + // Refresh the profile modal for the ACTIVE identity while it is open (e.g. right + // after switching accounts). Re-runs the current pane's own lazy load+populate — + // the SAME path as opening the pane fresh — so DMs reload, subscriptions and + // hashtags refetch for the new account, blocked/relays/storage repopulate, etc. + // Non-active panes stay lazy and repopulate when the user selects them. + refreshOpenPanesForActiveIdentity() { + if (!this.isProfileModalOpen()) { + return; + } + try { + this.selectPane(this.getActivePane()); + } catch (error) { + userLogger.warn( + "[profileModal] Failed to refresh the active pane for the current identity:", + error, + ); + } + } + setMessagesUnreadIndicator(visible) { if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) { return; @@ -5223,46 +5256,57 @@ export class ProfileModalController { this.setActivePubkey(activePubkey); } + // Reset identity-scoped UI state up front, each step isolated so one throwing + // can't skip the others. Regression this guards: switching from NIP-07 to a + // saved nsec (modal open) left the PREVIOUS account's DMs, subscriptions and + // hashtags on screen because a sibling call threw before the DM reset ran. + // DM identity setup also feeds the background unread indicator + DM + // subscription, so it stays eager. + this.runIdentityResetStep("dm-identity", () => + this.handleActiveDmIdentityChanged(activePubkey), + ); + this.runIdentityResetStep("saved-profiles", () => this.renderSavedProfiles()); + const walletPromise = this.hydrateActiveWalletSettings(activePubkey); const adminPromise = this.refreshAdminPaneState().catch((error) => { userLogger.warn("Failed to refresh admin pane after login:", error); }); - this.renderSavedProfiles(); - // Warm blocks + subscriptions, but go through ensureLoaded() (not the raw // loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's // login-time list loads instead of issuing a second, ungated fetch. The // direct calls here each kicked off an independent relay round-trip, // doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0). if (activePubkey) { - if (this.services.userBlocks?.ensureLoaded) { - this.services.userBlocks.ensureLoaded(activePubkey).catch(noop); - } else if (this.services.userBlocks?.loadBlocks) { - this.services.userBlocks.loadBlocks(activePubkey).catch(noop); - } - if (this.subscriptionsService?.ensureLoaded) { - this.subscriptionsService - .ensureLoaded(activePubkey, { allowPermissionPrompt: false }) - .catch(noop); - } else if (this.subscriptionsService?.loadSubscriptions) { - this.subscriptionsService - .loadSubscriptions(activePubkey, { allowPermissionPrompt: false }) - .catch(noop); - } + this.runIdentityResetStep("warm-lists", () => { + if (this.services.userBlocks?.ensureLoaded) { + this.services.userBlocks.ensureLoaded(activePubkey).catch(noop); + } else if (this.services.userBlocks?.loadBlocks) { + this.services.userBlocks.loadBlocks(activePubkey).catch(noop); + } + if (this.subscriptionsService?.ensureLoaded) { + this.subscriptionsService + .ensureLoaded(activePubkey, { allowPermissionPrompt: false }) + .catch(noop); + } else if (this.subscriptionsService?.loadSubscriptions) { + this.subscriptionsService + .loadSubscriptions(activePubkey, { allowPermissionPrompt: false }) + .catch(noop); + } + }); } - // DM identity is cheap state setup needed for the background unread - // indicator + DM subscription, so it stays eager. - this.handleActiveDmIdentityChanged(activePubkey); - // Profile-PANEL rendering is deferred: each pane lazily populates when it's // shown (selectPane), so eagerly rendering all of them at login paints into // CLOSED panels — friends-list avatars, subscriptions, blocks, storage, // relays — and jams the main thread right after login (the ~10-15s "profile - // panel won't open / site frozen" report). Only do the eager pass when the - // modal is already open (e.g. switching accounts with it visible); - // otherwise open()/selectPane handles it on demand. + // panel won't open / site frozen" report). Only refresh when the modal is + // already open (e.g. switching accounts with it visible); otherwise + // open()/selectPane handles it on demand. On a switch we re-run the ACTIVE + // pane's own load+populate (refreshOpenPanesForActiveIdentity) so the pane the + // user is viewing reloads for the new identity, then again once profile/wallet + // data settles (list panes also backfill via the auth coordinator once the new + // identity's lists finish loading). const settleLoginData = Promise.all([ walletPromise, adminPromise, @@ -5270,12 +5314,10 @@ export class ProfileModalController { ]); if (this.isProfileModalOpen()) { - this.populateOpenProfilePanels(); + this.refreshOpenPanesForActiveIdentity(); settleLoginData .then(() => { - if (this.isProfileModalOpen()) { - this.populateOpenProfilePanels(); - } + this.refreshOpenPanesForActiveIdentity(); }) .catch((error) => { userLogger.warn( diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 6f1487f9..bef794f6 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -1368,3 +1368,110 @@ test('handleDirectMessagesRelayWarning suppresses status updates when disabled', controller.handleDirectMessagesRelayWarning(detail); assert.equal(statusCalls.length, 0); }); + +// Account-switch refresh (nip-07 -> saved nsec, modal open): the modal must +// re-render for the NEW identity without a manual refresh, and a failure in one +// identity-reset step must not skip the others (notably the DM reset that clears +// the previous account's conversation). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-switch-modal-refresh +// given: "profile modal controller loaded" +// when: "handleAuthLogin runs for a new identity / refreshOpenPanesForActiveIdentity is called" +// then: "DM reset still runs after a sibling throw; the active pane is re-run when open" +// observable_outcomes: +// - "runIdentityResetStep swallows a throwing step (does not propagate)" +// - "a throwing DM reset does not skip renderSavedProfiles" +// - "refreshOpenPanesForActiveIdentity re-runs selectPane(activePane) only when open" +// determinism_controls: +// - "instance method spies; no network/timers relied upon for assertions" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +test('runIdentityResetStep isolates a throwing step (does not propagate)', async () => { + const controller = createController(); + let ran = false; + assert.doesNotThrow(() => { + controller.runIdentityResetStep('boom', () => { + throw new Error('kaboom'); + }); + controller.runIdentityResetStep('ok', () => { + ran = true; + }); + }); + assert.equal(ran, true, 'a later reset step still runs after an earlier one throws'); +}); + +test('handleAuthLogin: a throwing DM reset does not skip the saved-profiles reset', async (t) => { + const controller = createController(); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + + // Keep the async collaborators inert so the test only exercises the reset order. + controller.hydrateActiveWalletSettings = async () => null; + controller.refreshAdminPaneState = async () => {}; + controller.refreshOpenPanesForActiveIdentity = () => {}; + + let dmResetPubkey = 'unset'; + controller.handleActiveDmIdentityChanged = (pubkey) => { + dmResetPubkey = pubkey; + throw new Error('dm reset blew up'); + }; + let savedProfilesRendered = 0; + controller.renderSavedProfiles = () => { + savedProfilesRendered += 1; + }; + + const pubkey = 'b'.repeat(64); + const result = await controller.handleAuthLogin({ + pubkey, + activeProfilePubkey: pubkey, + identityChanged: true, + }); + + assert.equal(result, true); + assert.equal(dmResetPubkey, pubkey, 'DM reset was attempted for the new identity'); + assert.equal( + savedProfilesRendered, + 1, + 'the saved-profiles reset still ran even though the DM reset threw', + ); +}); + +test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is open', async (t) => { + const controller = createController(); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + + const selected = []; + controller.selectPane = (name) => { + selected.push(name); + }; + controller.getActivePane = () => 'messages'; + + controller.isProfileModalOpen = () => false; + controller.refreshOpenPanesForActiveIdentity(); + assert.deepEqual(selected, [], 'closed modal: no pane refresh'); + + controller.isProfileModalOpen = () => true; + controller.refreshOpenPanesForActiveIdentity(); + assert.deepEqual( + selected, + ['messages'], + 'open modal: re-runs the active pane so it reloads for the new identity', + ); +});