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}