about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx104
-rw-r--r--src/components/Link.tsx36
-rw-r--r--src/lib/hooks/useNavigationDeduped.ts47
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/view/com/util/Link.tsx83
-rw-r--r--src/view/shell/Drawer.tsx22
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx52
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx48
-rw-r--r--src/view/shell/desktop/LeftNav.tsx16
9 files changed, 215 insertions, 195 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 8b981df7c..2f26c0971 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -103,7 +103,7 @@ import {
 import {Wizard} from '#/screens/StarterPack/Wizard'
 import TopicScreen from '#/screens/Topic'
 import {VideoFeed} from '#/screens/VideoFeed'
-import {useTheme} from '#/alf'
+import {type Theme, useTheme} from '#/alf'
 import {
   EmailDialogScreenID,
   useEmailDialogControl,
@@ -127,7 +127,7 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
 /**
  * These "common screens" are reused across stacks.
  */
-function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
+function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
   const title = (page: MessageDescriptor) =>
     bskyTitle(i18n._(page), unreadCountLabel)
 
@@ -500,6 +500,10 @@ function TabsNavigator() {
       <Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
       <Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
       <Tab.Screen
+        name="MessagesTab"
+        getComponent={() => MessagesTabNavigator}
+      />
+      <Tab.Screen
         name="NotificationsTab"
         getComponent={() => NotificationsTabNavigator}
       />
@@ -507,29 +511,26 @@ function TabsNavigator() {
         name="MyProfileTab"
         getComponent={() => MyProfileTabNavigator}
       />
-      <Tab.Screen
-        name="MessagesTab"
-        getComponent={() => MessagesTabNavigator}
-      />
     </Tab.Navigator>
   )
 }
 
+function screenOptions(t: Theme) {
+  return {
+    fullScreenGestureEnabled: true,
+    headerShown: false,
+    contentStyle: t.atoms.bg,
+  } as const
+}
+
 function HomeTabNavigator() {
   const t = useTheme()
 
   return (
-    <HomeTab.Navigator
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+    <HomeTab.Navigator screenOptions={screenOptions(t)} initialRouteName="Home">
       <HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
       <HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
-      {commonScreens(HomeTab)}
+      {commonScreens(HomeTab as typeof Flat)}
     </HomeTab.Navigator>
   )
 }
@@ -538,15 +539,10 @@ function SearchTabNavigator() {
   const t = useTheme()
   return (
     <SearchTab.Navigator
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+      screenOptions={screenOptions(t)}
+      initialRouteName="Search">
       <SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
-      {commonScreens(SearchTab as typeof HomeTab)}
+      {commonScreens(SearchTab as typeof Flat)}
     </SearchTab.Navigator>
   )
 }
@@ -555,19 +551,14 @@ function NotificationsTabNavigator() {
   const t = useTheme()
   return (
     <NotificationsTab.Navigator
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+      screenOptions={screenOptions(t)}
+      initialRouteName="Notifications">
       <NotificationsTab.Screen
         name="Notifications"
         getComponent={() => NotificationsScreen}
         options={{requireAuth: true}}
       />
-      {commonScreens(NotificationsTab as typeof HomeTab)}
+      {commonScreens(NotificationsTab as typeof Flat)}
     </NotificationsTab.Navigator>
   )
 }
@@ -576,23 +567,16 @@ function MyProfileTabNavigator() {
   const t = useTheme()
   return (
     <MyProfileTab.Navigator
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+      screenOptions={screenOptions(t)}
+      initialRouteName="MyProfile">
       <MyProfileTab.Screen
-        // @ts-ignore // TODO: fix this broken type in ProfileScreen
-        name="MyProfile"
+        // MyProfile is not in AllNavigationParams - asserting as Profile at least
+        // gives us typechecking for initialParams -sfn
+        name={'MyProfile' as 'Profile'}
         getComponent={() => ProfileScreen}
-        initialParams={{
-          name: 'me',
-          hideBackButton: true,
-        }}
+        initialParams={{name: 'me', hideBackButton: true}}
       />
-      {commonScreens(MyProfileTab as typeof HomeTab)}
+      {commonScreens(MyProfileTab as unknown as typeof Flat)}
     </MyProfileTab.Navigator>
   )
 }
@@ -601,13 +585,8 @@ function MessagesTabNavigator() {
   const t = useTheme()
   return (
     <MessagesTab.Navigator
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+      screenOptions={screenOptions(t)}
+      initialRouteName="Messages">
       <MessagesTab.Screen
         name="Messages"
         getComponent={() => MessagesScreen}
@@ -616,7 +595,7 @@ function MessagesTabNavigator() {
           animationTypeForReplace: route.params?.animation ?? 'push',
         })}
       />
-      {commonScreens(MessagesTab as typeof HomeTab)}
+      {commonScreens(MessagesTab as typeof Flat)}
     </MessagesTab.Navigator>
   )
 }
