diff --git a/shared/crypto/sub-nav/index.tsx b/shared/crypto/sub-nav/index.tsx index 3a7571d2a8d6..d7bc231e9215 100644 --- a/shared/crypto/sub-nav/index.tsx +++ b/shared/crypto/sub-nav/index.tsx @@ -5,15 +5,9 @@ import * as Kb from '@/common-adapters' import * as Common from '@/router-v2/common' import * as TestIDs from '@/tests/e2e/shared/test-ids' import NavRow from './nav-row' -import { - useNavigationBuilder, - TabRouter, - createNavigatorFactory, -} from '@react-navigation/core' -import type {TypedNavigator, NavigatorTypeBagBase} from '@react-navigation/native' -import {routeMapToScreenElements} from '@/router-v2/routes' +import {useNavigationBuilder, TabRouter, createNavigatorFactory} from '@react-navigation/core' +import {routeMapToStaticScreens} from '@/router-v2/routes' import {makeLayout} from '@/router-v2/screen-layout' -import type {RouteDef, GetOptionsParams} from '@/constants/types/router' import {defineRouteMap} from '@/constants/types/router' import LeftNav from './left-nav.desktop' @@ -86,35 +80,19 @@ function LeftTabNavigator({ ) } -type NavType = NavigatorTypeBagBase & { - ParamList: { - [key in keyof typeof cryptoSubRoutes]: {} - } -} +// The factory's static-config call signature is hidden by our custom-navigator typing, so +// re-surface it with a cast. Screens come from the same route-map converter the root uses. +const createLeftTabNavigator = createNavigatorFactory(LeftTabNavigator) as unknown as (config: { + backBehavior: 'none' + initialRouteName: string + screens: ReturnType +}) => {getComponent: () => React.ComponentType} -const createLeftTabNavigator = createNavigatorFactory(LeftTabNavigator) as unknown as () => TypedNavigator -const TabNavigator = createLeftTabNavigator() -const makeOptions = (rd: RouteDef) => { - return ({route, navigation}: GetOptionsParams) => { - const no = rd.getOptions - const opt = typeof no === 'function' ? no({navigation, route}) : no - return {...opt} - } -} -const cryptoScreens = routeMapToScreenElements( - cryptoSubRoutes, - TabNavigator.Screen, - makeLayout, - makeOptions, - false, - false, - false -) -const DesktopCryptoSubNavigator = () => ( - - {cryptoScreens} - -) +const DesktopCryptoSubNavigator = createLeftTabNavigator({ + backBehavior: 'none', + initialRouteName: Crypto.encryptTab, + screens: routeMapToStaticScreens(cryptoSubRoutes, makeLayout, false, false, false), +}).getComponent() const NativeCryptoSubNav = () => { const {navigate} = C.useNav() diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index 672ac49d6c36..5513d7dbc2b8 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -4,16 +4,10 @@ import {useConfigState} from '@/stores/config' import * as Kb from '@/common-adapters' import * as React from 'react' import type * as T from '@/constants/types' -import {settingsLogOutTab} from '@/constants/settings' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {navToProfile} from '@/constants/router' -const prepareAccountRows = ( - accountRows: ReadonlyArray, - myUsername: string -): Array => accountRows.filter(account => account.username !== myUsername) - const AccountSwitcher = () => { const _fullnames = useUsersState(s => s.infoMap) const _accountRows = useConfigState(s => s.configuredAccounts) @@ -21,10 +15,6 @@ const AccountSwitcher = () => { const fullname = _fullnames.get(you)?.fullname ?? '' const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin) const onLoginAsAnotherUser = useConfigState(s => s.dispatch.logoutToLoggedOutFlow) - const navigateUp = C.Router2.navigateUp - const onCancel = () => { - navigateUp() - } const setUserSwitching = useConfigState(s => s.dispatch.setUserSwitching) const login = useConfigState(s => s.dispatch.login) @@ -33,19 +23,14 @@ const AccountSwitcher = () => { login(username, '') } const onSelectAccountLoggedOut = useConfigState(s => s.dispatch.logoutAndTryToLogInAs) - const navigateAppend = C.Router2.navigateAppend - const onSignOut = () => { - navigateAppend({name: settingsLogOutTab, params: {}}) - } - const accountRows = prepareAccountRows(_accountRows, you) + const accountRows = _accountRows.filter(account => account.username !== you) const props = { accountRows: accountRows.map(account => ({ account: account, fullName: (_fullnames.get(account.username) || {fullname: ''}).fullname || '', })), fullname, - onCancel, onLoginAsAnotherUser, onProfileClick: () => navToProfile(you), onSelectAccount: (username: string) => { @@ -53,28 +38,25 @@ const AccountSwitcher = () => { const loggedIn = (rows.length && rows[0]?.hasStoredSecret) ?? false return loggedIn ? onSelectAccountLoggedIn(username) : onSelectAccountLoggedOut(username) }, - onSignOut, username: you, waiting, } return ( - <> - - - {isMobile && } - - {isMobile ? ( + + + {isMobile && } + + {isMobile ? ( + + ) : ( + - ) : ( - - - - )} - {props.accountRows.length > 0 && !isMobile && } - - - + + )} + {props.accountRows.length > 0 && !isMobile && } + + ) } @@ -87,10 +69,8 @@ type Props = { accountRows: Array fullname: string onLoginAsAnotherUser: () => void - onCancel: () => void onProfileClick: () => void onSelectAccount: (username: string) => void - onSignOut: () => void username: string waiting: boolean } diff --git a/shared/router-v2/common.tsx b/shared/router-v2/common.tsx index 189cfff59108..ae8d6b37338a 100644 --- a/shared/router-v2/common.tsx +++ b/shared/router-v2/common.tsx @@ -1,11 +1,14 @@ -import type * as React from 'react' +import * as React from 'react' import * as Kb from '@/common-adapters' +import * as Tabs from '@/constants/tabs' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {TabActions, type NavigationContainerRef} from '@react-navigation/core' import type {ParamListBase} from '@react-navigation/native' import type {HeaderOptions} from '@react-navigation/elements' import type {NativeStackHeaderProps} from '@react-navigation/native-stack' import {HeaderLeftButton} from '@/common-adapters/header-buttons' import type {NavState} from '@/constants/router' +import {useCurrentUserState} from '@/stores/current-user' import Header from './header/index' export const headerDefaultStyle = isMobile @@ -26,6 +29,33 @@ export const tabBarStyle = { export const tabBarBlurEffect = isMobile ? ('systemDefault' as const) : undefined export const tabBarMinimizeBehavior = undefined +export const tabToTestID = new Map([ + [Tabs.peopleTab, TestIDs.NAV_TAB_PEOPLE], + [Tabs.chatTab, TestIDs.NAV_TAB_CHAT], + [Tabs.fsTab, TestIDs.NAV_TAB_FILES], + [Tabs.cryptoTab, TestIDs.NAV_TAB_CRYPTO], + [Tabs.teamsTab, TestIDs.NAV_TAB_TEAMS], + [Tabs.gitTab, TestIDs.NAV_TAB_GIT], + [Tabs.devicesTab, TestIDs.NAV_TAB_DEVICES], + [Tabs.settingsTab, TestIDs.NAV_TAB_SETTINGS], +]) + +// Remount the navigator when switching between two logged-in users. +// Ignore '' → username (initial login) so in-flight unbox requests aren't interrupted. +export const useUserSwitchNavKey = () => { + const username = useCurrentUserState(s => s.username) + const [navKey, setNavKey] = React.useState('') + const prevUsernameRef = React.useRef(username) + React.useEffect(() => { + const prev = prevUsernameRef.current + prevUsernameRef.current = username + if (prev && username && prev !== username) { + setNavKey(username) + } + }, [username]) + return navKey +} + const actionWidth = 64 const DEBUGCOLORS = __DEV__ && (false as boolean) @@ -119,43 +149,14 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ type SubnavNavigation = Pick, 'dispatch' | 'emit'> export const useSubnavTabAction = (navigation: SubnavNavigation, state: NavState) => { - if (!isMobile) { - const routesRef = {current: state?.routes} - const stateKeyRef = {current: state?.key} - const navRef = {current: navigation} - - const onSelectTab = (tab: string) => { - const r = routesRef.current?.find((r: {name?: string; key?: string}) => { - return r.name === tab - }) - - const key = r?.key ?? '' - const event = key - ? navRef.current.emit({ - canPreventDefault: true, - target: key, - // @ts-expect-error tabPress is valid but not in the emit type - type: 'tabPress', - }) - : {defaultPrevented: false} - - if (!event.defaultPrevented) { - navRef.current.dispatch({ - ...TabActions.jumpTo(tab), - target: stateKeyRef.current, - }) - } - } - return onSelectTab - } - const onSelectTab = (tab: string) => { const routes = state && 'routes' in state ? state.routes : undefined const route = routes?.find((r: {name?: string; key?: string}) => r.name === tab) - const event = route + const key = route?.key + const event = key ? navigation.emit({ canPreventDefault: true, - target: route.key, + target: key, // @ts-expect-error tabPress is valid but not in the emit type type: 'tabPress', }) diff --git a/shared/router-v2/header/index.desktop.tsx b/shared/router-v2/header/index.desktop.tsx index 6078db9c9510..44564715d8f8 100644 --- a/shared/router-v2/header/index.desktop.tsx +++ b/shared/router-v2/header/index.desktop.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import type * as React from 'react' import * as Kb from '@/common-adapters' import * as Platform from '@/constants/platform' import SyncingFolders from './syncing-folders' @@ -29,18 +29,11 @@ type RawOptions = { headerStyle?: Kb.Styles.CollapsibleStyle } -type Options = { - headerMode?: string - title?: React.ReactNode +// Same as RawOptions but with the component-constructor variants already rendered to nodes +type Options = Omit & { headerTitle?: React.ReactNode - headerLeft?: React.ReactNode | ((props: HeaderBackButtonProps) => React.ReactNode) - headerRight?: React.ReactNode | ((p: {tintColor?: string}) => React.ReactNode) headerRightActions?: React.ReactNode subHeader?: React.ReactNode - headerTransparent?: boolean - headerShadowVisible?: boolean - headerBottomStyle?: Kb.Styles.StylesCrossPlatform - headerStyle?: Kb.Styles.CollapsibleStyle } // A mobile-like header for desktop @@ -52,7 +45,6 @@ type Props = { back?: boolean style?: Kb.Styles._StylesCrossPlatform useNativeFrame: boolean - params?: unknown isMaximized: boolean navigation: { pop: () => void @@ -170,8 +162,6 @@ function DesktopHeader(p: Props) { ? Kb.Styles.globalColors.black_10 : Kb.Styles.globalColors.transparent - const popupAnchor = React.createRef() - const defaultBackButton = ( {/* TODO have headerLeft be the back button */} {backButton} @@ -396,6 +385,7 @@ const styles = Kb.Styles.styleSheetCreate( type HeaderProps = Omit & { back?: NativeStackHeaderProps['back'] options: RawOptions + params?: unknown } function DesktopHeaderWrapper(p: HeaderProps) { @@ -446,7 +436,6 @@ function DesktopHeaderWrapper(p: HeaderProps) { options={options} back={!!back /* not a bool upstream */} style={style} - params={params} navigation={navigation} /> ) diff --git a/shared/router-v2/header/syncing-folders.tsx b/shared/router-v2/header/syncing-folders.tsx index 798e5658c896..ceb440a07535 100644 --- a/shared/router-v2/header/syncing-folders.tsx +++ b/shared/router-v2/header/syncing-folders.tsx @@ -16,10 +16,10 @@ type Props = { const SyncingFolders = (props: Props) => props.show && props.progress !== 1.0 ? ( - + - + Syncing folders... @@ -32,7 +32,7 @@ const SyncFolders = (op: OwnProps) => { const {negative} = op if (syncingFoldersProgress.bytesTotal === 0) { - return + return null } const progress = syncingFoldersProgress.bytesFetched / syncingFoldersProgress.bytesTotal @@ -42,4 +42,10 @@ const SyncFolders = (op: OwnProps) => { ) return } + +const styles = Kb.Styles.styleSheetCreate(() => ({ + text: {marginLeft: 5}, + tooltipContainer: {alignSelf: 'center'}, +})) + export default SyncFolders diff --git a/shared/router-v2/hooks.native.tsx b/shared/router-v2/hooks.native.tsx deleted file mode 100644 index e86b7b8389a6..000000000000 --- a/shared/router-v2/hooks.native.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react' -import {useColorScheme} from 'react-native' -import {useCurrentUserState} from '@/stores/current-user' - -// Rerender everything on user switch, and on Android also on dark mode changes. -// Only switch on transitions between two non-empty usernames — not on initial login. -export const useRootKey = () => { - const isDarkMode = useColorScheme() === 'dark' - const username = useCurrentUserState(s => s.username) - const [navKey, setNavKey] = React.useState('') - const prevUsernameRef = React.useRef(username) - React.useEffect(() => { - const prev = prevUsernameRef.current - prevUsernameRef.current = username - if (prev && username && prev !== username) { - setNavKey(username) - } - }, [username]) - const darkSuffix = isAndroid ? (isDarkMode ? '-dark' : '-light') : '' - return navKey ? `${navKey}${darkSuffix}` : '' -} diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index c76bcd5a0138..7c109c757503 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -18,13 +18,10 @@ import {useDaemonState} from '@/stores/daemon' import {LoadedTeamsListProvider} from '@/teams/use-teams-list' import {makeLayout} from './screen-layout' import {createNativeStackNavigator} from '@react-navigation/native-stack' -import * as TestIDs from '@/tests/e2e/shared/test-ids' import type {NativeStackNavigationOptions} from '@react-navigation/native-stack' import type {SFSymbol} from 'sf-symbols-typescript' import type {NavigationProp} from '@react-navigation/native' import type {RootParamList} from './route-params' -import {useCurrentUserState} from '@/stores/current-user' -import * as Constants from '@/constants/router' import {useNotifState} from '@/stores/notifications' import {usePushState} from '@/stores/push' import {colors, darkColors} from '@/styles/colors' @@ -37,7 +34,7 @@ const isIOS17Plus = isIOS && parseInt(Platform.Version as string, 10) >= 17 // Tell the router constants which root-stack routes are modals (vs genuinely-visible // pushed screens like chatConversation). modalRoutes is the single source of truth. -Constants.setModalRouteNames(Object.keys(modalRoutes)) +C.Router2.setModalRouteNames(Object.keys(modalRoutes)) function SimpleLoading() { return ( @@ -52,39 +49,37 @@ function SimpleLoading() { ) } -const darkTheme: Theme = { +const makeTheme = (palette: {white: string; black: string; black_10: string}, dark: boolean): Theme => ({ colors: { - background: darkColors.white, - border: darkColors.black_10, - card: darkColors.white, - notification: darkColors.black, - primary: darkColors.black, - text: darkColors.black, + background: palette.white, + border: palette.black_10, + card: palette.white, + notification: palette.black, + primary: palette.black, + text: palette.black, }, - dark: true, + dark, fonts: { bold: Kb.Styles.globalStyles.fontBold, heavy: Kb.Styles.globalStyles.fontExtrabold, medium: Kb.Styles.globalStyles.fontSemibold, regular: Kb.Styles.globalStyles.fontRegular, }, +}) +const darkTheme = makeTheme(darkColors, true) +const lightTheme = makeTheme(colors, false) + +// Shared NavigationContainer plumbing (identical on both platforms) +const onUnhandledAction = (a: Readonly<{type: string}>) => { + logger.info(`[NAV] Unhandled action: ${a.type}`, a, C.Router2.logState()) } -const lightTheme: Theme = { - colors: { - background: colors.white, - border: colors.black_10, - card: colors.white, - notification: colors.black, - primary: colors.black, - text: colors.black, - }, - dark: false, - fonts: { - bold: Kb.Styles.globalStyles.fontBold, - heavy: Kb.Styles.globalStyles.fontExtrabold, - medium: Kb.Styles.globalStyles.fontSemibold, - regular: Kb.Styles.globalStyles.fontRegular, - }, +const onStateChange = () => { + C.useRouterState.getState().dispatch.setNavState(C.Router2.getRootState()) +} +const setNavRef = (ref: typeof C.Router2.navigationRef.current) => { + if (ref) { + C.Router2.navigationRef.current = ref + } } // ─── Desktop ────────────────────────────────────────────────────────────────── @@ -119,16 +114,14 @@ const useHandshakeEverDone = () => { }) } -let desktopTab: LeftTabNavigatorType | undefined -const desktopTabComponents: Record = {} let DesktopRootComponent: React.ComponentType -let LoggedOutDesktop: React.ComponentType if (!isMobile) { const {createLeftTabNavigator} = require('./left-tab-navigator.desktop') as { createLeftTabNavigator: () => LeftTabNavigatorType } - desktopTab = createLeftTabNavigator() + const desktopTab = createLeftTabNavigator() + const desktopTabComponents: Record = {} const desktopTabScreensConfig = routeMapToStaticScreens(routes, makeLayout, false, false, true) @@ -152,27 +145,28 @@ if (!isMobile) { desktopTabComponents[tab] = nav.getComponent() } - // Keep appTabsInnerOptions stable (defined above before the loop) - const capturedOptions = appTabsInnerOptions - const capturedTab = desktopTab - - function AppTabsInnerDesktop() { + function AppTabsDesktop() { return ( - + {Tabs.desktopTabs.map(tab => ( - + ))} - + ) } - const AppTabsDesktop = () => type DesktopHeaderProps = Record & {options: Record} const DesktopHeaderComponent = ( require('./header/index.desktop') as {default: React.ComponentType} ).default - const desktopLoggedOutScreensConfig = routeMapToStaticScreens(loggedOutRoutes, makeLayout, false, true, false) + const desktopLoggedOutScreensConfig = routeMapToStaticScreens( + loggedOutRoutes, + makeLayout, + false, + true, + false + ) const desktopLoggedOutOptions = { header: (p: Record) => { const options = { @@ -189,7 +183,7 @@ if (!isMobile) { screenOptions: desktopLoggedOutOptions, screens: desktopLoggedOutScreensConfig, }) - LoggedOutDesktop = loggedOutNav.getComponent() + const LoggedOutDesktop = loggedOutNav.getComponent() const desktopRootScreenOptions = { headerLeft: () => , @@ -272,35 +266,8 @@ const useConnectNavToState = () => { function DesktopRouter() { useConnectNavToState() - const onUnhandledAction = (a: Readonly<{type: string}>) => { - logger.info(`[NAV] Unhandled action: ${a.type}`, a, C.Router2.logState()) - } - - const setNavState = C.useRouterState(s => s.dispatch.setNavState) - const onStateChange = () => { - const ns = C.Router2.getRootState() - setNavState(ns) - } - - const navRef = (ref: typeof C.Router2.navigationRef.current) => { - if (ref) { - C.Router2.navigationRef.current = ref - } - } - const isDarkMode = useDarkModeState(s => s.isDarkMode()) - const username = useCurrentUserState(s => s.username) - // Only remount the navigator when switching between logged-in users. - // Ignore '' → username (initial login) so in-flight unbox requests aren't interrupted. - const [navKey, setNavKey] = React.useState('') - const prevUsernameRef = React.useRef(username) - React.useEffect(() => { - const prev = prevUsernameRef.current - prevUsernameRef.current = username - if (prev && username && prev !== username) { - setNavKey(username) - } - }, [username]) + const navKey = Common.useUserSwitchNavKey() const documentTitle = { formatter: () => { @@ -317,7 +284,7 @@ function DesktopRouter() { documentTitle={documentTitle} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} - ref={navRef} + ref={setNavRef} theme={isDarkMode ? darkTheme : lightTheme} > @@ -329,10 +296,9 @@ function DesktopRouter() { // ─── Native ─────────────────────────────────────────────────────────────────── -if (isMobile) { - if (module.hot) { - module.hot.accept('', () => {}) - } +// Self-accept HMR so an edit here doesn't bubble up and reload the whole app +if (isMobile && module.hot) { + module.hot.accept() } const tabToLabel = new Map([ @@ -343,14 +309,6 @@ const tabToLabel = new Map([ [Tabs.settingsTab, 'More'], ]) -const tabToTestID = new Map([ - [Tabs.chatTab, TestIDs.NAV_TAB_CHAT], - [Tabs.fsTab, TestIDs.NAV_TAB_FILES], - [Tabs.teamsTab, TestIDs.NAV_TAB_TEAMS], - [Tabs.peopleTab, TestIDs.NAV_TAB_PEOPLE], - [Tabs.settingsTab, TestIDs.NAV_TAB_SETTINGS], -]) - // just to get badge rollups const nativeTabs = C.isTablet ? Tabs.tabletTabs : Tabs.phoneTabs const settingsTabChildren = [Tabs.gitTab, Tabs.devicesTab, Tabs.settingsTab] as const @@ -377,32 +335,29 @@ const phoneRootRoutes = Object.fromEntries( const nativeTabComponents: Record = {} if (isMobile) { - const nativeTabScreensConfig = routeMapToStaticScreens(routes, makeLayout, false, false, true) + // Tablet tab stacks hold every route; phone tab stacks hold only their root screen + // (everything else lives in the root stack so it renders above the tab bar). + const tabletScreensConfig = C.isTablet + ? routeMapToStaticScreens(routes, makeLayout, false, false, true) + : undefined for (const tab of nativeTabs) { - if (C.isTablet) { - const nav = createNativeStackNavigator({ - initialRouteName: tabRoots[tab], - screenOptions: tabStackOptions, - screens: nativeTabScreensConfig, - }) - nativeTabComponents[tab] = nav.getComponent() - } else { - const rootName = tabRoots[tab] - const rootScreenConfig = routeMapToStaticScreens( + const rootName = tabRoots[tab] + const screens = + tabletScreensConfig ?? + routeMapToStaticScreens( {[rootName]: routes[rootName as keyof typeof routes]} as typeof routes, makeLayout, false, false, true ) - const nav = createNativeStackNavigator({ - initialRouteName: rootName, - screenOptions: tabStackOptions, - screens: rootScreenConfig, - }) - nativeTabComponents[tab] = nav.getComponent() - } + const nav = createNativeStackNavigator({ + initialRouteName: rootName, + screenOptions: tabStackOptions, + screens, + }) + nativeTabComponents[tab] = nav.getComponent() } } @@ -497,48 +452,51 @@ const appTabsScreenOptions = ( }), tabBarIcon: getNativeTabIcon(routeName), tabBarLabel: tabToLabel.get(routeName) ?? routeName, - tabBarTestID: tabToTestID.get(routeName), + tabBarTestID: Common.tabToTestID.get(routeName), tabBarLabelVisibilityMode: 'labeled' as const, tabBarStyle: {backgroundColor: isDarkMode ? colors.greyDarkest : colors.blueDark}, title: tabToLabel.get(routeName) ?? routeName, } } +const NativeTab = createBottomTabNavigator() + function AppTabsNative() { - const Tab = React.useMemo(() => createBottomTabNavigator(), []) const navBadges = useNotifState(s => s.navBadges) const hasPermissions = usePushState(s => s.hasPermissions) const isDarkMode = useDarkModeState(s => s.isDarkMode()) return ( - + {nativeTabs.map(tab => ( - ))} - + ) } -let NativeLoggedOut: React.ComponentType let NativeRootComponent: React.ComponentType if (isMobile) { - const nativeLoggedOutScreensConfig = routeMapToStaticScreens(loggedOutRoutes, makeLayout, false, true, false) - const nativeLoggedOutScreenOptions = { - ...Common.defaultNavigationOptions, - } as NativeStackNavigationOptions + const nativeLoggedOutScreensConfig = routeMapToStaticScreens( + loggedOutRoutes, + makeLayout, + false, + true, + false + ) const loggedOutNav = createNativeStackNavigator({ initialRouteName: 'login', - screenOptions: nativeLoggedOutScreenOptions, + screenOptions: Common.defaultNavigationOptions as NativeStackNavigationOptions, screens: nativeLoggedOutScreensConfig, }) - NativeLoggedOut = loggedOutNav.getComponent() + const NativeLoggedOut = loggedOutNav.getComponent() const rootStackScreenOptions = { headerBackButtonDisplayMode: 'minimal', @@ -617,25 +575,6 @@ function NativeRouter() { const {loggedIn, startupLoaded} = useConfigState( C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded})) ) - const setNavState = C.useRouterState(s => s.dispatch.setNavState) - const onStateChange = () => { - const ns = C.Router2.getRootState() - setNavState(ns) - } - // 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. - const onReady = onStateChange - - const onUnhandledAction = (a: Readonly<{type: string}>) => { - logger.info(`[NAV] Unhandled action: ${a.type}`, a, C.Router2.logState()) - } - - const navRef = (ref: typeof Constants.navigationRef.current) => { - if (ref) { - Constants.navigationRef.current = ref - } - } const {barStyle, isDarkMode} = useDarkModeState( C.useShallow(s => { @@ -650,20 +589,11 @@ function NativeRouter() { }) ) const bar = barStyle === 'default' ? null : - // Inline useRootKey (from hooks.native.tsx — can't require *.native files from shared code) + // Android also remounts on dark mode changes const nativeIsDarkMode = useColorScheme() === 'dark' - const nativeUsername = useCurrentUserState(s => s.username) - const [nativeNavKey, setNativeNavKey] = React.useState('') - const nativePrevUsernameRef = React.useRef(nativeUsername) - React.useEffect(() => { - const prev = nativePrevUsernameRef.current - nativePrevUsernameRef.current = nativeUsername - if (prev && nativeUsername && prev !== nativeUsername) { - setNativeNavKey(nativeUsername) - } - }, [nativeUsername]) + const navKey = Common.useUserSwitchNavKey() const nativeDarkSuffix = isAndroid ? (nativeIsDarkMode ? '-dark' : '-light') : '' - const rootKey = nativeNavKey ? `${nativeNavKey}${nativeDarkSuffix}` : '' + const rootKey = navKey ? `${navKey}${nativeDarkSuffix}` : '' if (!loggedInLoaded || (loggedIn && !startupLoaded)) { return ( @@ -679,10 +609,13 @@ function NativeRouter() { } linking={loggedIn ? nativeLinkingConfig : undefined} - onReady={onReady} + // 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} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} - ref={navRef} + ref={setNavRef} theme={isDarkMode ? darkTheme : lightTheme} > diff --git a/shared/router-v2/routes.tsx b/shared/router-v2/routes.tsx index d2b85612aa81..d98138659679 100644 --- a/shared/router-v2/routes.tsx +++ b/shared/router-v2/routes.tsx @@ -17,6 +17,17 @@ import {defineRouteMap} from '@/constants/types/router' import type {GetOptions, GetOptionsParams, GetOptionsRet, RouteDef} from '@/constants/types/router' import type {NativeStackNavigationOptions} from '@react-navigation/native-stack' +// The spread silently keeps the last route on a name collision, so assert uniqueness in dev +const assertNoDuplicateRouteNames = (...maps: Array>) => { + const seen = new Set() + for (const m of maps) { + for (const k of Object.keys(m)) { + if (seen.has(k)) throw new Error('Duplicate route name: ' + k) + seen.add(k) + } + } +} + // We have normal routes, modal routes, and logged out routes. // We also end up using existence of a nameToTab value for a route as a test // of whether we're on a loggedIn route: loggedOut routes have no selected tab. @@ -33,22 +44,17 @@ export const routes = defineRouteMap({ }) if (__DEV__) { - const allRouteKeys = [ - ...Object.keys(deviceNewRoutes), - ...Object.keys(chatNewRoutes), - ...Object.keys(cryptoNewRoutes), - ...Object.keys(peopleNewRoutes), - ...Object.keys(profileNewRoutes), - ...Object.keys(fsNewRoutes), - ...Object.keys(settingsNewRoutes), - ...Object.keys(teamsNewRoutes), - ...Object.keys(gitNewRoutes), - ] - const seen = new Set() - for (const k of allRouteKeys) { - if (seen.has(k)) throw new Error('Duplicate route name: ' + k) - seen.add(k) - } + assertNoDuplicateRouteNames( + deviceNewRoutes, + chatNewRoutes, + cryptoNewRoutes, + peopleNewRoutes, + profileNewRoutes, + fsNewRoutes, + settingsNewRoutes, + teamsNewRoutes, + gitNewRoutes + ) } export const tabRoots = { @@ -82,26 +88,21 @@ export const modalRoutes = defineRouteMap({ }) if (__DEV__) { - const allModalKeys = [ - ...Object.keys(chatNewModalRoutes), - ...Object.keys(cryptoNewModalRoutes), - ...Object.keys(deviceNewModalRoutes), - ...Object.keys(fsNewModalRoutes), - ...Object.keys(gitNewModalRoutes), - ...Object.keys(loginNewModalRoutes), - ...Object.keys(peopleNewModalRoutes), - ...Object.keys(profileNewModalRoutes), - ...Object.keys(settingsNewModalRoutes), - ...Object.keys(signupNewModalRoutes), - ...Object.keys(teamsNewModalRoutes), - ...Object.keys(walletsNewModalRoutes), - ...Object.keys(incomingShareNewModalRoutes), - ] - const seen = new Set() - for (const k of allModalKeys) { - if (seen.has(k)) throw new Error('Duplicate modal route name: ' + k) - seen.add(k) - } + assertNoDuplicateRouteNames( + chatNewModalRoutes, + cryptoNewModalRoutes, + deviceNewModalRoutes, + fsNewModalRoutes, + gitNewModalRoutes, + loginNewModalRoutes, + peopleNewModalRoutes, + profileNewModalRoutes, + settingsNewModalRoutes, + signupNewModalRoutes, + teamsNewModalRoutes, + walletsNewModalRoutes, + incomingShareNewModalRoutes + ) } export const loggedOutRoutes = defineRouteMap({..._loggedOutRoutes, ...signupNewRoutes}) @@ -117,7 +118,6 @@ type MakeLayoutFn = ( isTabScreen: boolean, getOptions?: GetOptions ) => LayoutFn -type MakeOptionsFn = (rd: RouteDef) => (params: GetOptionsParams) => GetOptionsRet type CheckedRouteEntry> = Routes[keyof Routes] function toNavOptions(opts: GetOptionsRet): NativeStackNavigationOptions { @@ -157,27 +157,3 @@ export function routeMapToStaticScreens>( - rs: RS, - Screen: React.ComponentType, - makeLayoutFn: MakeLayoutFn, - makeOptionsFn: MakeOptionsFn, - isModal: boolean, - isLoggedOut: boolean, - isTabScreen: boolean -) { - return (Object.keys(rs) as Array).flatMap(name => { - const rd = rs[name] as CheckedRouteEntry - return [ - , - ] - }) -} diff --git a/shared/router-v2/screen-layout.tsx b/shared/router-v2/screen-layout.tsx index 68f70ffba01a..3ae8c4b023b3 100644 --- a/shared/router-v2/screen-layout.tsx +++ b/shared/router-v2/screen-layout.tsx @@ -23,24 +23,10 @@ type LayoutProps = { // Native-only wrapper components -const TabScreenWrapper = ({children}: {children: React.ReactNode}) => { - if (isAndroid) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) -} - -const StackScreenWrapper = ({children}: {children: React.ReactNode}) => { - // Android targets SDK 35+ which enforces edge-to-edge, so content draws under - // the system nav bar unless we apply the bottom inset ourselves. +// Wraps both tab-root and pushed stack screens. Android targets SDK 35+ which enforces +// edge-to-edge, so content draws under the system nav bar unless we apply the bottom +// inset ourselves. +const ScreenWrapper = ({children}: {children: React.ReactNode}) => { if (isAndroid) { return ( @@ -123,7 +109,7 @@ const desktopMakeLayout = ( const nativeMakeLayout = ( isModal: boolean, isLoggedOut: boolean, - isTabScreen: boolean, + _isTabScreen: boolean, getOptions?: GetOptions ) => { const modalOffset = isIOS ? 40 : 0 @@ -132,11 +118,8 @@ const nativeMakeLayout = ( const wrappedContent = {children} - if (!isModal && !isLoggedOut && isTabScreen) { - return {wrappedContent} - } if (!isModal && !isLoggedOut) { - return {wrappedContent} + return {wrappedContent} } if (!isModal && isLoggedOut) { return {wrappedContent} diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 70841e86893f..2e896273464a 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -6,7 +6,6 @@ import * as Platforms from '@/constants/platform' import * as T from '@/constants/types' import * as React from 'react' import * as Tabs from '@/constants/tabs' -import * as TestIDs from '@/tests/e2e/shared/test-ids' import * as Common from './common' import {CommonActions} from '@react-navigation/core' import AccountSwitcher from './account-switcher' @@ -158,17 +157,6 @@ const Header = () => { ) } -const tabTestIDs = new Map([ - [Tabs.peopleTab, TestIDs.NAV_TAB_PEOPLE], - [Tabs.chatTab, TestIDs.NAV_TAB_CHAT], - [Tabs.fsTab, TestIDs.NAV_TAB_FILES], - [Tabs.cryptoTab, TestIDs.NAV_TAB_CRYPTO], - [Tabs.teamsTab, TestIDs.NAV_TAB_TEAMS], - [Tabs.gitTab, TestIDs.NAV_TAB_GIT], - [Tabs.devicesTab, TestIDs.NAV_TAB_DEVICES], - [Tabs.settingsTab, TestIDs.NAV_TAB_SETTINGS], -]) - const keysMap = Tabs.desktopTabs.reduce<{[key: string]: (typeof Tabs.desktopTabs)[number]}>( (map, tab, index) => { map[`mod+${index + 1}`] = tab @@ -287,7 +275,7 @@ function Tab(props: TabProps) { onMouseLeave={onMouseLeave} direction="horizontal" fullWidth={true} - testID={tabTestIDs.get(tab)} + testID={Common.tabToTestID.get(tab)} className={Kb.Styles.classNames( isSelected ? 'tab-selected' : 'tab', 'tab-tooltip', diff --git a/shared/settings/root-desktop-tablet.tsx b/shared/settings/root-desktop-tablet.tsx index 66e92cfd1ab9..63f7cf50fb92 100644 --- a/shared/settings/root-desktop-tablet.tsx +++ b/shared/settings/root-desktop-tablet.tsx @@ -1,15 +1,12 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as Common from '@/router-v2/common' -import {routeMapToScreenElements} from '@/router-v2/routes' +import {routeMapToStaticScreens} from '@/router-v2/routes' import {makeLayout} from '@/router-v2/screen-layout' -import type {RouteDef, GetOptionsParams} from '@/constants/types/router' import LeftNav from './sub-nav/left-nav' import {useNavigationBuilder, TabRouter, createNavigatorFactory} from '@react-navigation/core' -import type {TypedNavigator, NavigatorTypeBagBase} from '@react-navigation/native' import {settingsDesktopTabRoutes} from './routes' import {settingsAccountTab} from '@/constants/settings' -import type {SettingsAccountRouteParams} from './routes' function LeftTabNavigator({ initialRouteName, @@ -72,40 +69,21 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ nav: {width: Kb.Styles.isTablet ? 200 : 180}, })) -type NavType = NavigatorTypeBagBase & { - ParamList: { - [K in keyof typeof settingsDesktopTabRoutes]: K extends typeof settingsAccountTab - ? SettingsAccountRouteParams | undefined - : undefined - } -} - -export const createLeftTabNavigator = createNavigatorFactory(LeftTabNavigator) as unknown as () => TypedNavigator -const TabNavigator = createLeftTabNavigator() -const makeOptions = (rd: RouteDef) => { - return ({route, navigation}: GetOptionsParams) => { - const no = rd.getOptions - const opt = typeof no === 'function' ? no({navigation, route}) : no - return {...opt} - } -} -const settingsScreens = routeMapToScreenElements( - settingsDesktopTabRoutes, - TabNavigator.Screen, - makeLayout, - makeOptions, - false, - false, - false -) +// The factory's static-config call signature is hidden by our custom-navigator typing, so +// re-surface it with a cast. Screens come from the same route-map converter the root uses. +const createLeftTabNavigator = createNavigatorFactory(LeftTabNavigator) as unknown as (config: { + backBehavior: 'none' + initialRouteName: string + screens: ReturnType +}) => {getComponent: () => React.ComponentType} // TODO on ipad this doesn't have a stack navigator so when you go into crypto you get // a push from the parent stack. If we care just make a generic left nav / right stack // that the global app / etc could use and put it here also. not worth it now -const SettingsSubNavigator = () => ( - - {settingsScreens} - -) +const SettingsSubNavigator = createLeftTabNavigator({ + backBehavior: 'none', + initialRouteName: settingsAccountTab, + screens: routeMapToStaticScreens(settingsDesktopTabRoutes, makeLayout, false, false, false), +}).getComponent() export default SettingsSubNavigator