From 3bbff715f3663b456f52c278e5e8c362d5408684 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Thu, 16 Jul 2026 15:03:55 -0400 Subject: [PATCH 1/4] long press account switch --- shared/router-v2/router.tsx | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index a8bfb65f022c..44fbdc4101ad 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -451,11 +451,26 @@ const appTabsScreenOptions = ( let NativeRootComponent: React.ComponentType +// State for fast account switching via tab long press +let _pendingFastSwitchTab: string | undefined + if (isMobile) { // Created inside the isMobile guard: on desktop @react-navigation/bottom-tabs is // aliased to the null module, so calling it at module scope would crash startup. const NativeTab = createBottomTabNavigator() + const handleTabLongPress = (tab: Tabs.Tab) => { + if (tab !== Tabs.peopleTab) return + const accountRows = useConfigState.getState().configuredAccounts + const current = C.useCurrentUserState.getState().username + const row = accountRows.find(a => a.username !== current && a.hasStoredSecret) + if (row) { + _pendingFastSwitchTab = C.Router2.getTab() ?? undefined + useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.login(row.username, '') + } + } + function AppTabsNative() { const navBadges = useNotifState(s => s.navBadges) const hasPermissions = usePushState(s => s.hasPermissions) @@ -469,6 +484,9 @@ if (isMobile) { name={tab} component={nativeTabComponents[tab]!} options={appTabsScreenOptions(tab, navBadges, hasPermissions, isDarkMode)} + listeners={{ + tabLongPress: () => handleTabLongPress(tab), + }} /> ))} @@ -597,6 +615,25 @@ function NativeRouter() { return {barStyle, isDarkMode} }) ) + + // Restore tab after account switch via long press + React.useEffect(() => { + if (!loggedIn) return + const tab = _pendingFastSwitchTab + if (!tab) return + _pendingFastSwitchTab = undefined + let attempts = 0 + const trySwitch = () => { + if (attempts++ > 20) return + if (C.Router2.getTab()) { + C.Router2.switchTab(tab as Tabs.AppTab) + } else { + setTimeout(trySwitch, 100) + } + } + trySwitch() + }, [loggedIn]) + const bar = barStyle === 'default' ? null : // Android also remounts on dark mode changes const nativeIsDarkMode = useColorScheme() === 'dark' From 239b659c3e05fb62fc8e860916c9f89a0d7d59d8 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Tue, 21 Jul 2026 14:51:40 -0400 Subject: [PATCH 2/4] v2 --- shared/fs/nav-header/mobile-header.tsx | 12 ++- shared/people/routes.tsx | 17 ---- .../account-switch-header-avatar.desktop.tsx | 3 + .../account-switch-header-avatar.native.tsx | 60 ++++++++++++ shared/router-v2/account-switch.test.tsx | 68 ++++++++++++++ shared/router-v2/account-switch.tsx | 37 ++++++++ shared/router-v2/account-switcher/index.tsx | 4 + shared/router-v2/router.tsx | 91 +++++++++---------- 8 files changed, 226 insertions(+), 66 deletions(-) create mode 100644 shared/router-v2/account-switch-header-avatar.desktop.tsx create mode 100644 shared/router-v2/account-switch-header-avatar.native.tsx create mode 100644 shared/router-v2/account-switch.test.tsx create mode 100644 shared/router-v2/account-switch.tsx diff --git a/shared/fs/nav-header/mobile-header.tsx b/shared/fs/nav-header/mobile-header.tsx index a7fd92de2426..94065f9bc110 100644 --- a/shared/fs/nav-header/mobile-header.tsx +++ b/shared/fs/nav-header/mobile-header.tsx @@ -6,6 +6,7 @@ import type * as T from '@/constants/types' import {useFolderViewFilterState} from '@/fs/common/folder-view-filter-state' import Actions from './actions' import * as FS from '@/constants/fs' +import AccountSwitchHeaderAvatar from '@/router-v2/account-switch-header-avatar' /* * @@ -57,9 +58,13 @@ const NavMobileHeaderInner = (props: Props) => { return props.path === FS.defaultPath ? ( - - Files - + + + + Files + + + @@ -110,6 +115,7 @@ const styles = Kb.Styles.styleSheetCreate( paddingBottom: Kb.Styles.globalMargins.xsmall + Kb.Styles.globalMargins.xxtiny, }, rootContainer: {height: 56}, + rootSpacer: Kb.Styles.size(44), expandedTopContainer: { backgroundColor: Kb.Styles.globalColors.white, height: 56, diff --git a/shared/people/routes.tsx b/shared/people/routes.tsx index 4cfcb4f3d1b9..271b01d5ec0c 100644 --- a/shared/people/routes.tsx +++ b/shared/people/routes.tsx @@ -1,31 +1,14 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' -import * as TestIDs from '@/tests/e2e/shared/test-ids' import peopleTeamBuilder from '../team-building/page' import ProfileSearch from '../profile/search' -import {useCurrentUserState} from '@/stores/current-user' import {settingsLogOutTab} from '@/constants/settings' import {defineRouteMap} from '@/constants/types/router' -const HeaderAvatar = () => { - const myUsername = useCurrentUserState(s => s.username) - const navigateAppend = C.Router2.navigateAppend - const onClick = () => navigateAppend({name: 'accountSwitcher', params: {}}) - return -} - export const newRoutes = defineRouteMap({ peopleRoot: { getOptions: { - // iOS 26: hidesSharedBackground prevents the glass circle around the avatar - ...(isIOS - ? { - unstable_headerRightItems: () => [ - {element: , hidesSharedBackground: true, type: 'custom' as const}, - ], - } - : {headerRight: isMobile ? () => : undefined}), headerTitle: () => , }, screen: React.lazy(async () => import('./container')), diff --git a/shared/router-v2/account-switch-header-avatar.desktop.tsx b/shared/router-v2/account-switch-header-avatar.desktop.tsx new file mode 100644 index 000000000000..d4719a5fe0fe --- /dev/null +++ b/shared/router-v2/account-switch-header-avatar.desktop.tsx @@ -0,0 +1,3 @@ +const AccountSwitchHeaderAvatar = () => null + +export default AccountSwitchHeaderAvatar diff --git a/shared/router-v2/account-switch-header-avatar.native.tsx b/shared/router-v2/account-switch-header-avatar.native.tsx new file mode 100644 index 000000000000..1a0903c4d404 --- /dev/null +++ b/shared/router-v2/account-switch-header-avatar.native.tsx @@ -0,0 +1,60 @@ +import * as C from '@/constants' +import * as Haptics from 'expo-haptics' +import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' +import {getMostRecentlyUsedAccount, rememberAccountSwitchTab} from './account-switch' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {Pressable} from 'react-native' + +const openAccountSwitcher = () => { + C.Router2.navigateAppend({name: 'accountSwitcher', params: {}}) +} + +const AccountSwitchHeaderAvatar = () => { + const username = useCurrentUserState(s => s.username) + const {configuredAccounts, userSwitching} = useConfigState( + C.useShallow(s => ({configuredAccounts: s.configuredAccounts, userSwitching: s.userSwitching})) + ) + const recentAccount = getMostRecentlyUsedAccount(configuredAccounts, username) + + const switchToRecentAccount = () => { + if (userSwitching || !recentAccount) return + + Haptics.selectionAsync() + .then(() => {}) + .catch(() => {}) + rememberAccountSwitchTab(username, C.Router2.getTab()) + const {dispatch} = useConfigState.getState() + dispatch.setUserSwitching(true) + dispatch.login(recentAccount.username, '') + } + + return ( + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate(() => ({ + container: { + alignItems: 'center', + height: 44, + justifyContent: 'center', + width: 44, + }, +})) + +export default AccountSwitchHeaderAvatar diff --git a/shared/router-v2/account-switch.test.tsx b/shared/router-v2/account-switch.test.tsx new file mode 100644 index 000000000000..a78ee3a5e3ba --- /dev/null +++ b/shared/router-v2/account-switch.test.tsx @@ -0,0 +1,68 @@ +/// + +import * as Tabs from '@/constants/tabs' +import { + clearPendingAccountSwitch, + consumePendingAccountSwitchTab, + getMostRecentlyUsedAccount, + rememberAccountSwitchTab, +} from './account-switch' + +const account = (username: string, hasStoredSecret = true) => ({ + hasStoredSecret, + uid: `${username}-uid`, + username, +}) + +test('selects the first eligible account from the service MRU order', () => { + const accounts = [account('current'), account('most-recent'), account('older')] + + expect(getMostRecentlyUsedAccount(accounts, 'current')?.username).toBe('most-recent') +}) + +test('skips the current account and accounts without a stored secret', () => { + const accounts = [account('current'), account('recent-without-secret', false), account('older')] + + expect(getMostRecentlyUsedAccount(accounts, 'current')?.username).toBe('older') +}) + +test('returns undefined when no other account can be switched to', () => { + const accounts = [account('current'), account('other-without-secret', false)] + + expect(getMostRecentlyUsedAccount(accounts, 'current')).toBeUndefined() +}) + +describe('pending account-switch tab', () => { + afterEach(() => { + clearPendingAccountSwitch('alice') + clearPendingAccountSwitch('bob') + }) + + test('returns the remembered tab after the username changes and consumes it once', () => { + rememberAccountSwitchTab('alice', Tabs.chatTab) + + expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.chatTab) + expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + }) + + test('does not consume the tab before the account changes', () => { + rememberAccountSwitchTab('alice', Tabs.fsTab) + + expect(consumePendingAccountSwitchTab('alice')).toBeUndefined() + expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab) + }) + + test('clears the pending tab when switching ends without changing account', () => { + rememberAccountSwitchTab('alice', Tabs.teamsTab) + + clearPendingAccountSwitch('alice') + + expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + }) + + test('ignores routes that are not application tabs', () => { + rememberAccountSwitchTab('alice', Tabs.loginTab) + + expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + }) +}) diff --git a/shared/router-v2/account-switch.tsx b/shared/router-v2/account-switch.tsx new file mode 100644 index 000000000000..2b50d7ebe1f5 --- /dev/null +++ b/shared/router-v2/account-switch.tsx @@ -0,0 +1,37 @@ +import type * as T from '@/constants/types' +import * as Tabs from '@/constants/tabs' + +// The service returns configured accounts in descending login-time order, so +// the first eligible account is the most recently used account other than the +// current one. +export const getMostRecentlyUsedAccount = ( + accounts: ReadonlyArray, + currentUsername: string +) => accounts.find(account => account.username !== currentUsername && account.hasStoredSecret) + +type PendingAccountSwitch = { + sourceUsername: string + tab: Tabs.AppTab +} + +let pendingAccountSwitch: PendingAccountSwitch | undefined + +const isAppTab = (tab: Tabs.Tab | undefined): tab is Tabs.AppTab => + tab !== undefined && Tabs.desktopTabs.some(appTab => appTab === tab) + +export const rememberAccountSwitchTab = (sourceUsername: string, tab: Tabs.Tab | undefined) => { + pendingAccountSwitch = sourceUsername && isAppTab(tab) ? {sourceUsername, tab} : undefined +} + +export const consumePendingAccountSwitchTab = (currentUsername: string) => { + const pending = pendingAccountSwitch + if (!pending || !currentUsername || pending.sourceUsername === currentUsername) return + pendingAccountSwitch = undefined + return pending.tab +} + +export const clearPendingAccountSwitch = (currentUsername: string) => { + if (pendingAccountSwitch?.sourceUsername === currentUsername) { + pendingAccountSwitch = undefined + } +} diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index 36a75f6c82d4..dcc8bf9f7d43 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -7,6 +7,7 @@ import type * as T from '@/constants/types' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {navToProfile} from '@/constants/router' +import {rememberAccountSwitchTab} from '../account-switch' const AccountSwitcher = (p: {onSelected?: () => void}) => { const {onSelected} = p @@ -20,6 +21,9 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => { const setUserSwitching = useConfigState(s => s.dispatch.setUserSwitching) const login = useConfigState(s => s.dispatch.login) const onSelectAccountLoggedIn = (username: string) => { + if (isMobile) { + rememberAccountSwitchTab(you, C.Router2.getTab()) + } setUserSwitching(true) login(username, '') } diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index 44fbdc4101ad..fdbe34d3059a 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -31,6 +31,8 @@ import {colors, darkColors} from '@/styles/colors' import {createBottomTabNavigator} from '@react-navigation/bottom-tabs' import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liquid-glass' import {Platform, StatusBar, View, useColorScheme} from 'react-native' +import AccountSwitchHeaderAvatar from './account-switch-header-avatar' +import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch' const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false // `bubble`/`bubble.fill` SF Symbols only exist on iOS 17+; older sims render blank. const isIOS17Plus = isIOS && parseInt(Platform.Version as string, 10) >= 17 @@ -304,13 +306,27 @@ const tabStackOptions = ({ navigation, }: { navigation: {canGoBack: () => boolean} -}): NativeStackNavigationOptions => ({ - ...Common.defaultNavigationOptions, - // Use the native back button (liquid glass pill on iOS 26) for non-root screens; - // omit headerLeft entirely on root screens so no empty glass circle appears. - headerBackVisible: navigation.canGoBack(), - headerLeft: undefined, -}) +}): NativeStackNavigationOptions => { + const canGoBack = navigation.canGoBack() + return { + ...Common.defaultNavigationOptions, + // Root screens show the account switcher avatar. Pushed screens use the + // native back button (liquid glass pill on iOS 26). + headerBackVisible: canGoBack, + headerLeft: isAndroid && !canGoBack ? () => : undefined, + ...(isIOS && !canGoBack + ? { + unstable_headerLeftItems: () => [ + { + element: , + hidesSharedBackground: true, + type: 'custom' as const, + }, + ], + } + : {}), + } +} // On phones, each tab stack only contains its root screen. All other routes live in // the root stack (alongside chatConversation) so they render above the tab bar. @@ -451,26 +467,11 @@ const appTabsScreenOptions = ( let NativeRootComponent: React.ComponentType -// State for fast account switching via tab long press -let _pendingFastSwitchTab: string | undefined - if (isMobile) { // Created inside the isMobile guard: on desktop @react-navigation/bottom-tabs is // aliased to the null module, so calling it at module scope would crash startup. const NativeTab = createBottomTabNavigator() - const handleTabLongPress = (tab: Tabs.Tab) => { - if (tab !== Tabs.peopleTab) return - const accountRows = useConfigState.getState().configuredAccounts - const current = C.useCurrentUserState.getState().username - const row = accountRows.find(a => a.username !== current && a.hasStoredSecret) - if (row) { - _pendingFastSwitchTab = C.Router2.getTab() ?? undefined - useConfigState.getState().dispatch.setUserSwitching(true) - useConfigState.getState().dispatch.login(row.username, '') - } - } - function AppTabsNative() { const navBadges = useNotifState(s => s.navBadges) const hasPermissions = usePushState(s => s.hasPermissions) @@ -484,9 +485,6 @@ if (isMobile) { name={tab} component={nativeTabComponents[tab]!} options={appTabsScreenOptions(tab, navBadges, hasPermissions, isDarkMode)} - listeners={{ - tabLongPress: () => handleTabLongPress(tab), - }} /> ))} @@ -599,9 +597,14 @@ const nativeLinkingConfig = isMobile ? createLinkingConfig(handleAppLink) : unde function NativeRouter() { const loggedInLoaded = useHandshakeEverDone() - const {loggedIn, startupLoaded} = useConfigState( - C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded})) + const {loggedIn, startupLoaded, userSwitching} = useConfigState( + C.useShallow(s => ({ + loggedIn: s.loggedIn, + startupLoaded: s.startup.loaded, + userSwitching: s.userSwitching, + })) ) + const username = C.useCurrentUserState(s => s.username) const {barStyle, isDarkMode} = useDarkModeState( C.useShallow(s => { @@ -616,24 +619,6 @@ function NativeRouter() { }) ) - // Restore tab after account switch via long press - React.useEffect(() => { - if (!loggedIn) return - const tab = _pendingFastSwitchTab - if (!tab) return - _pendingFastSwitchTab = undefined - let attempts = 0 - const trySwitch = () => { - if (attempts++ > 20) return - if (C.Router2.getTab()) { - C.Router2.switchTab(tab as Tabs.AppTab) - } else { - setTimeout(trySwitch, 100) - } - } - trySwitch() - }, [loggedIn]) - const bar = barStyle === 'default' ? null : // Android also remounts on dark mode changes const nativeIsDarkMode = useColorScheme() === 'dark' @@ -641,6 +626,20 @@ function NativeRouter() { const nativeDarkSuffix = isAndroid ? (nativeIsDarkMode ? '-dark' : '-light') : '' const rootKey = navKey ? `${navKey}${nativeDarkSuffix}` : '' + React.useEffect(() => { + if (!userSwitching) { + clearPendingAccountSwitch(username) + } + }, [userSwitching, username]) + + const onNativeReady = () => { + onStateChange() + const tab = consumePendingAccountSwitchTab(username) + if (tab) { + C.Router2.switchTab(tab) + } + } + if (!loggedInLoaded || (loggedIn && !startupLoaded)) { return ( @@ -658,7 +657,7 @@ function NativeRouter() { // Sync the initial state from the linking config into the router store. // onStateChange doesn't fire for the initial state, so this ensures // onRouteChanged runs and conversation data gets loaded on startup. - onReady={onStateChange} + onReady={onNativeReady} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} ref={setNavRef} From 687525bf4ff69abdef5a9999fc8fae18e26366b9 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Tue, 21 Jul 2026 15:07:44 -0400 Subject: [PATCH 3/4] x --- shared/router-v2/router.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index fdbe34d3059a..d377101f7d32 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -33,6 +33,7 @@ import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liqu import {Platform, StatusBar, View, useColorScheme} from 'react-native' import AccountSwitchHeaderAvatar from './account-switch-header-avatar' import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch' +import {useCurrentUserState} from '@/stores/current-user' const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false // `bubble`/`bubble.fill` SF Symbols only exist on iOS 17+; older sims render blank. const isIOS17Plus = isIOS && parseInt(Platform.Version as string, 10) >= 17 @@ -604,7 +605,7 @@ function NativeRouter() { userSwitching: s.userSwitching, })) ) - const username = C.useCurrentUserState(s => s.username) + const username = useCurrentUserState(s => s.username) const {barStyle, isDarkMode} = useDarkModeState( C.useShallow(s => { From bdfb60cf2054add3a0dcb478fa572c366cf7e614 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Tue, 21 Jul 2026 15:28:09 -0400 Subject: [PATCH 4/4] x --- .../account-switch-header-avatar.native.tsx | 21 +++++++++-------- shared/router-v2/account-switch.test.tsx | 23 ++++++++++++------- shared/router-v2/account-switch.tsx | 17 ++++++++++---- shared/router-v2/account-switcher/index.tsx | 22 +++++++++++++----- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/shared/router-v2/account-switch-header-avatar.native.tsx b/shared/router-v2/account-switch-header-avatar.native.tsx index 1a0903c4d404..6f3dfbc67b27 100644 --- a/shared/router-v2/account-switch-header-avatar.native.tsx +++ b/shared/router-v2/account-switch-header-avatar.native.tsx @@ -13,21 +13,23 @@ const openAccountSwitcher = () => { const AccountSwitchHeaderAvatar = () => { const username = useCurrentUserState(s => s.username) - const {configuredAccounts, userSwitching} = useConfigState( - C.useShallow(s => ({configuredAccounts: s.configuredAccounts, userSwitching: s.userSwitching})) + const {configuredAccounts, login, setUserSwitching, userSwitching} = useConfigState( + C.useShallow(s => ({ + configuredAccounts: s.configuredAccounts, + login: s.dispatch.login, + setUserSwitching: s.dispatch.setUserSwitching, + userSwitching: s.userSwitching, + })) ) const recentAccount = getMostRecentlyUsedAccount(configuredAccounts, username) const switchToRecentAccount = () => { if (userSwitching || !recentAccount) return - Haptics.selectionAsync() - .then(() => {}) - .catch(() => {}) - rememberAccountSwitchTab(username, C.Router2.getTab()) - const {dispatch} = useConfigState.getState() - dispatch.setUserSwitching(true) - dispatch.login(recentAccount.username, '') + C.ignorePromise(Haptics.selectionAsync()) + rememberAccountSwitchTab(username, recentAccount.username, C.Router2.getTab()) + setUserSwitching(true) + login(recentAccount.username, '') } return ( @@ -37,7 +39,6 @@ const AccountSwitchHeaderAvatar = () => { } accessibilityLabel={`${username} account menu`} accessibilityRole="button" - accessible={true} onLongPress={recentAccount && !userSwitching ? switchToRecentAccount : undefined} onPress={openAccountSwitcher} style={Kb.Styles.castStyleNative(styles.container)} diff --git a/shared/router-v2/account-switch.test.tsx b/shared/router-v2/account-switch.test.tsx index a78ee3a5e3ba..bd35eb979d10 100644 --- a/shared/router-v2/account-switch.test.tsx +++ b/shared/router-v2/account-switch.test.tsx @@ -34,34 +34,41 @@ test('returns undefined when no other account can be switched to', () => { describe('pending account-switch tab', () => { afterEach(() => { - clearPendingAccountSwitch('alice') - clearPendingAccountSwitch('bob') + clearPendingAccountSwitch('') }) test('returns the remembered tab after the username changes and consumes it once', () => { - rememberAccountSwitchTab('alice', Tabs.chatTab) + rememberAccountSwitchTab('alice', 'bob', Tabs.chatTab) expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.chatTab) expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() }) test('does not consume the tab before the account changes', () => { - rememberAccountSwitchTab('alice', Tabs.fsTab) + rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab) expect(consumePendingAccountSwitchTab('alice')).toBeUndefined() expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab) }) - test('clears the pending tab when switching ends without changing account', () => { - rememberAccountSwitchTab('alice', Tabs.teamsTab) + test('keeps the pending tab when switching ends on the target account', () => { + rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab) + + clearPendingAccountSwitch('bob') + + expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.teamsTab) + }) + + test('clears the pending tab when switching fails after blanking the username', () => { + rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab) - clearPendingAccountSwitch('alice') + clearPendingAccountSwitch('') expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() }) test('ignores routes that are not application tabs', () => { - rememberAccountSwitchTab('alice', Tabs.loginTab) + rememberAccountSwitchTab('alice', 'bob', Tabs.loginTab) expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() }) diff --git a/shared/router-v2/account-switch.tsx b/shared/router-v2/account-switch.tsx index 2b50d7ebe1f5..5568af4a57f9 100644 --- a/shared/router-v2/account-switch.tsx +++ b/shared/router-v2/account-switch.tsx @@ -10,7 +10,7 @@ export const getMostRecentlyUsedAccount = ( ) => accounts.find(account => account.username !== currentUsername && account.hasStoredSecret) type PendingAccountSwitch = { - sourceUsername: string + targetUsername: string tab: Tabs.AppTab } @@ -19,19 +19,26 @@ let pendingAccountSwitch: PendingAccountSwitch | undefined const isAppTab = (tab: Tabs.Tab | undefined): tab is Tabs.AppTab => tab !== undefined && Tabs.desktopTabs.some(appTab => appTab === tab) -export const rememberAccountSwitchTab = (sourceUsername: string, tab: Tabs.Tab | undefined) => { - pendingAccountSwitch = sourceUsername && isAppTab(tab) ? {sourceUsername, tab} : undefined +export const rememberAccountSwitchTab = ( + sourceUsername: string, + targetUsername: string, + tab: Tabs.Tab | undefined +) => { + pendingAccountSwitch = + sourceUsername && targetUsername && sourceUsername !== targetUsername && isAppTab(tab) + ? {tab, targetUsername} + : undefined } export const consumePendingAccountSwitchTab = (currentUsername: string) => { const pending = pendingAccountSwitch - if (!pending || !currentUsername || pending.sourceUsername === currentUsername) return + if (pending?.targetUsername !== currentUsername) return pendingAccountSwitch = undefined return pending.tab } export const clearPendingAccountSwitch = (currentUsername: string) => { - if (pendingAccountSwitch?.sourceUsername === currentUsername) { + if (pendingAccountSwitch?.targetUsername !== currentUsername) { pendingAccountSwitch = undefined } } diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index dcc8bf9f7d43..3309bf2ceebe 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -12,22 +12,32 @@ import {rememberAccountSwitchTab} from '../account-switch' const AccountSwitcher = (p: {onSelected?: () => void}) => { const {onSelected} = p const _fullnames = useUsersState(s => s.infoMap) - const _accountRows = useConfigState(s => s.configuredAccounts) + const { + accountRows: _accountRows, + login, + logoutAndTryToLogInAs: onSelectAccountLoggedOut, + logoutToLoggedOutFlow: onLoginAsAnotherUser, + setUserSwitching, + } = useConfigState( + C.useShallow(s => ({ + accountRows: s.configuredAccounts, + login: s.dispatch.login, + logoutAndTryToLogInAs: s.dispatch.logoutAndTryToLogInAs, + logoutToLoggedOutFlow: s.dispatch.logoutToLoggedOutFlow, + setUserSwitching: s.dispatch.setUserSwitching, + })) + ) const you = useCurrentUserState(s => s.username) const fullname = _fullnames.get(you)?.fullname ?? '' const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin) - const onLoginAsAnotherUser = useConfigState(s => s.dispatch.logoutToLoggedOutFlow) - const setUserSwitching = useConfigState(s => s.dispatch.setUserSwitching) - const login = useConfigState(s => s.dispatch.login) const onSelectAccountLoggedIn = (username: string) => { if (isMobile) { - rememberAccountSwitchTab(you, C.Router2.getTab()) + rememberAccountSwitchTab(you, username, C.Router2.getTab()) } setUserSwitching(true) login(username, '') } - const onSelectAccountLoggedOut = useConfigState(s => s.dispatch.logoutAndTryToLogInAs) const accountRows = _accountRows.filter(account => account.username !== you) const props = {