Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions shared/fs/nav-header/mobile-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
*
Expand Down Expand Up @@ -57,9 +58,13 @@ const NavMobileHeaderInner = (props: Props) => {
return props.path === FS.defaultPath ? (
<Kb.SafeAreaViewTop>
<Kb.Box2 direction="vertical" fullWidth={true} style={styles.headerContainer}>
<Kb.Box2 direction="horizontal" fullWidth={true} centerChildren={true} gap="xtiny" style={styles.rootContainer}>
<Kb.Text type="BodyBig">Files</Kb.Text>
<FilesTabStatusIcon />
<Kb.Box2 direction="horizontal" fullWidth={true} alignItems="center" style={styles.rootContainer}>
<AccountSwitchHeaderAvatar />
<Kb.Box2 direction="horizontal" centerChildren={true} flex={1} gap="xtiny">
<Kb.Text type="BodyBig">Files</Kb.Text>
<FilesTabStatusIcon />
</Kb.Box2>
<Kb.Box2 direction="vertical" style={styles.rootSpacer} />
</Kb.Box2>
</Kb.Box2>
</Kb.SafeAreaViewTop>
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 0 additions & 17 deletions shared/people/routes.tsx
Original file line number Diff line number Diff line change
@@ -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 <Kb.Avatar size={32} username={myUsername} onClick={onClick} testID={TestIDs.PEOPLE_HEADER_AVATAR} />
}

export const newRoutes = defineRouteMap({
peopleRoot: {
getOptions: {
// iOS 26: hidesSharedBackground prevents the glass circle around the avatar
...(isIOS
? {
unstable_headerRightItems: () => [
{element: <HeaderAvatar />, hidesSharedBackground: true, type: 'custom' as const},
],
}
: {headerRight: isMobile ? () => <HeaderAvatar /> : undefined}),
headerTitle: () => <ProfileSearch />,
},
screen: React.lazy(async () => import('./container')),
Expand Down
3 changes: 3 additions & 0 deletions shared/router-v2/account-switch-header-avatar.desktop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const AccountSwitchHeaderAvatar = () => null

export default AccountSwitchHeaderAvatar
61 changes: 61 additions & 0 deletions shared/router-v2/account-switch-header-avatar.native.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
accessibilityHint={
recentAccount ? `Long press to switch to ${recentAccount.username}` : undefined
}
accessibilityLabel={`${username} account menu`}
accessibilityRole="button"
onLongPress={recentAccount && !userSwitching ? switchToRecentAccount : undefined}
onPress={openAccountSwitcher}
style={Kb.Styles.castStyleNative(styles.container)}
testID={TestIDs.PEOPLE_HEADER_AVATAR}
>
<Kb.Avatar size={32} username={username} />
</Pressable>
)
}

const styles = Kb.Styles.styleSheetCreate(() => ({
container: {
alignItems: 'center',
height: 44,
justifyContent: 'center',
width: 44,
},
}))

export default AccountSwitchHeaderAvatar
75 changes: 75 additions & 0 deletions shared/router-v2/account-switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/// <reference types="jest" />

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()
})
})
44 changes: 44 additions & 0 deletions shared/router-v2/account-switch.tsx
Original file line number Diff line number Diff line change
@@ -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<T.Config.ConfiguredAccount>,
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
}
}
24 changes: 19 additions & 5 deletions shared/router-v2/account-switcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
57 changes: 47 additions & 10 deletions shared/router-v2/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ? () => <AccountSwitchHeaderAvatar /> : undefined,
...(isIOS && !canGoBack
? {
unstable_headerLeftItems: () => [
{
element: <AccountSwitchHeaderAvatar />,
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.
Expand Down Expand Up @@ -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 => {
Expand All @@ -597,13 +619,28 @@ function NativeRouter() {
return {barStyle, isDarkMode}
})
)

const bar = barStyle === 'default' ? null : <StatusBar barStyle={barStyle} />
// Android also remounts on dark mode changes
const nativeIsDarkMode = useColorScheme() === 'dark'
const navKey = Common.useUserSwitchNavKey()
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 (
<Kb.Box2 direction="vertical" style={Kb.Styles.globalStyles.fillAbsolute}>
Expand All @@ -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}
Expand Down