@@ -634,13 +613,7 @@ const FlatNavigator = () => {
   return (
     <Flat.Navigator
       screenListeners={screenListeners}
-      screenOptions={{
-        animationDuration: 285,
-        gestureEnabled: true,
-        fullScreenGestureEnabled: true,
-        headerShown: false,
-        contentStyle: t.atoms.bg,
-      }}>
+      screenOptions={screenOptions(t)}>
       <Flat.Screen
         name="Home"
         getComponent={() => HomeScreen}
@@ -666,7 +639,7 @@ const FlatNavigator = () => {
         getComponent={() => HomeScreen}
         options={{title: title(msg`Home`)}}
       />
-      {commonScreens(Flat as typeof HomeTab, numUnread)}
+      {commonScreens(Flat, numUnread)}
     </Flat.Navigator>
   )
 }
@@ -773,7 +746,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
           logModuleInitTime()
           onReady()
           logger.metric('router:navigate', {}, {statsig: false})
-        }}>
+        }}
+        // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
+        // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
+        // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
+        // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
+        // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
+        // -sfn
+        navigationInChildEnabled>
         {children}
       </NavigationContainer>
     </>
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index cca93c0c8..d73a3db4a 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,7 +1,11 @@
-import React from 'react'
+import React, {useMemo} from 'react'
 import {type GestureResponderEvent} from 'react-native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
-import {StackActions, useLinkProps} from '@react-navigation/native'
+import {
+  type LinkProps as RNLinkProps,
+  StackActions,
+  useLinkBuilder,
+} from '@react-navigation/native'
 
 import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
 import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
@@ -28,12 +32,11 @@ import {router} from '#/routes'
  */
 export {useButtonContext as useLinkContext} from '#/components/Button'
 
