Skip to content

Commit d254aaa

Browse files
authored
long press account switch (#29454)
* long press account switch * v2 * x * x
1 parent e28e1dc commit d254aaa

8 files changed

Lines changed: 258 additions & 35 deletions

File tree

shared/fs/nav-header/mobile-header.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type * as T from '@/constants/types'
66
import {useFolderViewFilterState} from '@/fs/common/folder-view-filter-state'
77
import Actions from './actions'
88
import * as FS from '@/constants/fs'
9+
import AccountSwitchHeaderAvatar from '@/router-v2/account-switch-header-avatar'
910

1011
/*
1112
*
@@ -57,9 +58,13 @@ const NavMobileHeaderInner = (props: Props) => {
5758
return props.path === FS.defaultPath ? (
5859
<Kb.SafeAreaViewTop>
5960
<Kb.Box2 direction="vertical" fullWidth={true} style={styles.headerContainer}>
60-
<Kb.Box2 direction="horizontal" fullWidth={true} centerChildren={true} gap="xtiny" style={styles.rootContainer}>
61-
<Kb.Text type="BodyBig">Files</Kb.Text>
62-
<FilesTabStatusIcon />
61+
<Kb.Box2 direction="horizontal" fullWidth={true} alignItems="center" style={styles.rootContainer}>
62+
<AccountSwitchHeaderAvatar />
63+
<Kb.Box2 direction="horizontal" centerChildren={true} flex={1} gap="xtiny">
64+
<Kb.Text type="BodyBig">Files</Kb.Text>
65+
<FilesTabStatusIcon />
66+
</Kb.Box2>
67+
<Kb.Box2 direction="vertical" style={styles.rootSpacer} />
6368
</Kb.Box2>
6469
</Kb.Box2>
6570
</Kb.SafeAreaViewTop>
@@ -110,6 +115,7 @@ const styles = Kb.Styles.styleSheetCreate(
110115
paddingBottom: Kb.Styles.globalMargins.xsmall + Kb.Styles.globalMargins.xxtiny,
111116
},
112117
rootContainer: {height: 56},
118+
rootSpacer: Kb.Styles.size(44),
113119
expandedTopContainer: {
114120
backgroundColor: Kb.Styles.globalColors.white,
115121
height: 56,

shared/people/routes.tsx

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,14 @@
11
import * as React from 'react'
22
import * as C from '@/constants'
33
import * as Kb from '@/common-adapters'
4-
import * as TestIDs from '@/tests/e2e/shared/test-ids'
54
import peopleTeamBuilder from '../team-building/page'
65
import ProfileSearch from '../profile/search'
7-
import {useCurrentUserState} from '@/stores/current-user'
86
import {settingsLogOutTab} from '@/constants/settings'
97
import {defineRouteMap} from '@/constants/types/router'
108

11-
const HeaderAvatar = () => {
12-
const myUsername = useCurrentUserState(s => s.username)
13-
const navigateAppend = C.Router2.navigateAppend
14-
const onClick = () => navigateAppend({name: 'accountSwitcher', params: {}})
15-
return <Kb.Avatar size={32} username={myUsername} onClick={onClick} testID={TestIDs.PEOPLE_HEADER_AVATAR} />
16-
}
17-
189
export const newRoutes = defineRouteMap({
1910
peopleRoot: {
2011
getOptions: {
21-
// iOS 26: hidesSharedBackground prevents the glass circle around the avatar
22-
...(isIOS
23-
? {
24-
unstable_headerRightItems: () => [
25-
{element: <HeaderAvatar />, hidesSharedBackground: true, type: 'custom' as const},
26-
],
27-
}
28-
: {headerRight: isMobile ? () => <HeaderAvatar /> : undefined}),
2912
headerTitle: () => <ProfileSearch />,
3013
},
3114
screen: React.lazy(async () => import('./container')),
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const AccountSwitchHeaderAvatar = () => null
2+
3+
export default AccountSwitchHeaderAvatar
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as C from '@/constants'
2+
import * as Haptics from 'expo-haptics'
3+
import * as Kb from '@/common-adapters'
4+
import * as TestIDs from '@/tests/e2e/shared/test-ids'
5+
import {getMostRecentlyUsedAccount, rememberAccountSwitchTab} from './account-switch'
6+
import {useConfigState} from '@/stores/config'
7+
import {useCurrentUserState} from '@/stores/current-user'
8+
import {Pressable} from 'react-native'
9+
10+
const openAccountSwitcher = () => {
11+
C.Router2.navigateAppend({name: 'accountSwitcher', params: {}})
12+
}
13+
14+
const AccountSwitchHeaderAvatar = () => {
15+
const username = useCurrentUserState(s => s.username)
16+
const {configuredAccounts, login, setUserSwitching, userSwitching} = useConfigState(
17+
C.useShallow(s => ({
18+
configuredAccounts: s.configuredAccounts,
19+
login: s.dispatch.login,
20+
setUserSwitching: s.dispatch.setUserSwitching,
21+
userSwitching: s.userSwitching,
22+
}))
23+
)
24+
const recentAccount = getMostRecentlyUsedAccount(configuredAccounts, username)
25+
26+
const switchToRecentAccount = () => {
27+
if (userSwitching || !recentAccount) return
28+
29+
C.ignorePromise(Haptics.selectionAsync())
30+
rememberAccountSwitchTab(username, recentAccount.username, C.Router2.getTab())
31+
setUserSwitching(true)
32+
login(recentAccount.username, '')
33+
}
34+
35+
return (
36+
<Pressable
37+
accessibilityHint={
38+
recentAccount ? `Long press to switch to ${recentAccount.username}` : undefined
39+
}
40+
accessibilityLabel={`${username} account menu`}
41+
accessibilityRole="button"
42+
onLongPress={recentAccount && !userSwitching ? switchToRecentAccount : undefined}
43+
onPress={openAccountSwitcher}
44+
style={Kb.Styles.castStyleNative(styles.container)}
45+
testID={TestIDs.PEOPLE_HEADER_AVATAR}
46+
>
47+
<Kb.Avatar size={32} username={username} />
48+
</Pressable>
49+
)
50+
}
51+
52+
const styles = Kb.Styles.styleSheetCreate(() => ({
53+
container: {
54+
alignItems: 'center',
55+
height: 44,
56+
justifyContent: 'center',
57+
width: 44,
58+
},
59+
}))
60+
61+
export default AccountSwitchHeaderAvatar
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/// <reference types="jest" />
2+
3+
import * as Tabs from '@/constants/tabs'
4+
import {
5+
clearPendingAccountSwitch,
6+
consumePendingAccountSwitchTab,
7+
getMostRecentlyUsedAccount,
8+
rememberAccountSwitchTab,
9+
} from './account-switch'
10+
11+
const account = (username: string, hasStoredSecret = true) => ({
12+
hasStoredSecret,
13+
uid: `${username}-uid`,
14+
username,
15+
})
16+
17+
test('selects the first eligible account from the service MRU order', () => {
18+
const accounts = [account('current'), account('most-recent'), account('older')]
19+
20+
expect(getMostRecentlyUsedAccount(accounts, 'current')?.username).toBe('most-recent')
21+
})
22+
23+
test('skips the current account and accounts without a stored secret', () => {
24+
const accounts = [account('current'), account('recent-without-secret', false), account('older')]
25+
26+
expect(getMostRecentlyUsedAccount(accounts, 'current')?.username).toBe('older')
27+
})
28+
29+
test('returns undefined when no other account can be switched to', () => {
30+
const accounts = [account('current'), account('other-without-secret', false)]
31+
32+
expect(getMostRecentlyUsedAccount(accounts, 'current')).toBeUndefined()
33+
})
34+
35+
describe('pending account-switch tab', () => {
36+
afterEach(() => {
37+
clearPendingAccountSwitch('')
38+
})
39+
40+
test('returns the remembered tab after the username changes and consumes it once', () => {
41+
rememberAccountSwitchTab('alice', 'bob', Tabs.chatTab)
42+
43+
expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.chatTab)
44+
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
45+
})
46+
47+
test('does not consume the tab before the account changes', () => {
48+
rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab)
49+
50+
expect(consumePendingAccountSwitchTab('alice')).toBeUndefined()
51+
expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
52+
})
53+
54+
test('keeps the pending tab when switching ends on the target account', () => {
55+
rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab)
56+
57+
clearPendingAccountSwitch('bob')
58+
59+
expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.teamsTab)
60+
})
61+
62+
test('clears the pending tab when switching fails after blanking the username', () => {
63+
rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab)
64+
65+
clearPendingAccountSwitch('')
66+
67+
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
68+
})
69+
70+
test('ignores routes that are not application tabs', () => {
71+
rememberAccountSwitchTab('alice', 'bob', Tabs.loginTab)
72+
73+
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
74+
})
75+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type * as T from '@/constants/types'
2+
import * as Tabs from '@/constants/tabs'
3+
4+
// The service returns configured accounts in descending login-time order, so
5+
// the first eligible account is the most recently used account other than the
6+
// current one.
7+
export const getMostRecentlyUsedAccount = (
8+
accounts: ReadonlyArray<T.Config.ConfiguredAccount>,
9+
currentUsername: string
10+
) => accounts.find(account => account.username !== currentUsername && account.hasStoredSecret)
11+
12+
type PendingAccountSwitch = {
13+
targetUsername: string
14+
tab: Tabs.AppTab
15+
}
16+
17+
let pendingAccountSwitch: PendingAccountSwitch | undefined
18+
19+
const isAppTab = (tab: Tabs.Tab | undefined): tab is Tabs.AppTab =>
20+
tab !== undefined && Tabs.desktopTabs.some(appTab => appTab === tab)
21+
22+
export const rememberAccountSwitchTab = (
23+
sourceUsername: string,
24+
targetUsername: string,
25+
tab: Tabs.Tab | undefined
26+
) => {
27+
pendingAccountSwitch =
28+
sourceUsername && targetUsername && sourceUsername !== targetUsername && isAppTab(tab)
29+
? {tab, targetUsername}
30+
: undefined
31+
}
32+
33+
export const consumePendingAccountSwitchTab = (currentUsername: string) => {
34+
const pending = pendingAccountSwitch
35+
if (pending?.targetUsername !== currentUsername) return
36+
pendingAccountSwitch = undefined
37+
return pending.tab
38+
}
39+
40+
export const clearPendingAccountSwitch = (currentUsername: string) => {
41+
if (pendingAccountSwitch?.targetUsername !== currentUsername) {
42+
pendingAccountSwitch = undefined
43+
}
44+
}

shared/router-v2/account-switcher/index.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,37 @@ import type * as T from '@/constants/types'
77
import {useUsersState} from '@/stores/users'
88
import {useCurrentUserState} from '@/stores/current-user'
99
import {navToProfile} from '@/constants/router'
10+
import {rememberAccountSwitchTab} from '../account-switch'
1011

1112
const AccountSwitcher = (p: {onSelected?: () => void}) => {
1213
const {onSelected} = p
1314
const _fullnames = useUsersState(s => s.infoMap)
14-
const _accountRows = useConfigState(s => s.configuredAccounts)
15+
const {
16+
accountRows: _accountRows,
17+
login,
18+
logoutAndTryToLogInAs: onSelectAccountLoggedOut,
19+
logoutToLoggedOutFlow: onLoginAsAnotherUser,
20+
setUserSwitching,
21+
} = useConfigState(
22+
C.useShallow(s => ({
23+
accountRows: s.configuredAccounts,
24+
login: s.dispatch.login,
25+
logoutAndTryToLogInAs: s.dispatch.logoutAndTryToLogInAs,
26+
logoutToLoggedOutFlow: s.dispatch.logoutToLoggedOutFlow,
27+
setUserSwitching: s.dispatch.setUserSwitching,
28+
}))
29+
)
1530
const you = useCurrentUserState(s => s.username)
1631
const fullname = _fullnames.get(you)?.fullname ?? ''
1732
const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin)
18-
const onLoginAsAnotherUser = useConfigState(s => s.dispatch.logoutToLoggedOutFlow)
1933

20-
const setUserSwitching = useConfigState(s => s.dispatch.setUserSwitching)
21-
const login = useConfigState(s => s.dispatch.login)
2234
const onSelectAccountLoggedIn = (username: string) => {
35+
if (isMobile) {
36+
rememberAccountSwitchTab(you, username, C.Router2.getTab())
37+
}
2338
setUserSwitching(true)
2439
login(username, '')
2540
}
26-
const onSelectAccountLoggedOut = useConfigState(s => s.dispatch.logoutAndTryToLogInAs)
2741

2842
const accountRows = _accountRows.filter(account => account.username !== you)
2943
const props = {

shared/router-v2/router.tsx

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import {colors, darkColors} from '@/styles/colors'
3131
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
3232
import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liquid-glass'
3333
import {Platform, StatusBar, View, useColorScheme} from 'react-native'
34+
import AccountSwitchHeaderAvatar from './account-switch-header-avatar'
35+
import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch'
36+
import {useCurrentUserState} from '@/stores/current-user'
3437
const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false
3538
// `bubble`/`bubble.fill` SF Symbols only exist on iOS 17+; older sims render blank.
3639
const isIOS17Plus = isIOS && parseInt(Platform.Version as string, 10) >= 17
@@ -304,13 +307,27 @@ const tabStackOptions = ({
304307
navigation,
305308
}: {
306309
navigation: {canGoBack: () => boolean}
307-
}): NativeStackNavigationOptions => ({
308-
...Common.defaultNavigationOptions,
309-
// Use the native back button (liquid glass pill on iOS 26) for non-root screens;
310-
// omit headerLeft entirely on root screens so no empty glass circle appears.
311-
headerBackVisible: navigation.canGoBack(),
312-
headerLeft: undefined,
313-
})
310+
}): NativeStackNavigationOptions => {
311+
const canGoBack = navigation.canGoBack()
312+
return {
313+
...Common.defaultNavigationOptions,
314+
// Root screens show the account switcher avatar. Pushed screens use the
315+
// native back button (liquid glass pill on iOS 26).
316+
headerBackVisible: canGoBack,
317+
headerLeft: isAndroid && !canGoBack ? () => <AccountSwitchHeaderAvatar /> : undefined,
318+
...(isIOS && !canGoBack
319+
? {
320+
unstable_headerLeftItems: () => [
321+
{
322+
element: <AccountSwitchHeaderAvatar />,
323+
hidesSharedBackground: true,
324+
type: 'custom' as const,
325+
},
326+
],
327+
}
328+
: {}),
329+
}
330+
}
314331

315332
// On phones, each tab stack only contains its root screen. All other routes live in
316333
// the root stack (alongside chatConversation) so they render above the tab bar.
@@ -581,9 +598,14 @@ const nativeLinkingConfig = isMobile ? createLinkingConfig(handleAppLink) : unde
581598
function NativeRouter() {
582599
const loggedInLoaded = useHandshakeEverDone()
583600

584-
const {loggedIn, startupLoaded} = useConfigState(
585-
C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded}))
601+
const {loggedIn, startupLoaded, userSwitching} = useConfigState(
602+
C.useShallow(s => ({
603+
loggedIn: s.loggedIn,
604+
startupLoaded: s.startup.loaded,
605+
userSwitching: s.userSwitching,
606+
}))
586607
)
608+
const username = useCurrentUserState(s => s.username)
587609

588610
const {barStyle, isDarkMode} = useDarkModeState(
589611
C.useShallow(s => {
@@ -597,13 +619,28 @@ function NativeRouter() {
597619
return {barStyle, isDarkMode}
598620
})
599621
)
622+
600623
const bar = barStyle === 'default' ? null : <StatusBar barStyle={barStyle} />
601624
// Android also remounts on dark mode changes
602625
const nativeIsDarkMode = useColorScheme() === 'dark'
603626
const navKey = Common.useUserSwitchNavKey()
604627
const nativeDarkSuffix = isAndroid ? (nativeIsDarkMode ? '-dark' : '-light') : ''
605628
const rootKey = navKey ? `${navKey}${nativeDarkSuffix}` : ''
606629

630+
React.useEffect(() => {
631+
if (!userSwitching) {
632+
clearPendingAccountSwitch(username)
633+
}
634+
}, [userSwitching, username])
635+
636+
const onNativeReady = () => {
637+
onStateChange()
638+
const tab = consumePendingAccountSwitchTab(username)
639+
if (tab) {
640+
C.Router2.switchTab(tab)
641+
}
642+
}
643+
607644
if (!loggedInLoaded || (loggedIn && !startupLoaded)) {
608645
return (
609646
<Kb.Box2 direction="vertical" style={Kb.Styles.globalStyles.fillAbsolute}>
@@ -621,7 +658,7 @@ function NativeRouter() {
621658
// Sync the initial state from the linking config into the router store.
622659
// onStateChange doesn't fire for the initial state, so this ensures
623660
// onRouteChanged runs and conversation data gets loaded on startup.
624-
onReady={onStateChange}
661+
onReady={onNativeReady}
625662
onStateChange={onStateChange}
626663
onUnhandledAction={onUnhandledAction}
627664
ref={setNavRef}

0 commit comments

Comments
 (0)