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..6f3dfbc67b27
--- /dev/null
+++ b/shared/router-v2/account-switch-header-avatar.native.tsx
@@ -0,0 +1,61 @@
+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, 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
+
+ C.ignorePromise(Haptics.selectionAsync())
+ rememberAccountSwitchTab(username, recentAccount.username, C.Router2.getTab())
+ setUserSwitching(true)
+ 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..bd35eb979d10
--- /dev/null
+++ b/shared/router-v2/account-switch.test.tsx
@@ -0,0 +1,75 @@
+///
+
+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('')
+ })
+
+ test('returns the remembered tab after the username changes and consumes it once', () => {
+ 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', 'bob', Tabs.fsTab)
+
+ expect(consumePendingAccountSwitchTab('alice')).toBeUndefined()
+ expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
+ })
+
+ 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('')
+
+ expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
+ })
+
+ test('ignores routes that are not application tabs', () => {
+ 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
new file mode 100644
index 000000000000..5568af4a57f9
--- /dev/null
+++ b/shared/router-v2/account-switch.tsx
@@ -0,0 +1,44 @@
+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 = {
+ targetUsername: 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,
+ 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?.targetUsername !== currentUsername) return
+ pendingAccountSwitch = undefined
+ return pending.tab
+}
+
+export const clearPendingAccountSwitch = (currentUsername: string) => {
+ 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 36a75f6c82d4..3309bf2ceebe 100644
--- a/shared/router-v2/account-switcher/index.tsx
+++ b/shared/router-v2/account-switcher/index.tsx
@@ -7,23 +7,37 @@ 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
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, 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 = {
diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx
index a8bfb65f022c..d377101f7d32 100644
--- a/shared/router-v2/router.tsx
+++ b/shared/router-v2/router.tsx
@@ -31,6 +31,9 @@ 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'
+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
@@ -304,13 +307,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.
@@ -581,9 +598,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 = useCurrentUserState(s => s.username)
const {barStyle, isDarkMode} = useDarkModeState(
C.useShallow(s => {
@@ -597,6 +619,7 @@ function NativeRouter() {
return {barStyle, isDarkMode}
})
)
+
const bar = barStyle === 'default' ? null :
// Android also remounts on dark mode changes
const nativeIsDarkMode = useColorScheme() === 'dark'
@@ -604,6 +627,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 (
@@ -621,7 +658,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}