-type BaseLinkProps = Pick<
-  Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
-  'to'
-> & {
+type BaseLinkProps = {
   testID?: string
 
+  to: RNLinkProps<AllNavigatorParams> | string
+
   /**
    * The React Navigation `StackAction` to perform when the link is pressed.
    */
@@ -92,10 +95,23 @@ export function useLink({
   shouldProxy?: boolean
 }) {
   const navigation = useNavigationDeduped()
-  const {href} = useLinkProps<AllNavigatorParams>({
-    to:
-      typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
-  })
+  const {buildHref} = useLinkBuilder()
+  const href = useMemo(() => {
+    return typeof to === 'string'
+      ? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
+      : to.screen
+      ? buildHref(to.screen, to.params)
+      : to.href
+      ? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
+      : undefined
+  }, [to, buildHref])
+
+  if (!href) {
+    throw new Error(
+      'Link `to` prop must be a string or an object with `screen` and `params` properties',
+    )
+  }
+
   const isExternal = isExternalUrl(href)
   const {openModal, closeModal} = useModalControls()
   const openLink = useOpenLink()
diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts
index 56ae5e8a2..dc18742c0 100644
--- a/src/lib/hooks/useNavigationDeduped.ts
+++ b/src/lib/hooks/useNavigationDeduped.ts
@@ -1,10 +1,8 @@
-import React from 'react'
+import {useMemo} from 'react'
 import {useNavigation} from '@react-navigation/core'
-import {NavigationState} from '@react-navigation/native'
-import type {NavigationAction} from '@react-navigation/routers'
 
 import {useDedupe} from '#/lib/hooks/useDedupe'
-import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 
 export type DebouncedNavigationProp = Pick<
   NavigationProp,
@@ -22,46 +20,19 @@ export function useNavigationDeduped() {
   const navigation = useNavigation<NavigationProp>()
   const dedupe = useDedupe()
 
-  return React.useMemo(
-    (): DebouncedNavigationProp => ({
-      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
-      push: <RouteName extends keyof AllNavigatorParams>(
-        ...args: undefined extends AllNavigatorParams[RouteName]
-          ?
-              | [screen: RouteName]
-              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
-          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
-      ) => {
+  return useMemo<DebouncedNavigationProp>(
+    () => ({
+      push: (...args: Parameters<typeof navigation.push>) => {
         dedupe(() => navigation.push(...args))
       },
-      // Types from @react-navigation/core/src/types.tsx
-      navigate: <RouteName extends keyof AllNavigatorParams>(
-        ...args: RouteName extends unknown
-          ? undefined extends AllNavigatorParams[RouteName]
-            ?
-                | [screen: RouteName]
-                | [screen: RouteName, params: AllNavigatorParams[RouteName]]
-            : [screen: RouteName, params: AllNavigatorParams[RouteName]]
-          : never
-      ) => {
+      navigate: (...args: Parameters<typeof navigation.navigate>) => {
         dedupe(() => navigation.navigate(...args))
       },
-      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
-      replace: <RouteName extends keyof AllNavigatorParams>(
-        ...args: undefined extends AllNavigatorParams[RouteName]
-          ?
-              | [screen: RouteName]
-              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
-          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
-      ) => {
+      replace: (...args: Parameters<typeof navigation.replace>) => {
         dedupe(() => navigation.replace(...args))
       },
-      dispatch: (
-        action:
-          | NavigationAction
-          | ((state: NavigationState) => NavigationAction),
-      ) => {
-        dedupe(() => navigation.dispatch(action))
+      dispatch: (...args: Parameters<typeof navigation.dispatch>) => {
+        dedupe(() => navigation.dispatch(...args))
       },
       popToTop: () => {
         dedupe(() => navigation.popToTop())
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0bc85b630..6f102d438 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -92,7 +92,7 @@ export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
 }
 
 export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
-  MyProfile: undefined
+  MyProfile: {name: 'me'; hideBackButton: true}
 }
 
 export type MessagesTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 489fbc59c..3a0bf6f6d 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,20 +1,20 @@
-import React, {ComponentProps, memo, useMemo} from 'react'
+import {memo, useCallback, useMemo} from 'react'
 import {
-  GestureResponderEvent,
+  type GestureResponderEvent,
   Platform,
   Pressable,
-  StyleProp,
-  TextProps,
-  TextStyle,
-  TouchableOpacity,
+  type StyleProp,
+  type TextProps,
+  type TextStyle,
+  type TouchableOpacity,
   View,
-  ViewStyle,
+  type ViewStyle,
 } from 'react-native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
-import {StackActions, useLinkProps} from '@react-navigation/native'
+import {StackActions} from '@react-navigation/native'
 
 import {
-  DebouncedNavigationProp,
+  type DebouncedNavigationProp,
   useNavigationDeduped,
 } from '#/lib/hooks/useNavigationDeduped'
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
@@ -24,7 +24,7 @@ import {
   isExternalUrl,
   linkRequiresWarning,
 } from '#/lib/strings/url-helpers'
-import {TypographyVariant} from '#/lib/ThemeContext'
+import {type TypographyVariant} from '#/lib/ThemeContext'
 import {isAndroid, isWeb} from '#/platform/detection'
 import {emitSoftReset} from '#/state/events'
 import {useModalControls} from '#/state/modals'
@@ -38,7 +38,7 @@ type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
   | GestureResponderEvent
 
-interface Props extends ComponentProps<typeof TouchableOpacity> {
+interface Props extends React.ComponentProps<typeof TouchableOpacity> {
   testID?: string
   style?: StyleProp<ViewStyle>
   href?: string
@@ -47,7 +47,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   hoverStyle?: StyleProp<ViewStyle>
   noFeedback?: boolean
   asAnchor?: boolean
-  dataSet?: Object | undefined
+  dataSet?: any
   anchorNoUnderline?: boolean
   navigationAction?: 'push' | 'replace' | 'navigate'
   onPointerEnter?: () => void
@@ -69,6 +69,7 @@ export const Link = memo(function Link({
   onBeforePress,
   accessibilityActions,
   onAccessibilityAction,
+  dataSet: dataSetProp,
   ...props
 }: Props) {
   const t = useTheme()
@@ -77,7 +78,7 @@ export const Link = memo(function Link({
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
   const openLink = useOpenLink()
 
-  const onPress = React.useCallback(
+  const onPress = useCallback(
     (e?: Event) => {
       onBeforePress?.()
       if (typeof href === 'string') {
@@ -99,6 +100,14 @@ export const Link = memo(function Link({
     {name: 'activate', label: title},
   ]
 
+  const dataSet = useMemo(() => {
+    const ds = {...dataSetProp}
+    if (anchorNoUnderline) {
+      ds.noUnderline = 1
+    }
+    return ds
+  }, [dataSetProp, anchorNoUnderline])
+
   if (noFeedback) {
     return (
       <WebAuxClickWrapper>
@@ -129,17 +138,6 @@ export const Link = memo(function Link({
     )
   }
 
-  if (anchorNoUnderline) {
-    // @ts-ignore web only -prf
-    props.dataSet = props.dataSet || {}
-    // @ts-ignore web only -prf
-    props.dataSet.noUnderline = 1
-  }
-
-  if (title && !props.accessibilityLabel) {
-    props.accessibilityLabel = title
-  }
-
   const Com = props.hoverStyle ? PressableWithHover : Pressable
   return (
     <Com
@@ -148,8 +146,11 @@ export const Link = memo(function Link({
       onPress={onPress}
       accessible={accessible}
       accessibilityRole="link"
+      accessibilityLabel={props.accessibilityLabel ?? title}
+      accessibilityHint={props.accessibilityHint}
       // @ts-ignore web only -prf
       href={anchorHref}
+      dataSet={dataSet}
       {...props}>
       {children ? children : <Text>{title || 'link'}</Text>}
     </Com>
@@ -164,14 +165,14 @@ export const TextLink = memo(function TextLink({
   text,
   numberOfLines,
   lineHeight,
-  dataSet,
+  dataSet: dataSetProp,
   title,
-  onPress,
+  onPress: onPressProp,
   onBeforePress,
   disableMismatchWarning,
   navigationAction,
   anchorNoUnderline,
-  ...orgProps
+  ...props
 }: {
   testID?: string
   type?: TypographyVariant
@@ -187,7 +188,6 @@ export const TextLink = memo(function TextLink({
   anchorNoUnderline?: boolean
   onBeforePress?: () => void
 } & TextProps) {
-  const {...props} = useLinkProps({to: sanitizeUrl(href)})
   const navigation = useNavigationDeduped()
   const {openModal, closeModal} = useModalControls()
   const openLink = useOpenLink()
@@ -196,12 +196,15 @@ export const TextLink = memo(function TextLink({
     console.error('Unable to detect mismatching label')
   }
 
-  if (anchorNoUnderline) {
-    dataSet = dataSet ?? {}
-    dataSet.noUnderline = 1
-  }
+  const dataSet = useMemo(() => {
+    const ds = {...dataSetProp}
+    if (anchorNoUnderline) {
+      ds.noUnderline = 1
+    }
+    return ds
+  }, [dataSetProp, anchorNoUnderline])
 
-  props.onPress = React.useCallback(
+  const onPress = useCallback(
     (e?: Event) => {
       const requiresWarning =
         !disableMismatchWarning &&
@@ -224,10 +227,10 @@ export const TextLink = memo(function TextLink({
         return
       }
       onBeforePress?.()
-      if (onPress) {
+      if (onPressProp) {
         e?.preventDefault?.()
-        // @ts-ignore function signature differs by platform -prf
-        return onPress()
+        // @ts-expect-error function signature differs by platform -prf
+        return onPressProp()
       }
       return onPressInner(
         closeModal,
@@ -240,7 +243,7 @@ export const TextLink = memo(function TextLink({
     },
     [
       onBeforePress,
-      onPress,
+      onPressProp,
       closeModal,
       openModal,
       navigation,
@@ -273,8 +276,10 @@ export const TextLink = memo(function TextLink({
       title={title}
       // @ts-ignore web only -prf
       hrefAttrs={hrefAttrs} // hack to get open in new tab to work on safari. without this, safari will open in a new window
-      {...props}
-      {...orgProps}>
+      onPress={onPress}
+      accessibilityRole="link"
+      href={convertBskyAppUrlIfNeeded(sanitizeUrl(href))}
+      {...props}>
       {text}
     </Text>
   )
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index c4624e8e1..79d8a21ae 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -160,7 +160,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
   // =
 
   const onPressTab = React.useCallback(
-    (tab: string) => {
+    (tab: 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile') => {
       const state = navigation.getState()
       setDrawerOpen(false)
       if (isWeb) {
@@ -168,7 +168,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
         if (tab === 'MyProfile') {
           navigation.navigate('Profile', {name: currentAccount!.handle})
         } else {
-          // @ts-ignore must be Home, Search, Notifications, or MyProfile
+          // @ts-expect-error struggles with string unions, apparently
           navigation.navigate(tab)
         }
       } else {
@@ -176,9 +176,23 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
         if (tabState === TabState.InsideAtRoot) {
           emitSoftReset()
         } else if (tabState === TabState.Inside) {
-          navigation.dispatch(StackActions.popToTop())
+          // find the correct navigator in which to pop-to-top
+          const target = state.routes.find(route => route.name === `${tab}Tab`)
+            ?.state?.key
+          if (target) {
+            // if we found it, trigger pop-to-top
+            navigation.dispatch({
+              ...StackActions.popToTop(),
+              target,
+            })
+          } else {
+            // fallback: reset navigation
+            navigation.reset({
+              index: 0,
+              routes: [{name: `${tab}Tab`}],
+            })
+          }
         } else {
-          // @ts-ignore must be Home, Search, Notifications, or MyProfile
           navigation.navigate(`${tab}Tab`)
         }
       }
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 92be6c67e..5e9168ecd 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -1,4 +1,4 @@
-import React, {type ComponentProps} from 'react'
+import {useCallback} from 'react'
 import {type GestureResponderEvent, View} from 'react-native'
 import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -52,13 +52,7 @@ import {
 import {useDemoMode} from '#/storage/hooks/demo-mode'
 import {styles} from './BottomBarStyles'
 
-type TabOptions =
-  | 'Home'
-  | 'Search'
-  | 'Notifications'
-  | 'MyProfile'
-  | 'Feeds'
-  | 'Messages'
+type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile'
 
 export function BottomBar({navigation}: BottomTabBarProps) {
   const {hasSession, currentAccount} = useSession()
@@ -81,48 +75,62 @@ export function BottomBar({navigation}: BottomTabBarProps) {
   const gate = useGate()
   const iconWidth = 28
 
-  const showSignIn = React.useCallback(() => {
+  const showSignIn = useCallback(() => {
     closeAllActiveElements()
     requestSwitchToAccount({requestedAccount: 'none'})
   }, [requestSwitchToAccount, closeAllActiveElements])
 
-  const showCreateAccount = React.useCallback(() => {
+  const showCreateAccount = useCallback(() => {
     closeAllActiveElements()
     requestSwitchToAccount({requestedAccount: 'new'})
     // setShowLoggedOut(true)
   }, [requestSwitchToAccount, closeAllActiveElements])
 
-  const onPressTab = React.useCallback(
+  const onPressTab = useCallback(
     (tab: TabOptions) => {
       const state = navigation.getState()
       const tabState = getTabState(state, tab)
       if (tabState === TabState.InsideAtRoot) {
         emitSoftReset()
       } else if (tabState === TabState.Inside) {
-        dedupe(() => navigation.dispatch(StackActions.popToTop()))
+        // find the correct navigator in which to pop-to-top
+        const target = state.routes.find(route => route.name === `${tab}Tab`)
+          ?.state?.key
+        dedupe(() => {
+          if (target) {
+            // if we found it, trigger pop-to-top
+            navigation.dispatch({
+              ...StackActions.popToTop(),
+              target,
+            })
+          } else {
+            // fallback: reset navigation
+            navigation.reset({
+              index: 0,
+              routes: [{name: `${tab}Tab`}],
+            })
+          }
+        })
       } else {
         dedupe(() => navigation.navigate(`${tab}Tab`))
       }
     },
     [navigation, dedupe],
   )
-  const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
-  const onPressSearch = React.useCallback(
-    () => onPressTab('Search'),
-    [onPressTab],
-  )
-  const onPressNotifications = React.useCallback(
+  const onPressHome = useCallback(() => onPressTab('Home'), [onPressTab])
+  const onPressSearch = useCallback(() => onPressTab('Search'), [onPressTab])
+  const onPressNotifications = useCallback(
     () => onPressTab('Notifications'),
     [onPressTab],
   )
-  const onPressProfile = React.useCallback(() => {
+  const onPressProfile = useCallback(() => {
     onPressTab('MyProfile')
   }, [onPressTab])
-  const onPressMessages = React.useCallback(() => {
+  const onPressMessages = useCallback(() => {
     onPressTab('Messages')
   }, [onPressTab])
 
-  const onLongPressProfile = React.useCallback(() => {
+  const onLongPressProfile = useCallback(() => {
     playHaptic()
     accountSwitchControl.open()
   }, [accountSwitchControl, playHaptic])
@@ -361,7 +369,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
 
 interface BtnProps
   extends Pick<
-    ComponentProps<typeof PressableScale>,
+    React.ComponentProps<typeof PressableScale>,
     | 'accessible'
     | 'accessibilityRole'
     | 'accessibilityHint'
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 868bba5b0..1c32971d4 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -1,25 +1,29 @@
 import * as React from 'react'
 import {View} from 'react-native'
-// Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts
+// Based on @react-navigation/native-stack/src/navigators/createNativeStackNavigator.ts
 // MIT License
 // Copyright (c) 2017 React Navigation Contributors
 import {
   createNavigatorFactory,
   type EventArg,
+  type NavigatorTypeBagBase,
   type ParamListBase,
   type StackActionHelpers,
   StackActions,
   type StackNavigationState,
   StackRouter,
   type StackRouterOptions,
+  type StaticConfig,
+  type TypedNavigator,
   useNavigationBuilder,
 } from '@react-navigation/native'
+import {NativeStackView} from '@react-navigation/native-stack'
 import {
   type NativeStackNavigationEventMap,
   type NativeStackNavigationOptions,
+  type NativeStackNavigationProp,
+  type NativeStackNavigatorProps,
 } from '@react-navigation/native-stack'
-import {NativeStackView} from '@react-navigation/native-stack'
-import {type NativeStackNavigatorProps} from '@react-navigation/native-stack/src/types'
 
 import {PWI_ENABLED} from '#/lib/build-flags'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -48,12 +52,14 @@ function NativeStackNavigator({
   id,
   initialRouteName,
   children,
+  layout,
   screenListeners,
   screenOptions,
+  screenLayout,
   ...rest
 }: NativeStackNavigatorProps) {
   // --- this is copy and pasted from the original native stack navigator ---
-  const {state, descriptors, navigation, NavigationContent} =
+  const {state, describe, descriptors, navigation, NavigationContent} =
     useNavigationBuilder<
       StackNavigationState<ParamListBase>,
       StackRouterOptions,
@@ -64,9 +70,12 @@ function NativeStackNavigator({
       id,
       initialRouteName,
       children,
+      layout,
       screenListeners,
       screenOptions,
+      screenLayout,
     })
+
   React.useEffect(
     () =>
       // @ts-expect-error: there may not be a tab navigator in parent
@@ -148,7 +157,8 @@ function NativeStackNavigator({
           {...rest}
           state={state}
           navigation={navigation}
-          descriptors={newDescriptors}
+          descriptors={descriptors}
+          describe={describe}
         />
       </View>
       {isWeb && (
@@ -161,9 +171,25 @@ function NativeStackNavigator({
   )
 }
 
-export const createNativeStackNavigatorWithAuth = createNavigatorFactory<
-  StackNavigationState<ParamListBase>,
-  NativeStackNavigationOptionsWithAuth,
-  NativeStackNavigationEventMap,
-  typeof NativeStackNavigator
->(NativeStackNavigator)
+export function createNativeStackNavigatorWithAuth<
+  const ParamList extends ParamListBase,
+  const NavigatorID extends string | undefined = undefined,
+  const TypeBag extends NavigatorTypeBagBase = {
+    ParamList: ParamList
+    NavigatorID: NavigatorID
+    State: StackNavigationState<ParamList>
+    ScreenOptions: NativeStackNavigationOptionsWithAuth
+    EventMap: NativeStackNavigationEventMap
+    NavigationList: {
+      [RouteName in keyof ParamList]: NativeStackNavigationProp<
+        ParamList,
+        RouteName,
+        NavigatorID
+      >
+    }
+    Navigator: typeof NativeStackNavigator
+  },
+  const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
+>(config?: Config): TypedNavigator<TypeBag, Config> {
+  return createNavigatorFactory(NativeStackNavigator)(config)
+}
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index f6c852ca1..52df66d70 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -1,10 +1,10 @@
-import React from 'react'
+import {useCallback, useMemo, useState} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {
-  useLinkProps,
+  useLinkTo,
   useNavigation,
   useNavigationState,
 } from '@react-navigation/native'
@@ -326,7 +326,7 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const {leftNavMinimal} = useLayoutBreakpoints()
-  const [pathName] = React.useMemo(() => router.matchPath(href), [href])
+  const [pathName] = useMemo(() => router.matchPath(href), [href])
   const currentRouteInfo = useNavigationState(state => {
     if (!state) {
       return {name: 'Home'}
@@ -339,8 +339,8 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
         (currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
           currentAccount?.handle
       : isTab(currentRouteInfo.name, pathName)
-  const {onPress} = useLinkProps({to: href})
-  const onPressWrapped = React.useCallback(
+  const linkTo = useLinkTo()
+  const onPressWrapped = useCallback(
     (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
       if (e.ctrlKey || e.metaKey || e.altKey) {
         return
@@ -349,10 +349,10 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
       if (isCurrent) {
         emitSoftReset()
       } else {
-        onPress()
+        linkTo(href)
       }
     },
-    [onPress, isCurrent],
+    [linkTo, href, isCurrent],
   )
 
   return (
@@ -468,7 +468,7 @@ function ComposeBtn() {
   const {openComposer} = useOpenComposer()
   const {_} = useLingui()
   const {leftNavMinimal} = useLayoutBreakpoints()
-  const [isFetchingHandle, setIsFetchingHandle] = React.useState(false)
+  const [isFetchingHandle, setIsFetchingHandle] = useState(false)
   const fetchHandle = useFetchHandle()
 
   const getProfileHandle = async () => {