diff --git a/shared/chat/inbox-and-conversation-shared.tsx b/shared/chat/inbox-and-conversation-shared.tsx index a97c68440f83..14cc052ca727 100644 --- a/shared/chat/inbox-and-conversation-shared.tsx +++ b/shared/chat/inbox-and-conversation-shared.tsx @@ -8,7 +8,6 @@ import Conversation from '@/chat/conversation/container' import InfoPanel, {type Panel} from '@/chat/conversation/info-panel' import type {ThreadSearchRouteProps} from '@/chat/conversation/thread-search-route' import {useInboxLayoutState} from '@/chat/inbox/layout-state' -import type {NavState} from '@/constants/router' import logger from '@/logger' export type InboxAndConversationProps = ThreadSearchRouteProps & { @@ -30,7 +29,7 @@ export function InboxAndConversationShell(props: Props) { const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey const lastValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') const chatTabSelected = C.useRouterState(s => { - const storedTab = C.Router2.getTab(s.navState as NavState | undefined) + const storedTab = C.Router2.getTab(s.navState) return (storedTab ?? C.Router2.getTab()) === C.Tabs.chatTab }) const firstSmallTeam = useInboxLayoutState(s => { diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 85394323a62e..fd69fc78416d 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -217,9 +217,7 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => } } -const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { - const next = nextNavState as Util.NavState - const prev = previousNavState as Util.NavState +const onNavStateChanged = (next: RouterState['navState'], prev: RouterState['navState']) => { if (prev === next) return // Clear critical update when we nav away from tab diff --git a/shared/constants/nav-tree.tsx b/shared/constants/nav-tree.tsx new file mode 100644 index 000000000000..2c26f443553b --- /dev/null +++ b/shared/constants/nav-tree.tsx @@ -0,0 +1,209 @@ +// The shape of the navigation tree, in one place. +// +// Every screen lives at one of three depths: +// root stack routes[0] is the root screen - the 'loggedIn' tab navigator when +// signed in, otherwise 'loggedOut' (or 'loading' on desktop); +// routes[1+] hold modals AND non-modal screens pushed above the +// tab bar on phones. +// tab navigator one route per tab; its index selects the visible tab. +// tab stack the screens pushed inside that tab, rooted at tabRoots[tab]. +// +// Readers take a plain NavState and are pure. Builders return a PartialNavState suitable +// for CommonActions.reset or React Navigation's linking getStateFromPath. Nothing here +// touches a navigator, dispatches, or knows anything about chat. +import * as Tabs from './tabs' +import type {Immutable} from 'immer' +import type {NavigationState} from '@react-navigation/core' +import type {RootParamList} from '@/router-v2/route-params' + +export type Route = NavigationState['routes'][0] +// still a little paranoid about some things being missing in this type +export type NavState = Partial + +export type PartialRoute = { + name: string + params?: Record + state?: PartialNavState +} + +export type PartialNavState = { + routes: Array + index?: number +} + +export type ScreenSpec = {name: string; params?: Record} + +// The root screen of each tab's stack. Kept here rather than with the route table so the +// tree shape has no dependency on the (very heavy) router config; router-v2/routes +// re-exports this for the navigator definitions. +export const tabRoots = { + [Tabs.peopleTab]: 'peopleRoot', + [Tabs.chatTab]: 'chatRoot', + [Tabs.cryptoTab]: 'cryptoRoot', + [Tabs.fsTab]: 'fsRoot', + [Tabs.teamsTab]: 'teamsRoot', + [Tabs.gitTab]: 'gitRoot', + [Tabs.devicesTab]: 'devicesRoot', + [Tabs.settingsTab]: 'settingsRoot', + + [Tabs.loginTab]: '', + [Tabs.searchTab]: '', +} as const + +// Modal route names, registered at startup from the router config (the single source +// of truth — see modalRoutes in router-v2/routes). A serialized NavigationState route +// does not carry its `presentation`, so we cannot detect modals structurally: a route +// living in the root stack (alongside the tab navigator) is a modal iff its name is in +// this set. Everything else there (e.g. chatConversation, and any other non-modal screen +// pushed above the tab bar on phones) is a genuinely-visible screen. +let modalRouteNames: ReadonlySet | undefined +export const setModalRouteNames = (names: Iterable) => { + modalRouteNames = new Set(names) +} +export const isModalRouteName = (name: string) => { + if (!modalRouteNames) { + throw new Error('modalRouteNames not registered; call setModalRouteNames at startup') + } + return modalRouteNames.has(name) +} + +// ---- Readers ---- + +export const isLoggedIn = (state?: Immutable) => state?.routes?.[0]?.name === 'loggedIn' + +export const currentTab = (state?: Immutable): Tabs.Tab | undefined => { + const loggedInRoute = state?.routes?.[0] + if (loggedInRoute?.name === 'loggedIn') { + // eslint-disable-next-line + return loggedInRoute.state?.routes?.[loggedInRoute.state.index ?? 0]?.name as Tabs.Tab + } + return undefined +} + +// The tab navigator's own state - routes[0] of the root stack. Undefined when logged out. +export const tabNavigatorState = (state?: Immutable): Immutable | undefined => + isLoggedIn(state) ? state?.routes?.[0]?.state : undefined + +// The routes in the root stack above the tab navigator that are real modals. +export const modalStack = (state?: Immutable): Immutable> => { + if (!state || !isLoggedIn(state)) { + return [] + } + return (state.routes?.slice(1) ?? []).filter(r => isModalRouteName(r.name)) as Immutable> +} + +// The innermost stack the user is looking at - the one a push/pop should target. +export const activeStack = (state?: Immutable): Immutable | undefined => { + const descend = (s: Immutable | undefined, depth: number): Immutable | undefined => { + if (!s?.routes || s.index === undefined) { + return undefined + } + if (depth === 0) { + const topModal = (s.routes.slice(1) as Array).filter(route => isModalRouteName(route.name)).at(-1) + if (topModal) { + return descend(topModal.state, depth + 1) ?? s + } + const loggedInRoute = s.routes[0] as Route | undefined + return descend(loggedInRoute?.state, depth + 1) ?? (s.type === 'stack' ? s : undefined) + } + const childRoute = s.routes[s.index] as Route | undefined + return descend(childRoute?.state, depth + 1) ?? (s.type === 'stack' ? s : undefined) + } + return descend(state, 0) +} + +// loggedIn/tab/stack items, plus whatever sits above the tab navigator. +export const visiblePath = ( + state?: Immutable, + opts?: {includeModals?: boolean} +): Immutable> => { + const includeModals = opts?.includeModals ?? true + + const findVisibleRoute = ( + arr: Immutable>, + s: Immutable, + depth: number + ): Immutable> => { + if (!s?.routes || s.index === undefined) { + return arr + } + let childRoute = s.routes[s.index] as Route | undefined + if (!childRoute) { + return arr + } + + let toAdd: Array + let toAddModals: Array = [] + // special handling of modals, we keep them to the side to add them later, then go down the visible tab + if (depth === 0) { + childRoute = s.routes[0] as Route + toAdd = [childRoute] + // routes[1+] holds both real modals and root non-modal screens (e.g. + // chatConversation on phones, stacked above the tab bar). The latter are + // genuinely visible, so always include them; only gate real modals on includeModals. + const rest = s.routes.slice(1) as Array + toAddModals = includeModals ? rest : rest.filter(r => !isModalRouteName(r.name)) + } else { + // include items in the stack + if (s.type === 'stack') { + toAdd = s.routes as Array + } else { + toAdd = [childRoute] + } + } + + const nextArr = [...arr, ...toAdd] + const children = findVisibleRoute(nextArr, childRoute.state, depth + 1) + return [...children, ...toAddModals] + } + + if (!state) return [] + return findVisibleRoute([], state, 0) +} + +export const visibleScreen = (state?: Immutable, opts?: {includeModals?: boolean}) => + visiblePath(state, opts).at(-1) + +// ---- Builders ---- + +// Tabs at the root, `tab` selected, optionally with screens pushed inside that tab's stack. +export const tabState = (tab: Tabs.Tab, screenStack?: ReadonlyArray): PartialNavState => { + const tabRoute: PartialRoute = {name: tab} + if (screenStack?.length) { + tabRoute.state = {index: screenStack.length - 1, routes: [...screenStack]} + } + return { + index: 0, + routes: [{name: 'loggedIn', state: {index: 0, routes: [tabRoute]}}], + } +} + +// A modal in the root stack. underTab selects which tab sits beneath it; without it +// loggedIn falls back to the initial (people) tab. +export const modalState = ( + modalName: string, + params?: Record, + underTab?: Tabs.AppTab +): PartialNavState => ({ + index: 1, + routes: [ + underTab ? {name: 'loggedIn', state: {index: 0, routes: [{name: underTab}]}} : {name: 'loggedIn'}, + {name: modalName, ...(params ? {params} : {})}, + ], +}) + +// Phone shape: the tab navigator sits at the root with `tab` selected on its root screen, +// and `screen` is pushed above it so it covers the tab bar. +export const pushedAboveTabs = (tab: Tabs.AppTab, screen: ScreenSpec): PartialNavState => ({ + index: 1, + routes: [ + { + name: 'loggedIn', + state: { + index: 0, + routes: [{name: tab, state: {index: 0, routes: [{name: tabRoots[tab]}]}}], + }, + }, + screen, + ], +}) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 583a2edb7505..4a296d4972fb 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -12,10 +12,10 @@ import { type NavigationContainerRef, NavigationContext, createNavigationContainerRef, - type NavigationState, } from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' +import * as NavTree from './nav-tree' import type {GetOptionsRet, RouteDef} from './types/router' import {isSplit, threadRouteName} from './chat/layout' import {ignorePromise, shallowEqual} from './utils' @@ -59,28 +59,14 @@ registerDebugClear(() => { navigationRef.current = null }) -export type Route = NavigationState['routes'][0] -// still a little paranoid about some things being missing in this type -export type NavState = Partial +export type {Route, NavState} from './nav-tree' +type Route = NavTree.Route +type NavState = NavTree.NavState export type Navigator = NavigationContainerRef +export {setModalRouteNames} from './nav-tree' + const DEBUG_NAV = __DEV__ && (false as boolean) -// Modal route names, registered at startup from the router config (the single source -// of truth — see modalRoutes in router-v2/routes). A serialized NavigationState route -// does not carry its `presentation`, so we cannot detect modals structurally: a route -// living in the root stack (alongside the tab navigator) is a modal iff its name is in -// this set. Everything else there (e.g. chatConversation, and any other non-modal screen -// pushed above the tab bar on phones) is a genuinely-visible screen. -let modalRouteNames: ReadonlySet | undefined -export const setModalRouteNames = (names: Iterable) => { - modalRouteNames = new Set(names) -} -const isRootModalRoute = (name: string) => { - if (!modalRouteNames) { - throw new Error('modalRouteNames not registered; call setModalRouteNames at startup') - } - return modalRouteNames.has(name) -} const uiParticipantsToParticipantInfo = ( uiParticipants: ReadonlyArray @@ -104,116 +90,26 @@ export const getRootState = (): NavState | undefined => { return navigationRef.getRootState() } -export const getTab = (navState?: T.Immutable): undefined | Tabs.Tab => { - const s = navState || getRootState() - const loggedInRoute = s?.routes?.[0] - if (loggedInRoute?.name === 'loggedIn') { - // eslint-disable-next-line - return loggedInRoute.state?.routes?.[loggedInRoute.state.index ?? 0]?.name as Tabs.Tab - } - return undefined -} - -const _isLoggedIn = (s: T.Immutable) => { - if (!s) { - return false - } - return s.routes?.[0]?.name === 'loggedIn' -} +export const getTab = (navState?: T.Immutable): undefined | Tabs.Tab => + NavTree.currentTab(navState || getRootState()) export const _getNavigator = () => { return navigationRef.isReady() ? navigationRef : undefined } -const getActiveStackState = (navState?: T.Immutable): T.Immutable | undefined => { - const rs = navState || getRootState() - const findActiveStackState = ( - state: T.Immutable | undefined, - depth: number - ): T.Immutable | undefined => { - if (!state?.routes || state.index === undefined) { - return undefined - } - if (depth === 0) { - const topModal = (state.routes.slice(1) as Array) - .filter(route => isRootModalRoute(route.name)) - .at(-1) - if (topModal) { - return findActiveStackState(topModal.state, depth + 1) ?? state - } - const loggedInRoute = state.routes[0] as Route | undefined - return findActiveStackState(loggedInRoute?.state, depth + 1) ?? (state.type === 'stack' ? state : undefined) - } - const childRoute = state.routes[state.index] as Route | undefined - return findActiveStackState(childRoute?.state, depth + 1) ?? (state.type === 'stack' ? state : undefined) - } - return findActiveStackState(rs, 0) -} +const getActiveStackState = (navState?: T.Immutable) => + NavTree.activeStack(navState || getRootState()) // Public API // gives you loggedin/tab/stackitems + modals -export const getVisiblePath = (navState?: T.Immutable, _inludeModals?: boolean) => { - const rs = navState || getRootState() - const inludeModals = _inludeModals ?? true - - const findVisibleRoute = ( - arr: T.Immutable>, - s: T.Immutable, - depth: number - ): T.Immutable> => { - if (!s?.routes || s.index === undefined) { - return arr - } - let childRoute = s.routes[s.index] as Route | undefined - if (!childRoute) { - return arr - } +export const getVisiblePath = (navState?: T.Immutable, includeModals?: boolean) => + NavTree.visiblePath(navState || getRootState(), {includeModals}) - let toAdd: Array - let toAddModals: Array = [] - // special handling of modals, we keep them to the side to add them later, then go down the visible tab - if (depth === 0) { - childRoute = s.routes[0] as Route - toAdd = [childRoute] - // routes[1+] holds both real modals and root non-modal screens (e.g. - // chatConversation on phones, stacked above the tab bar). The latter are - // genuinely visible, so always include them; only gate real modals on includeModals. - const rest = s.routes.slice(1) as Array - toAddModals = inludeModals ? rest : rest.filter(r => !isRootModalRoute(r.name)) - } else { - // include items in the stack - if (s.type === 'stack') { - toAdd = s.routes as Array - } else { - toAdd = [childRoute] - } - } - - const nextArr = [...arr, ...toAdd] - const children = findVisibleRoute(nextArr, childRoute.state, depth + 1) - return [...children, ...toAddModals] - } - - if (!rs) return [] - const vs = findVisibleRoute([], rs, 0) - return vs -} - -export const getModalStack = (navState?: T.Immutable) => { - const rs = navState || getRootState() - if (!rs) { - return [] - } - if (!_isLoggedIn(rs)) { - return [] - } - return (rs.routes?.slice(1) ?? []).filter(r => isRootModalRoute(r.name)) -} +export const getModalStack = (navState?: T.Immutable) => + NavTree.modalStack(navState || getRootState()) -export const getVisibleScreen = (navState?: T.Immutable, _inludeModals?: boolean) => { - const visible = getVisiblePath(navState, _inludeModals ?? true) - return visible.at(-1) -} +export const getVisibleScreen = (navState?: T.Immutable, includeModals?: boolean) => + NavTree.visibleScreen(navState || getRootState(), {includeModals}) export const logState = () => { const rs = getRootState() @@ -221,7 +117,7 @@ export const logState = () => { ps.map(p => ({key: p.key, name: p.name})) const modals = safePaths(getModalStack(rs)) const visible = safePaths(getVisiblePath(rs)) - return {loggedIn: _isLoggedIn(rs), modals, visible} + return {loggedIn: NavTree.isLoggedIn(rs), modals, visible} } // if a toast is inside of a portal then its not in nav so useFocusEffect would throw, @@ -296,13 +192,11 @@ export const clearModals = () => { const n = _getNavigator() if (!n) return const ns = getRootState() - if (!_isLoggedIn(ns)) { + if (!NavTree.isLoggedIn(ns)) { return } const rootRoutes = ns?.routes ?? [] - const keepRoutes = rootRoutes.filter( - (route, index) => index === 0 || !isRootModalRoute(route.name) - ) + const keepRoutes = rootRoutes.filter((route, index) => index === 0 || !NavTree.isModalRouteName(route.name)) if (keepRoutes.length !== rootRoutes.length) { n.dispatch({ ...CommonActions.reset({ @@ -458,8 +352,7 @@ export const switchTab = (name: Tabs.AppTab) => { } const n = _getNavigator() if (!n) return - const ns = getRootState() - const tabNavState = ns?.routes?.[0]?.state + const tabNavState = NavTree.tabNavigatorState(getRootState()) if (!tabNavState?.key) return n.dispatch({ ...TabActions.jumpTo(name), @@ -735,8 +628,7 @@ export const setChatRootParams = ( ): boolean => { const n = _getNavigator() if (!n) return false - const rs = getRootState() - const tabNavState = rs?.routes?.[0]?.state + const tabNavState = NavTree.tabNavigatorState(getRootState()) if (!tabNavState?.key) return false const tabRoutes = tabNavState.routes as Array const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) @@ -867,18 +759,7 @@ const navToThread = ( return setChatRootParams(params) } else { // Phone: switch to the chat tab, then push the conversation above the tabs. - const nextState = { - index: 1, - routes: [ - { - name: 'loggedIn', - state: { - routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], - }, - }, - {name: 'chatConversation', params}, - ], - } + const nextState = NavTree.pushedAboveTabs(Tabs.chatTab, {name: 'chatConversation', params}) n.dispatch({ ...CommonActions.reset(nextState as Parameters[0]), target: rs.key, diff --git a/shared/constants/tests/nav-tree.test.ts b/shared/constants/tests/nav-tree.test.ts new file mode 100644 index 000000000000..58b533077332 --- /dev/null +++ b/shared/constants/tests/nav-tree.test.ts @@ -0,0 +1,267 @@ +/// +import * as Tabs from '@/constants/tabs' +import { + activeStack, + currentTab, + isLoggedIn, + modalStack, + modalState, + pushedAboveTabs, + setModalRouteNames, + tabNavigatorState, + tabState, + visiblePath, + visibleScreen, +} from '../nav-tree' + +// Mirror what router-v2 does at startup: register the modal route names so the tree +// can tell real modals from genuinely-visible pushed screens (e.g. chatConversation). +beforeEach(() => { + setModalRouteNames(['chatInfoPanel']) +}) + +// Module-level state — clear it so it can't leak into other tests in this worker. +afterEach(() => { + setModalRouteNames([]) +}) + +// On phones, chatConversation lives in the root stack as a sibling of the tab +// navigator (above the tab bar), not inside a tab. getSelectedConversation calls +// visibleScreen with includeModals=false, so the visible path must still surface +// chatConversation even though it sits at routes[1+] alongside real modals. +const makePhoneNavState = (extraRootRoutes: ReadonlyArray<{name: string; params?: object}> = []) => + ({ + index: extraRootRoutes.length, + key: 'root', + type: 'stack', + routes: [ + { + key: 'loggedIn', + name: 'loggedIn', + state: { + index: 0, + key: 'tabs', + type: 'tab', + routes: [ + { + key: 'chatTab', + name: 'tabs.chatTab', + state: { + index: 0, + key: 'chatStack', + type: 'stack', + routes: [{key: 'chatRoot', name: 'chatRoot'}], + }, + }, + ], + }, + }, + ...extraRootRoutes.map((r, i) => ({key: `extra-${i}`, name: r.name, params: r.params})), + ], + }) as any + +test('visibleScreen with includeModals=false surfaces chatConversation in the phone root stack', () => { + const navState = makePhoneNavState([{name: 'chatConversation', params: {conversationIDKey: 'CONV'}}]) + + const visible = visibleScreen(navState, {includeModals: false}) + + expect(visible?.name).toBe('chatConversation') + expect((visible?.params as {conversationIDKey?: string} | undefined)?.conversationIDKey).toBe('CONV') +}) + +test('visiblePath with includeModals=false includes chatConversation but excludes real modals', () => { + const navState = makePhoneNavState([ + {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, + {name: 'chatInfoPanel'}, + ]) + + const path = visiblePath(navState, {includeModals: false}).map(r => r.name) + + expect(path).toContain('chatConversation') + expect(path).not.toContain('chatInfoPanel') +}) + +test('visibleScreen returns the topmost convo when multiple are pushed', () => { + const navState = makePhoneNavState([ + {name: 'chatConversation', params: {conversationIDKey: 'CONV1'}}, + {name: 'chatConversation', params: {conversationIDKey: 'CONV2'}}, + ]) + + const visible = visibleScreen(navState, {includeModals: false}) + + expect(visible?.name).toBe('chatConversation') + expect((visible?.params as {conversationIDKey?: string} | undefined)?.conversationIDKey).toBe('CONV2') +}) + +test('visibleScreen(includeModals=false) still surfaces the convo under a modal', () => { + const navState = makePhoneNavState([ + {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, + {name: 'chatInfoPanel'}, + ]) + + // includeModals=false ignores the modal layered on top and reports the convo, + // matching desktop where the conversation lives in the base (non-modal) layer. + expect(visibleScreen(navState, {includeModals: false})?.name).toBe('chatConversation') + expect(visibleScreen(navState)?.name).toBe('chatInfoPanel') +}) + +test('visiblePath defaults to including real modals', () => { + const navState = makePhoneNavState([ + {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, + {name: 'chatInfoPanel'}, + ]) + + const path = visiblePath(navState).map(r => r.name) + + expect(path).toContain('chatConversation') + expect(path).toContain('chatInfoPanel') +}) + +test('visiblePath of an empty state is empty', () => { + expect(visiblePath(undefined)).toEqual([]) +}) + +// ---- currentTab / isLoggedIn / modalStack ---- + +test('currentTab reads the selected tab, and is undefined when logged out', () => { + expect(currentTab(makePhoneNavState())).toBe(Tabs.chatTab) + expect(currentTab({index: 0, routes: [{key: 'l', name: 'loggedOut'}]} as any)).toBeUndefined() + expect(currentTab(undefined)).toBeUndefined() +}) + +test('isLoggedIn is true only when the tab navigator is the root route', () => { + expect(isLoggedIn(makePhoneNavState())).toBe(true) + expect(isLoggedIn({index: 0, routes: [{key: 'l', name: 'loggedOut'}]} as any)).toBe(false) + expect(isLoggedIn(undefined)).toBe(false) +}) + +test('modalStack holds only the registered modal names above the tab navigator', () => { + const navState = makePhoneNavState([ + {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, + {name: 'chatInfoPanel'}, + ]) + + expect(modalStack(navState).map(r => r.name)).toEqual(['chatInfoPanel']) + expect(modalStack(makePhoneNavState())).toEqual([]) + expect(modalStack({index: 0, routes: [{key: 'l', name: 'loggedOut'}]} as any)).toEqual([]) +}) + +test('tabNavigatorState is the tab navigator, and nothing when logged out', () => { + expect(tabNavigatorState(makePhoneNavState())?.key).toBe('tabs') + expect(tabNavigatorState({index: 0, routes: [{key: 'l', name: 'loggedOut', state: {key: 'out'}}]} as any)).toBeUndefined() + expect(tabNavigatorState(undefined)).toBeUndefined() +}) + +// ---- activeStack ---- + +test('activeStack descends to the stack inside the selected tab', () => { + expect(activeStack(makePhoneNavState())?.key).toBe('chatStack') +}) + +// A non-modal screen pushed above the tab bar (the phone thread) is not a stack of its +// own, so pushes still target the selected tab's stack. +test('activeStack ignores non-modal screens pushed above the tabs', () => { + const navState = makePhoneNavState([{name: 'chatConversation', params: {conversationIDKey: 'CONV'}}]) + + expect(activeStack(navState)?.key).toBe('chatStack') +}) + +// A modal has no nested stack state of its own here, so the root stack is what a pop +// would act on. +test('activeStack stops at the root stack when a modal is on top', () => { + const navState = makePhoneNavState([{name: 'chatInfoPanel'}]) + + expect(activeStack(navState)?.key).toBe('root') +}) + +test('activeStack of an empty state is undefined', () => { + expect(activeStack(undefined)).toBeUndefined() + expect(activeStack({routes: []} as any)).toBeUndefined() +}) + +// ---- builders ---- + +test('tabState selects a tab with no screens pushed inside it', () => { + expect(tabState(Tabs.fsTab)).toEqual({ + index: 0, + routes: [{name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.fsTab}]}}], + }) +}) + +test('tabState pushes a screen stack inside the tab and selects its last entry', () => { + expect(tabState(Tabs.peopleTab, [{name: 'peopleRoot'}, {name: 'profile', params: {username: 'testuser'}}])).toEqual( + { + index: 0, + routes: [ + { + name: 'loggedIn', + state: { + index: 0, + routes: [ + { + name: Tabs.peopleTab, + state: { + index: 1, + routes: [{name: 'peopleRoot'}, {name: 'profile', params: {username: 'testuser'}}], + }, + }, + ], + }, + }, + ], + } + ) +}) + +test('modalState without underTab leaves loggedIn on its initial tab', () => { + expect(modalState('settingsPushPrompt')).toEqual({ + index: 1, + routes: [{name: 'loggedIn'}, {name: 'settingsPushPrompt'}], + }) +}) + +test('modalState parks the requested tab beneath the modal and carries params', () => { + expect(modalState('incomingShareNew', {selectedConversationIDKey: 'CONV'}, Tabs.chatTab)).toEqual({ + index: 1, + routes: [ + {name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.chatTab}]}}, + {name: 'incomingShareNew', params: {selectedConversationIDKey: 'CONV'}}, + ], + }) +}) + +// The phone shape: the tab navigator sits at routes[0] on the tab's own root screen, and +// the pushed screen covers it at routes[1]. Every index is spelled out - a tab navigator +// rehydrates a missing index to 0 while a stack rehydrates it to the last route, so +// leaving them off means the shape reads differently at different depths. +test('pushedAboveTabs puts the tab root under a screen pushed above the tab bar', () => { + expect(pushedAboveTabs(Tabs.chatTab, {name: 'chatConversation', params: {conversationIDKey: 'CONV'}})).toEqual({ + index: 1, + routes: [ + { + name: 'loggedIn', + state: { + index: 0, + routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot'}]}}], + }, + }, + {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, + ], + }) +}) + +test('pushedAboveTabs uses each tab own root screen', () => { + expect(pushedAboveTabs(Tabs.fsTab, {name: 'fsBrowse', params: {path: '/keybase/private/testuser'}})).toEqual({ + index: 1, + routes: [ + { + name: 'loggedIn', + state: { + index: 0, + routes: [{name: Tabs.fsTab, state: {index: 0, routes: [{name: 'fsRoot'}]}}], + }, + }, + {name: 'fsBrowse', params: {path: '/keybase/private/testuser'}}, + ], + }) +}) diff --git a/shared/constants/tests/router-visible.test.ts b/shared/constants/tests/router-visible.test.ts deleted file mode 100644 index ad3b898a7ac5..000000000000 --- a/shared/constants/tests/router-visible.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/// -import {getVisiblePath, getVisibleScreen, setModalRouteNames} from '../router' - -// Mirror what router-v2 does at startup: register the modal route names so the router -// can tell real modals from genuinely-visible pushed screens (e.g. chatConversation). -beforeEach(() => { - setModalRouteNames(['chatInfoPanel']) -}) - -// Module-level state — clear it so it can't leak into other tests in this worker. -afterEach(() => { - setModalRouteNames([]) -}) - -// On phones, chatConversation lives in the root stack as a sibling of the tab -// navigator (above the tab bar), not inside a tab. getSelectedConversation calls -// getVisibleScreen with includeModals=false, so the visible path must still surface -// chatConversation even though it sits at routes[1+] alongside real modals. -const makePhoneNavState = (extraRootRoutes: ReadonlyArray<{name: string; params?: object}> = []) => - ({ - index: extraRootRoutes.length, - key: 'root', - type: 'stack', - routes: [ - { - key: 'loggedIn', - name: 'loggedIn', - state: { - index: 0, - key: 'tabs', - type: 'tab', - routes: [ - { - key: 'chatTab', - name: 'tabs.chatTab', - state: { - index: 0, - key: 'chatStack', - type: 'stack', - routes: [{key: 'chatRoot', name: 'chatRoot'}], - }, - }, - ], - }, - }, - ...extraRootRoutes.map((r, i) => ({key: `extra-${i}`, name: r.name, params: r.params})), - ], - }) as any - -test('getVisibleScreen with includeModals=false surfaces chatConversation in the phone root stack', () => { - const navState = makePhoneNavState([{name: 'chatConversation', params: {conversationIDKey: 'CONV'}}]) - - const visible = getVisibleScreen(navState, false) - - expect(visible?.name).toBe('chatConversation') - expect((visible?.params as {conversationIDKey?: string} | undefined)?.conversationIDKey).toBe('CONV') -}) - -test('getVisiblePath with includeModals=false includes chatConversation but excludes real modals', () => { - const navState = makePhoneNavState([ - {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, - {name: 'chatInfoPanel'}, - ]) - - const path = getVisiblePath(navState, false).map(r => r.name) - - expect(path).toContain('chatConversation') - expect(path).not.toContain('chatInfoPanel') -}) - -test('getVisibleScreen returns the topmost convo when multiple are pushed', () => { - const navState = makePhoneNavState([ - {name: 'chatConversation', params: {conversationIDKey: 'CONV1'}}, - {name: 'chatConversation', params: {conversationIDKey: 'CONV2'}}, - ]) - - const visible = getVisibleScreen(navState, false) - - expect(visible?.name).toBe('chatConversation') - expect((visible?.params as {conversationIDKey?: string} | undefined)?.conversationIDKey).toBe('CONV2') -}) - -test('getVisibleScreen(false) still surfaces the convo under a modal', () => { - const navState = makePhoneNavState([ - {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, - {name: 'chatInfoPanel'}, - ]) - - // includeModals=false ignores the modal layered on top and reports the convo, - // matching desktop where the conversation lives in the base (non-modal) layer. - expect(getVisibleScreen(navState, false)?.name).toBe('chatConversation') - expect(getVisibleScreen(navState, true)?.name).toBe('chatInfoPanel') -}) - -test('getVisiblePath with includeModals=true includes real modals', () => { - const navState = makePhoneNavState([ - {name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, - {name: 'chatInfoPanel'}, - ]) - - const path = getVisiblePath(navState, true).map(r => r.name) - - expect(path).toContain('chatConversation') - expect(path).toContain('chatInfoPanel') -}) diff --git a/shared/fs/common/daemon.tsx b/shared/fs/common/daemon.tsx index 0f1d86b2ca27..7c8b83975db4 100644 --- a/shared/fs/common/daemon.tsx +++ b/shared/fs/common/daemon.tsx @@ -75,7 +75,7 @@ export const FsDaemonProvider = ({children}: {children: React.ReactNode}) => { // Re-kick the watcher when the daemon handshake (re)completes: the watch loop exits // if the service dies, and a new handshake means RPCs work again. const handshakeDone = useDaemonState(s => s.handshakeState === 'done') - const navState = useRouterState(s => s.navState as RouterConstants.NavState | undefined) + const navState = useRouterState(s => s.navState) const [kbfsDaemonStatus, setKbfsDaemonStatus] = React.useState( Constants.unknownKbfsDaemonStatus ) diff --git a/shared/router-v2/linking-state.test.ts b/shared/router-v2/linking-state.test.ts index 72e211644714..c971508364e7 100644 --- a/shared/router-v2/linking-state.test.ts +++ b/shared/router-v2/linking-state.test.ts @@ -15,8 +15,8 @@ test('an unknown path produces no navigation state', () => { expect(getStateFromPath('/')).toBeUndefined() }) -// spelled out here rather than reusing makeChatConversationState, so a bug shared -// by the builder and the path parser cannot pass unnoticed +// spelled out here rather than reusing makeChatConversationState or NavTree's builders, +// so a bug shared by the builder and the path parser cannot pass unnoticed const chatConversationState = (conversationIDKey: string) => ({ index: 0, routes: [ diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 6f3442f00728..73379113ec25 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -5,6 +5,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' import {usePushState} from '@/stores/push' +import * as NavTree from '@/constants/nav-tree' import type {LinkingOptions} from '@react-navigation/native' import type {RootParamList} from './route-params' import {Linking} from 'react-native' @@ -15,72 +16,13 @@ export {emitDeepLink, normalizeUrl} from './deep-link-emitter' // ---- State building helpers ---- -type PartialRoute = { - name: string - params?: Record - state?: PartialNavState -} - -type PartialNavState = { - routes: Array - index?: number -} - -// Build state for navigating to a screen within a tab -const makeTabState = ( - tab: string, - screenStack?: Array<{name: string; params?: Record}> -): PartialNavState => { - const tabRoute: PartialRoute = {name: tab} - if (screenStack && screenStack.length > 0) { - tabRoute.state = { - index: screenStack.length - 1, - routes: screenStack, - } - } - return { - index: 0, - routes: [{name: 'loggedIn', state: {index: 0, routes: [tabRoute]}}], - } -} - // Build state for navigating to a chat conversation -export const makeChatConversationState = (conversationIDKey: string): PartialNavState => { - if (isSplit) { - // Tablet/desktop: chatRoot with conversationIDKey param (split view) - return makeTabState(Tabs.chatTab, [{name: 'chatRoot', params: {conversationIDKey}}]) - } - // Phone: tabs at root, conversation pushed above them - return { - index: 1, - routes: [ - { - name: 'loggedIn', - state: { - index: 0, - routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], - }, - }, - {name: 'chatConversation', params: {conversationIDKey}}, - ], - } -} - -// Build state for a modal screen at root level. underTab selects which tab sits -// beneath the modal; without it loggedIn falls back to the initial (people) tab. -const makeModalState = ( - modalName: string, - params?: Record, - underTab?: Tabs.AppTab -): PartialNavState => ({ - index: 1, - routes: [ - underTab - ? {name: 'loggedIn', state: {index: 0, routes: [{name: underTab}]}} - : {name: 'loggedIn'}, - {name: modalName, ...(params ? {params} : {})}, - ], -}) +export const makeChatConversationState = (conversationIDKey: string): NavTree.PartialNavState => + isSplit + ? // Tablet/desktop: chatRoot with conversationIDKey param (split view) + NavTree.tabState(Tabs.chatTab, [{name: 'chatRoot', params: {conversationIDKey}}]) + : // Phone: tabs at root, conversation pushed above them + NavTree.pushedAboveTabs(Tabs.chatTab, {name: 'chatConversation', params: {conversationIDKey}}) // ---- URL pattern handling ---- @@ -156,7 +98,7 @@ export const subscribeNavigationIntents = ( const customGetStateFromPath = ( path: string, _options?: object -): PartialNavState | undefined => { +): NavTree.PartialNavState | undefined => { // path has prefix already stripped by React Navigation (e.g., "convid/abc123") const cleanPath = path.replace(/^\/+/, '').replace(/\?.*$/, '') if (!cleanPath) return undefined @@ -175,7 +117,7 @@ const customGetStateFromPath = ( // keybase://profile/show/{username} case 'profile': if (parts[1] === 'show' && parts[2]) { - return makeTabState(Tabs.peopleTab, [ + return NavTree.tabState(Tabs.peopleTab, [ {name: 'peopleRoot'}, {name: 'profile', params: {username: parts[2]}}, ]) @@ -191,23 +133,11 @@ const customGetStateFromPath = ( const path = `/keybase/${decoded}` if (isSplit) { // Tablet: push the folder above the Files tab root, inside the tab stack. - return makeTabState(Tabs.fsTab, [{name: 'fsRoot'}, {name: 'fsBrowse', params: {path}}]) + return NavTree.tabState(Tabs.fsTab, [{name: 'fsRoot'}, {name: 'fsBrowse', params: {path}}]) } // Phone: fsRoot is the only screen in the Files tab stack; folders open as // fsBrowse pushed on the root stack, above the tabs. - return { - index: 1, - routes: [ - { - name: 'loggedIn', - state: { - index: 0, - routes: [{name: Tabs.fsTab, state: {index: 0, routes: [{name: 'fsRoot'}]}}], - }, - }, - {name: 'fsBrowse', params: {path}}, - ], - } + return NavTree.pushedAboveTabs(Tabs.fsTab, {name: 'fsBrowse', params: {path}}) } catch {} break } @@ -217,7 +147,7 @@ const customGetStateFromPath = ( case 'incoming-share': // Share always ends in chat, so park the chat tab (inbox) beneath the modal; // otherwise dismissing/back lands on the initial people tab. - return makeModalState( + return NavTree.modalState( 'incomingShareNew', parts[1] ? {selectedConversationIDKey: stringToConversationIDKey(parts[1])} : undefined, Tabs.chatTab @@ -225,7 +155,7 @@ const customGetStateFromPath = ( // keybase://settingsPushPrompt case 'settingsPushPrompt': - return makeModalState('settingsPushPrompt') + return NavTree.modalState('settingsPushPrompt') // Tab switches: keybase://tabs.chatTab, etc. case Tabs.chatTab: @@ -236,7 +166,7 @@ const customGetStateFromPath = ( case Tabs.cryptoTab: case Tabs.devicesTab: case Tabs.gitTab: - return makeTabState(root) + return NavTree.tabState(root) default: break diff --git a/shared/router-v2/routes.tsx b/shared/router-v2/routes.tsx index b8ee9d93b4b3..232be24005ac 100644 --- a/shared/router-v2/routes.tsx +++ b/shared/router-v2/routes.tsx @@ -12,7 +12,6 @@ import {newRoutes as teamsNewRoutes, newModalRoutes as teamsNewModalRoutes} from import {newModalRoutes as walletsNewModalRoutes} from '../wallets/routes' import {newModalRoutes as incomingShareNewModalRoutes} from '../incoming-share/routes' import type * as React from 'react' -import * as Tabs from '@/constants/tabs' import {defineRouteMap} from '@/constants/types/router' import type {GetOptions, GetOptionsParams, GetOptionsRet, RouteDef} from '@/constants/types/router' import type {NativeStackNavigationOptions} from '@react-navigation/native-stack' @@ -57,19 +56,7 @@ if (__DEV__) { ) } -export const tabRoots = { - [Tabs.peopleTab]: 'peopleRoot', - [Tabs.chatTab]: 'chatRoot', - [Tabs.cryptoTab]: 'cryptoRoot', - [Tabs.fsTab]: 'fsRoot', - [Tabs.teamsTab]: 'teamsRoot', - [Tabs.gitTab]: 'gitRoot', - [Tabs.devicesTab]: 'devicesRoot', - [Tabs.settingsTab]: 'settingsRoot', - - [Tabs.loginTab]: '', - [Tabs.searchTab]: '', -} as const +export {tabRoots} from '@/constants/nav-tree' export const modalRoutes = defineRouteMap({ ...chatNewModalRoutes, diff --git a/shared/stores/router.tsx b/shared/stores/router.tsx index ab421dbef069..c7aee6f79dbb 100644 --- a/shared/stores/router.tsx +++ b/shared/stores/router.tsx @@ -1,11 +1,12 @@ import type * as T from '@/constants/types' import * as Z from '@/util/zustand' -import type * as Util from '@/constants/router' +import {castDraft} from 'immer' +import type {NavState} from '@/constants/nav-tree' -export {type NavState} from '@/constants/router' +export {type NavState} from '@/constants/nav-tree' type Store = T.Immutable<{ - navState?: unknown + navState?: NavState }> const initialStore: Store = { @@ -15,7 +16,7 @@ const initialStore: Store = { export type State = Store & { dispatch: { resetState: () => void - setNavState: (ns: Util.NavState) => void + setNavState: (ns: T.Immutable) => void } } @@ -32,10 +33,10 @@ export const useRouterState = Z.createZustand('router', (set, get) => { if (DEBUG_NAV) { console.log('[Nav] setNavState') } - const prev = get().navState as Util.NavState + const prev = get().navState if (prev === next) return set(s => { - s.navState = next + s.navState = castDraft(next) }) }, }