diff --git a/jest/setup.ts b/jest/setup.ts index 9afaa8f219a1..951ef6577055 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -355,6 +355,10 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +// The native tab bar icons are drawn with Skia's imperative API, which the mock above leaves out, so the tabs get no icons. +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer', () => jest.fn(() => undefined)); +jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage', () => jest.fn(() => undefined)); + jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), diff --git a/package-lock.json b/package-lock.json index f656c125faa8..4d2702f72455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,10 +43,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.15.5", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.1.33", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -14687,17 +14687,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.15.5", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.5.tgz", - "integrity": "sha512-wQHredlCrRmShWQ1vF4HUcLdaiJ8fUgnbaeQH7BJ7MQVQh4mdzab0IOY/4QSmUyNRB350oyu1biTycyQ5FKWMQ==", + "version": "7.18.16", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.16.tgz", + "integrity": "sha512-5tBFuxvsCQBvYauDZK6u/uPdD86i/caWgBzABlZfTh8W2J7YZSHjT9SWOsquSMmR6dpcNj6idMd4utCNzuOnZg==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.10", + "@react-navigation/elements": "^2.9.38", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -14745,9 +14745,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz", - "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==", + "version": "2.9.38", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.38.tgz", + "integrity": "sha512-M/2HWDiSO4zO7VyfAnovrTPWOAzTC+1DnVT7ZWZW93+/KVRF9OYlqixIUpPenQ0iQh2j1EaQ4zOe/134Arisqw==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -14756,7 +14756,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.2", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -14786,15 +14786,16 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.33", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.33.tgz", - "integrity": "sha512-DpFdWGcgLajKZ1TuIvDNQsblN2QaUFWpTQaB8v7WRP9Mix8H/6TFoIrZd93pbymI2hybd6UYrD+lI408eWVcfw==", + "version": "7.3.16", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.16.tgz", + "integrity": "sha512-Wy/6mai2HpA5AEVFk7JFfvv4RUArwuN2UenP/fc3NK7kOyfSlNu1tmb/3JDPpMzPIRbF1MPB8PKAHjVB29qNEQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.16.1", + "@react-navigation/core": "^7.21.12", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { @@ -39767,6 +39768,15 @@ "node": ">=8" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", diff --git a/package.json b/package.json index 9a8893604467..c3074557445b 100644 --- a/package.json +++ b/package.json @@ -119,10 +119,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.15.5", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.1.33", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -414,6 +414,7 @@ "node-abi": "^3.78.0", "pdfjs-dist": "5.5.207", "lightningcss": "1.30.1", + "@react-navigation/elements": "2.9.38", "@fullstory/react-native": { "expo": "57.0.8" }, diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 000000000000..bd772bb16417 --- /dev/null +++ b/patches/react-native-screens/details.md @@ -0,0 +1,24 @@ +# `react-native-screens` patches + +### [react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch) + +- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch animates the change once the bar is in a window, which lets UIKit run its own show/hide animation without animating the bar out on the first render. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below. +- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+002+android-tab-label-typography.patch](react-native-screens+4.28.0+002+android-tab-label-typography.patch) + +- Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. + - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others. + - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0. +- Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) + +### [react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch) + +- Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. +- Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339) diff --git a/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch new file mode 100644 index 000000000000..632508daac5b --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm ++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm +@@ -244,7 +244,9 @@ + _tabBarHidden = newComponentProps.tabBarHidden; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + if (@available(iOS 18.0, *)) { +- [_controller setTabBarHidden:_tabBarHidden animated:NO]; ++ // Animated so the bar slides out with the screen that hid it instead of blinking away, except before the bar ++ // is on screen, where the first render would animate it out. ++ [_controller setTabBarHidden:_tabBarHidden animated:[[_controller tabBar] window] != nil]; + } else + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0) + { diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch new file mode 100644 index 000000000000..a532d6a859e5 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+002+android-tab-label-typography.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 10cd91b..6f059bd 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance + import android.annotation.SuppressLint + import android.content.Context + import android.content.res.ColorStateList ++import android.graphics.Typeface + import android.graphics.drawable.StateListDrawable + import android.util.TypedValue + import android.view.MenuItem +@@ -161,13 +162,18 @@ internal class TabsAppearanceApplicator( + tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) } + ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size) + ++ // Both labels drop the 0.5sp letter tracking of Material's text appearance, which reads wider than the app's labels. + // Inactive + smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize) + smallLabel.typeface = fontFamily ++ smallLabel.letterSpacing = 0f + + // Active ++ // Bold is set here rather than with `setItemTextAppearanceActiveBoldEnabled`, which re-applies Material's text size ++ // to this label alone, so Material lifts the selected item by the size difference between the two labels. + largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize) +- largeLabel.typeface = fontFamily ++ largeLabel.setTypeface(fontFamily, Typeface.BOLD) ++ largeLabel.letterSpacing = 0f + } + } + diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch new file mode 100644 index 000000000000..95dc7a725632 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch @@ -0,0 +1,33 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +index 9238416..d7d2024 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt +@@ -64,24 +64,10 @@ internal class TabsAppearanceApplicator( + bottomNavigationView.itemTextColor = ColorStateList(states, fontColors) + + // Icon color +- val iconDisabledColor = +- tabBarAppearance?.disabled?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconFocusedColor = +- tabBarAppearance?.focused?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconSelectedColor = +- tabBarAppearance?.selected?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer) +- +- val iconNormalColor = +- tabBarAppearance?.normal?.tabBarItemIconColor +- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant) +- +- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor) +- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors) ++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph and the user's ++ // avatar on the account tab. A tint list paints over all of it with a single color, so no tint is applied and ++ // the bitmap is drawn as supplied, which is how `tinted: false` already behaves on iOS. ++ bottomNavigationView.itemIconTintList = null + + // LabelVisibilityMode + // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch new file mode 100644 index 000000000000..ee75c7d7fdea --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index 1183c51..303635c 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -160,7 +160,7 @@ export function NativeBottomTabView({ + ios: PlatformColor('label'), + default: colors.text + }); +- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined; ++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); + const onTransitionStart = ({ + closing, + route diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch new file mode 100644 index 000000000000..9dd8d44e6bd1 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch @@ -0,0 +1,151 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +index e6ea55b..4d577fe 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac + import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; + import Color from 'color'; + import * as React from 'react'; +-import { Platform, PlatformColor } from 'react-native'; ++import { Platform, PlatformColor, StyleSheet, View } from 'react-native'; + import { Tabs } from 'react-native-screens'; + import { Deferred } from "./Deferred.js"; + import { NativeScreen } from "./NativeScreen/NativeScreen.js"; +-import { jsx as _jsx } from "react/jsx-runtime"; ++import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; + const meta = { + type: 'native-tabs' + }; +@@ -32,9 +32,21 @@ export function NativeBottomTabView({ + fonts + } = useTheme(); + const focusedRouteKey = state.routes[state.index].key; ++ // A route with `tabBarItemHidden` gets no native tab item. While it is focused, it is drawn over the native host ++ // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath. ++ const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true; ++ const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]); ++ // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped. ++ const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route)); ++ const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey; ++ // Hidden routes stay mounted once visited, the same as the native tabs. ++ const visitedHiddenRouteKeysRef = React.useRef(new Set()); ++ if (isFocusedRouteHidden) { ++ visitedHiddenRouteKeysRef.current.add(focusedRouteKey); ++ } + const [nativeState, dispatch] = React.useReducer(reducer, { + confirmed: { +- routeKey: focusedRouteKey, ++ routeKey: nativeSelectedRouteKey, + provenance: 0 + } + }); +@@ -189,17 +201,17 @@ export function NativeBottomTabView({ + const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; + const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; + const bottomAccessory = currentOptions.bottomAccessory; +- return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { +- children: /*#__PURE__*/_jsx(Tabs.Host, { ++ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, { ++ children: [/*#__PURE__*/_jsx(Tabs.Host, { + navStateRequest: { +- selectedScreenKey: focusedRouteKey, ++ selectedScreenKey: nativeSelectedRouteKey, + baseProvenance: nativeState.confirmed.provenance + }, + rejectStaleNavStateUpdates: true, + onTabSelected: onTabSelected, + onTabSelectionRejected: onTabSelectionRejected, + onTabSelectionPrevented: onTabSelectionPrevented, +- tabBarHidden: shouldHideTabBar, ++ tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, + colorScheme: dark ? 'dark' : 'light', + nativeContainerStyle: { + backgroundColor: colors.background +@@ -213,6 +225,9 @@ export function NativeBottomTabView({ + tabBarTintColor: activeTintColor + }, + children: state.routes.map((route, index) => { ++ if (isRouteHidden(route)) { ++ return null; ++ } + const { + options, + render, +@@ -224,6 +239,7 @@ export function NativeBottomTabView({ + title, + lazy = false, + tabBarLabel, ++ tabBarAccessibilityLabel, + tabBarSelectionEnabled, + tabBarBadgeStyle, + tabBarIcon, +@@ -290,6 +306,7 @@ export function NativeBottomTabView({ + preventNativeSelection: tabBarSelectionEnabled === false, + badgeValue: tabBarBadge?.toString(), + title: tabTitle, ++ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, + nativeContainerStyle: { + backgroundColor: colors.background + }, +@@ -369,9 +386,40 @@ export function NativeBottomTabView({ + }) + }, route.key); + }) +- }) ++ }, "native-tabs-host"), ...state.routes.map((route, index) => { ++ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) { ++ return null; ++ } ++ const { ++ options, ++ render, ++ navigation ++ } = descriptors[route.key]; ++ const isFocused = state.index === index; ++ return /*#__PURE__*/_jsx(View, { ++ style: [StyleSheet.absoluteFill, { ++ backgroundColor: colors.background ++ }, !isFocused && styles.hiddenRoute], ++ pointerEvents: isFocused ? 'auto' : 'none', ++ children: /*#__PURE__*/_jsx(ScreenWithHeader, { ++ isFocused: isFocused, ++ route: route, ++ navigation: navigation, ++ options: options, ++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { ++ value: meta, ++ children: render() ++ }) ++ }) ++ }, route.key); ++ })] + }); + } ++const styles = StyleSheet.create({ ++ hiddenRoute: { ++ display: 'none' ++ } ++}); + function ScreenWithHeader({ + isFocused, + route, +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +index ac25ed1..c05b04b 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts ++++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +@@ -78,6 +78,15 @@ type IconIOSSfSymbol = { + }; + export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage; + export type NativeBottomTabNavigationOptions = NativeHeaderOptions & { ++ /** ++ * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn over the ++ * native tabs and the tab bar is hidden. ++ */ ++ tabBarItemHidden?: boolean; ++ /** ++ * Accessibility label for the tab bar item, read by the screen reader. Useful when the tab has no visible label. ++ */ ++ tabBarAccessibilityLabel?: string; + /** + * Title text for the screen. + */ diff --git a/patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch similarity index 70% rename from patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch index 6c845817d9b0..8cc2e4e479bd 100644 --- a/patches/react-navigation/@react-navigation+native+7.1.33+001+initial.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch @@ -1,48 +1,26 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 474db5e..4d94ed8 100644 +index 58a543b..c475462 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -1,10 +1,27 @@ +@@ -1,6 +1,7 @@ "use strict"; import { nanoid } from 'nanoid/non-secure'; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; + const getPathWithoutHash = path => path.split('#')[0] ?? ''; export function createMemoryHistory() { let index = 0; - let items = []; - -+ // const log = () => { -+ // console.log(JSON.stringify({ -+ // index, -+ // indexGetter: history.index, -+ // items: items.map((item, i) => { -+ // var _item$state; -+ // return { -+ // selected: history.index === i ? '<<<<<<<' : undefined, -+ // path: item.path, -+ // id: item.id, -+ // state: ((_item$state = item.state) === null || _item$state === void 0 ? void 0 : _item$state.key) || null -+ // }; -+ // }) -+ // }, null, 4)); -+ // }; -+ - // Pending callbacks for `history.go(n)` - // We might modify the callback stored if it was interrupted, so we have a ref to identify it - const pending = []; -@@ -17,7 +34,10 @@ export function createMemoryHistory() { - it.cb = () => cb(true); +@@ -19,6 +20,9 @@ export function createMemoryHistory() { }); }; -- const history = { -+ return { + const history = { + get items() { + return items; + }, get index() { // We store an id in the state instead of an index // Index could get out of sync with in-memory values if page reloads -@@ -32,12 +52,13 @@ export function createMemoryHistory() { +@@ -33,7 +37,8 @@ export function createMemoryHistory() { return items[index]; }, backIndex({ @@ -50,15 +28,18 @@ index 474db5e..4d94ed8 100644 + path, + state }) { - // We need to find the index from the element before current to get closest path to go back to - for (let i = index - 1; i >= 0; i--) { - const item = items[i]; -- if (item.path === path) { -+ if (item.path === path && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { + const pathWithoutHash = getPathWithoutHash(path); + +@@ -43,7 +48,7 @@ export function createMemoryHistory() { + if (item == null) { + continue; + } +- if (item.path === pathWithoutHash) { ++ if (item.path === pathWithoutHash && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { return i; } } -@@ -77,7 +98,7 @@ export function createMemoryHistory() { +@@ -85,7 +90,7 @@ export function createMemoryHistory() { // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path @@ -67,16 +48,9 @@ index 474db5e..4d94ed8 100644 const hash = pathWithHash.includes('#') ? '' : location.hash; if (!items.length || items.findIndex(item => item.id === id) < 0) { // There are two scenarios for creating an array with only one history record: -@@ -211,6 +232,5 @@ export function createMemoryHistory() { - return () => window.removeEventListener('popstate', onPopState); - } - }; -- return history; - } - //# sourceMappingURL=createMemoryHistory.js.map diff --git a/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js b/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js new file mode 100644 -index 0000000..b996822 +index 0000000..290e96f --- /dev/null +++ b/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js @@ -0,0 +1,8 @@ @@ -84,23 +58,23 @@ index 0000000..b996822 + +import { findFocusedRoute } from '@react-navigation/core'; +export function findFocusedRouteKey(state) { -+ const focusedRoute = findFocusedRoute(state); -+ return focusedRoute?.key; ++ const focusedRoute = findFocusedRoute(state); ++ return focusedRoute?.key; +} +//# sourceMappingURL=findFocusedRouteKey.js.map diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js -index d30009f..0dab822 100644 +index 66b73b5..99b630d 100644 --- a/node_modules/@react-navigation/native/lib/module/useLinking.js +++ b/node_modules/@react-navigation/native/lib/module/useLinking.js -@@ -4,6 +4,7 @@ import { findFocusedRoute, getActionFromState as getActionFromStateDefault, getP +@@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { createMemoryHistory } from "./createMemoryHistory.js"; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; import { ServerContext } from "./ServerContext.js"; + /** - * Find the matching navigation state that changed between 2 navigation states -@@ -45,6 +46,43 @@ export const series = cb => { +@@ -114,6 +115,43 @@ export const series = cb => { return callback; }; const linkingHandlers = []; @@ -144,17 +118,17 @@ index d30009f..0dab822 100644 export function useLinking(ref, { enabled = true, config, -@@ -256,6 +294,9 @@ export function useLinking(ref, { - // Otherwise it's likely a change triggered by `popstate` - path !== pendingPath) { - const historyDelta = (focusedState.history ? focusedState.history.length : focusedState.routes.length) - (previousFocusedState.history ? previousFocusedState.history.length : previousFocusedState.routes.length); +@@ -409,6 +447,9 @@ export function useLinking(ref, { + const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - ( + // Subtract the delta already applied by the browser to sync only the remaining changes + pendingPopStateDelta ?? 0); + + // The historyDelta and historyDeltaByKeys may differ if the new state has an entry that didn't exist in previous state -+ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState); ++ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState) - (pendingPopStateDelta ?? 0); if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState -@@ -267,7 +308,8 @@ export function useLinking(ref, { +@@ -420,7 +461,8 @@ export function useLinking(ref, { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ @@ -164,7 +138,7 @@ index d30009f..0dab822 100644 }); const currentIndex = history.index; try { -@@ -276,11 +318,26 @@ export function useLinking(ref, { +@@ -429,11 +471,26 @@ export function useLinking(ref, { history.get(nextIndex)) { // An existing entry for this path exists and it's less than current index, go back to that await history.go(nextIndex - currentIndex); @@ -192,7 +166,7 @@ index d30009f..0dab822 100644 } // Store the updated state as well as fix the path if incorrect -@@ -293,10 +350,22 @@ export function useLinking(ref, { +@@ -446,10 +503,26 @@ export function useLinking(ref, { } } else { // If history length is unchanged, we want to replaceState @@ -210,11 +184,15 @@ index d30009f..0dab822 100644 + state + }); + } else { -+ await history.go(-staleHistoryDiff); -+ history.push({ -+ path, -+ state -+ }); ++ try { ++ await history.go(-staleHistoryDiff); ++ history.push({ ++ path, ++ state ++ }); ++ } catch (e) { ++ // The navigation was interrupted ++ } + } } } else { diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch similarity index 88% rename from patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch index 4d7806ce3819..062073b6a427 100644 --- a/patches/react-navigation/@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +index c475462..f8e7d1c 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -189,10 +189,14 @@ +@@ -188,10 +188,14 @@ export function createMemoryHistory() { cb: done }); @@ -17,8 +18,8 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistor + // back/forward navigation and reset the app to a stale route // We're using a hacky timeout since there doesn't seem to be way to know for sure const timer = setTimeout(() => { - const foundIndex = pending.findIndex(it => it.ref === done); -@@ -201,7 +205,7 @@ + window.removeEventListener('popstate', onPopState); +@@ -201,7 +205,7 @@ export function createMemoryHistory() { pending.splice(foundIndex, 1); } index = this.index; diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 955b43b2c86b..6dbc289facd8 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,12 +1,12 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch -#### [@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch) @@ -24,13 +24,13 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097) -### [@react-navigation+native+7.1.33+001+initial.patch](@react-navigation+native+7.1.33+001+initial.patch) +### [@react-navigation+native+7.3.16+001+initial.patch](@react-navigation+native+7.3.16+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. - E/App issue: [#21356](https://github.com/Expensify/App/issues/21356) - PR Introducing Patch: [#24165](https://github.com/Expensify/App/pull/24165) -- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) +- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) [#103346](https://github.com/Expensify/App/pull/103346) ### [@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch](@react-navigation+stack+7.8.5+001+edge-drag-gesture.patch) @@ -49,7 +49,7 @@ - PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. -### [@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch) +### [@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch) - Reason: @@ -67,6 +67,24 @@ - Upstream PR/issue: https://github.com/react-navigation/react-navigation/issues/11145 - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) +- PR Updating Patch: [#103346](https://github.com/Expensify/App/pull/103346) +- Note: Upstream raised the timeout to 1000ms in `@react-navigation/native` 7.5.0, so this patch can be dropped on the bump to 7.5.0 or newer. + +### [@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch) + +- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. +- Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A + +### [@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch) + +- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. +- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. +- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169) +- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- PR Updating Patch: N/A ### [@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch](@react-navigation+stack+7.8.5+005+clip-inactive-card-inside-card.patch) @@ -77,4 +95,4 @@ - PR Updating Patch: N/A - Note: Not gated to the RHP. Every stack now clips inactive cards one level lower, so the built-in `styles.shadow` edge view inside `Card` is clipped on inactive cards too. The active card covers that edge. - Note: Upstream shipped the fix in `@react-navigation/stack` 7.11.2, first release containing https://github.com/react-navigation/react-navigation/pull/13255. Their shape differs from ours: `CardA11yWrapper` drops `overflow` completely, and `CardContainer` passes `pageOverflowEnabled: active && headerMode !== 'float' && presentation !== 'modal'`, so the clip lands in `CardContent`, whose `styles.card` still carries `overflow: 'hidden'`. Inactive cards stay clipped and the stacked RHP shadow survives, but the clip sits one level below ours, which leaves the built-in `styles.shadow` edge view unclipped on inactive cards. -- Note: Drop this patch by moving to `stack` 7.11.2 or newer, see #101715 above. It cannot be bumped on its own: `createStackNavigator` there reads `render` from `useNavigationBuilder`, which starts in `core` 7.22.1, and `elements` 2.9.43 plus every sibling declare `native ^7.4.1` as a peer, so npm rejects 7.11.2 next to our `native` 7.1.33. `stack+001+edge-drag-gesture` and `stack+002+dontDetachScreen` need hand-porting to 7.11.2 because it rewrote the gesture and animation lifecycle in `Card.js` and switched the gesture wrapper to `pointerEvents: "auto"`. +- Note: Drop this patch by moving to `stack` 7.11.2 or newer, see #101715 above. It cannot be bumped on its own: `createStackNavigator` there reads `render` from `useNavigationBuilder`, which starts in `core` 7.22.1, and `elements` 2.9.43 plus every sibling declare `native ^7.4.1` as a peer, so npm rejects 7.11.2 next to our `native` 7.3.16. `stack+001+edge-drag-gesture` and `stack+002+dontDetachScreen` need hand-porting to 7.11.2 because it rewrote the gesture and animation lifecycle in `Card.js` and switched the gesture wrapper to `pointerEvents: "auto"`. diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 034067553d11..5b48576d3f3e 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; @@ -45,6 +46,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [personalDetails] = useAllPersonalDetails(); const styles = useThemeStyles(); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, true); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. @@ -162,10 +164,11 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio { - startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, - forceTransaction: true, - }); - startNavigateToReportsSpans(); + startNavigateToReportsTabSpans(); Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuery)); }); diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 6d75e37b5ea0..dcf891ed9d51 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -1,6 +1,7 @@ import Icon from '@components/Icon'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,15 +34,17 @@ function getIconFill(isSelected: boolean, isHovered: boolean, theme: ReturnType< function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicatorColor, numberOfLines = 2}: TabBarItemProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( - <> + {!!statusIndicatorColor && ( @@ -49,11 +52,17 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator {label} - + ); } diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index e76c98b3398b..bfbc7bb71b85 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -14,6 +14,19 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; +/** The latest Spend search kept in Onyx, for a Spend tab that has not shown a search yet. */ +function getStoredSearchTabParams(lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { + const lastQueryJSON = lastSearchParams?.queryJSON; + const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; + if (lastQueryFromOnyx) { + // The persisted search key belongs to the persisted query, so it only travels with it. + return {query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}; + } + + const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + return {query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}; +} + function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastSearchRoute = getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT); @@ -28,15 +41,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt } } - const lastQueryJSON = lastSearchParams?.queryJSON; - const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; - if (lastQueryFromOnyx) { - // The persisted search key belongs to the persisted query, so it only travels with it. - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx, searchKey: lastSearchParams?.searchKey}); - } - - const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES}); + return ROUTES.SEARCH_ROOT.getRoute(getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery)); } export default getSearchTabRoute; +export {getStoredSearchTabParams}; diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index f934146a96da..432f205cd96f 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -169,6 +169,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig icon={expensifyIcons.Home} label={translate('common.home')} isSelected={selectedTab === NAVIGATION_TABS.HOME} + numberOfLines={1} /> + + + ); } diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx new file mode 100644 index 000000000000..0ecf905ca0d3 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.android.tsx @@ -0,0 +1,29 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type TabRootScreenWrapperProps from './types'; + +/** + * Material's bar is drawn over the full-height tab screen, so a narrow tab root screen reserves the bar's row under its + * content, and the ScreenWrapper adds the gesture inset the bar sits on below it. Account drawn over the tabs hides the bar. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); + const styles = useThemeStyles(); + + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return {}; + } + + return {bottomContent: }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx new file mode 100644 index 000000000000..3b815cf130c2 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -0,0 +1,35 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFloatingButtonsBottom} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions'; + +import type TabRootScreenWrapperProps from './types'; + +/** + * On narrow layouts the content of a tab root screen runs under the translucent UITabBar, down to the bottom edge of + * the screen, so the ScreenWrapper runs edge to edge and adds no bottom padding, to the screen or to the offline + * indicator. Each tab root's list lets UIKit inset its end past the bar, and the offline indicator sits above the bar. + * Account drawn over the tabs hides the bar. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSettingsDrawnOverTabs = useIsSettingsDrawnOverTabs(); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + if (!shouldUseNarrowLayout || (selectedTab === NAVIGATION_TABS.SETTINGS && isSettingsDrawnOverTabs)) { + return {}; + } + + return { + enableEdgeToEdgeBottomSafeAreaPadding: false, + offlineIndicatorStyle: styles.tabBarOfflineIndicator(getFloatingButtonsBottom(bottomInset)), + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx new file mode 100644 index 000000000000..c2acf741d829 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -0,0 +1,29 @@ +import type TabBarBottomContentProps from '@components/Navigation/TabBarBottomContent/types'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; + +import type TabRootScreenWrapperProps from './types'; + +import TabBarBottomContent from '..'; + +/** + * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it + * down to the bottom edge of the screen. Each tab root's list and the offline indicator end with room for the bar. + */ +function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + + return { + bottomContent: , + bottomContentStyle: shouldUseNarrowLayout ? [styles.stickToBottom, styles.overflowVisible] : styles.overflowVisible, + offlineIndicatorStyle: shouldUseNarrowLayout ? styles.tabBarOfflineIndicator(variables.floatingTabBarHeight + variables.floatingTabBarBottomInset) : undefined, + }; +} + +export default useTabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts new file mode 100644 index 000000000000..44e8a56a8a2f --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -0,0 +1,7 @@ +import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {ScreenWrapperOfflineIndicatorsProps} from '@components/ScreenWrapper/ScreenWrapperOfflineIndicators'; + +type TabRootScreenWrapperProps = Pick & + Pick; + +export default TabRootScreenWrapperProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx new file mode 100644 index 000000000000..65aaa5912a7a --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx @@ -0,0 +1,13 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; + +/** + * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves + * the bar's row below the content, so the content container keeps its style. + */ +function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { + return {contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx new file mode 100644 index 000000000000..c75a5c774fc8 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -0,0 +1,27 @@ +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import variables from '@styles/variables'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; + +/** + * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with + * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area + * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. + */ +function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPadding = false): TabRootScrollProps { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const insetStyle = useBottomSafeSafeAreaPaddingStyle({ + style, + addBottomSafeAreaPadding: !hasBottomSafeAreaPadding, + addOfflineIndicatorBottomSafeAreaPadding: false, + additionalPaddingBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, + }); + + return {contentContainerStyle: shouldUseNarrowLayout ? insetStyle : style}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts new file mode 100644 index 000000000000..61dc387fad90 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -0,0 +1,5 @@ +import type {ScrollViewProps} from 'react-native'; + +type TabRootScrollProps = Pick; + +export default TabRootScrollProps; diff --git a/src/components/Navigation/TopBar.tsx b/src/components/Navigation/TopBar.tsx index 16163c9ad1c2..90dff1001aab 100644 --- a/src/components/Navigation/TopBar.tsx +++ b/src/components/Navigation/TopBar.tsx @@ -1,3 +1,4 @@ +import Icon from '@components/Icon'; import LoadingBar from '@components/LoadingBar'; import {PressableWithoutFeedback} from '@components/Pressable'; import SearchButton from '@components/Search/SearchRouter/SearchButton'; @@ -7,8 +8,10 @@ import {useWideRHPState} from '@components/WideRHPContextProvider'; import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import SignInButton from '@pages/inbox/sidebar/SignInButton'; @@ -37,12 +40,25 @@ type TopBarProps = { cancelSearch?: () => void; children?: React.ReactNode; breadcrumbAnimatedStyle?: StyleProp>; + /** Shows a back arrow before the breadcrumb that calls this when pressed. */ + onBackButtonPress?: () => void; }; const authTokenTypeSelector = (session: OnyxEntry) => session && {authTokenType: session.authTokenType}; -function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpButton = false, cancelSearch, shouldShowLoadingBar, children, breadcrumbAnimatedStyle}: TopBarProps) { +function TopBar({ + breadcrumbLabel, + shouldDisplaySearch = true, + shouldDisplayHelpButton = false, + cancelSearch, + shouldShowLoadingBar, + children, + breadcrumbAnimatedStyle, + onBackButtonPress, +}: TopBarProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow']); const {translate} = useLocalize(); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); const isAnonymousUser = isAnonymousUserUtil(session); @@ -58,11 +74,25 @@ function TopBar({breadcrumbLabel, shouldDisplaySearch = true, shouldDisplayHelpB return ( Keyboard.dismiss() : undefined} > + {!!onBackButtonPress && ( + + + + )} ; * A thin wrapper around TopBar that encapsulates useLoadingBarVisibility(). * Use this in places where shouldShowLoadingBar is NOT passed explicitly by the parent. */ -function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, children}: TopBarWithLoadingBarProps) { +function TopBarWithLoadingBar({breadcrumbLabel, shouldDisplaySearch, shouldDisplayHelpButton, cancelSearch, onBackButtonPress, children}: TopBarWithLoadingBarProps) { const shouldShowLoadingBar = useLoadingBarVisibility(); return ( {children} diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 8164250b667d..97fe20695996 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import {FlashList} from '@shopify/flash-list'; @@ -23,6 +24,7 @@ function BaseSearchList({ stickyHeaderIndices, getItemType, }: BaseSearchListProps) { + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); const renderItemWithoutKeyboardFocus = useCallback( ({item, index}: {item: SearchListItem; index: number}) => { return renderItem(item, index, false, undefined); @@ -45,7 +47,7 @@ function BaseSearchList({ onLayout={onLayout} removeClippedSubviews drawDistance={250} - contentContainerStyle={contentContainerStyle} + {...tabRootScrollProps} maintainVisibleContentPosition={{disabled: true}} stickyHeaderIndices={stickyHeaderIndices} getItemType={getItemType} diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 3fcad84abb92..cf573939637c 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -1,4 +1,5 @@ import {useEditingCellState} from '@components/EditableCell'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; @@ -74,6 +75,7 @@ function BaseSearchList({ disabledIndexes, overrideItemLayout, }: BaseSearchListProps) { + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); const {focusedCellId, isEditingCell} = useEditingCellState(); @@ -193,7 +195,7 @@ function BaseSearchList({ CellRendererComponent={CellRendererComponent} removeClippedSubviews drawDistance={250} - contentContainerStyle={contentContainerStyle} + {...tabRootScrollProps} maintainVisibleContentPosition={{disabled: true}} stickyHeaderIndices={stickyHeaderIndices} stickyHeaderConfig={stickyHeaderConfig} diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 4f1ae02ce830..c2daaa19bbcb 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -1,3 +1,4 @@ +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; @@ -69,6 +70,7 @@ type WorkspaceListTableProps = { }; export default function WorkspaceListTable({ref, workspaces, headerComponent, onDeleteWorkspace, onArchiveWorkspace, pendingDeletePolicyID}: WorkspaceListTableProps) { + const {contentInsetAdjustmentBehavior} = useTabRootScrollProps(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); @@ -211,6 +213,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on keyExtractor={(row, index) => `${row.policyID}-${index}`} filters={filterConfig} isItemInFilter={isItemInFilter} + contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} > {tableHeaderComponent} diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index 8e4d03b04fd5..a78cee210fcc 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -18,7 +18,7 @@ import {View} from 'react-native'; import {useDebugTabViewHeight} from './Navigation/DebugTabView'; import NAVIGATION_TABS from './Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from './Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from './Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from './Navigation/TopBarWithLoadingBar'; import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; @@ -91,6 +91,7 @@ function WorkspaceListHeaderContent({activeTabKey, headerButton, shouldShowHeade function WorkspaceListLayout({children, activeTabKey, headerButton, headerComponent, scrollHeaderWithTable = false}: WorkspaceListLayoutProps) { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -122,8 +123,7 @@ function WorkspaceListLayout({children, activeTabKey, headerButton, headerCompon shouldEnableMaxHeight shouldEnablePickerAvoiding={false} enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContentStyle={styles.overflowVisible} - bottomContent={} + {...tabRootScreenWrapperProps} > diff --git a/src/hooks/useHasTabBeenShown.ts b/src/hooks/useHasTabBeenShown.ts new file mode 100644 index 000000000000..f4ee1cf3de54 --- /dev/null +++ b/src/hooks/useHasTabBeenShown.ts @@ -0,0 +1,37 @@ +import {getTabNavigatorState} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import type NAVIGATORS from '@src/NAVIGATORS'; + +import type {NavigationState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +import {useState} from 'react'; + +import useRootNavigationState from './useRootNavigationState'; + +type TabNavigatorName = ValueOf; + +/** Whether the tab is shown or under a screen drawn over it, rather than a background tab of the tab navigator. */ +function isTabShown(rootState: NavigationState | undefined, tabName: TabNavigatorName) { + if (rootState?.routes.some((route) => route.name === tabName)) { + return true; + } + const tabState = getTabNavigatorState(rootState); + return !tabState || tabState.routes.at(tabState.index ?? 0)?.name === tabName; +} + +/** + * Whether the tab has been shown since the screen mounted. Native tab bars mount every tab at startup, so a tab root + * screen uses it to hold back the requests and writes it makes on mount until the user first opens the tab, as it does + * when it mounts with the tab. + */ +function useHasTabBeenShown(tabName: TabNavigatorName) { + const isShown = useRootNavigationState((rootState) => isTabShown(rootState, tabName)); + const [hasBeenShown, setHasBeenShown] = useState(isShown); + if (isShown && !hasBeenShown) { + setHasBeenShown(true); + } + return hasBeenShown; +} + +export default useHasTabBeenShown; diff --git a/src/hooks/useInboxTabIndicatorStatus.ts b/src/hooks/useInboxTabIndicatorStatus.ts new file mode 100644 index 000000000000..0420652be03a --- /dev/null +++ b/src/hooks/useInboxTabIndicatorStatus.ts @@ -0,0 +1,26 @@ +import CONST from '@src/CONST'; + +import {useChatTabBrickRoad} from './useSidebarOrderedReports'; +import useTheme from './useTheme'; + +type InboxTabIndicatorStatusResult = { + /** The chat tab's brick road status, or undefined when no chat needs attention. */ + status: ReturnType; + + /** The indicator dot color: success for an info status, danger for anything else, none without a status. */ + indicatorColor: string | undefined; +}; + +function useInboxTabIndicatorStatus(): InboxTabIndicatorStatusResult { + const theme = useTheme(); + const status = useChatTabBrickRoad(); + + let indicatorColor: string | undefined; + if (status) { + indicatorColor = status === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger; + } + + return {status, indicatorColor}; +} + +export default useInboxTabIndicatorStatus; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts new file mode 100644 index 000000000000..ba0c059aafa2 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.native.ts @@ -0,0 +1,19 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getTabWithoutBarItem from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; + +/** + * When the native tab bar has no Account item, the Account tab is drawn over the other tabs + * as a full screen with the bar hidden, and it needs its own way back. + */ +function useIsSettingsDrawnOverTabs() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + return shouldUseNarrowLayout && getTabWithoutBarItem(isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useIsSettingsDrawnOverTabs/index.ts b/src/hooks/useIsSettingsDrawnOverTabs/index.ts new file mode 100644 index 000000000000..7229ecc8f572 --- /dev/null +++ b/src/hooks/useIsSettingsDrawnOverTabs/index.ts @@ -0,0 +1,6 @@ +/** On web the Account tab stays a tab root screen above the JS tab bar, whether or not the bar shows its item. */ +function useIsSettingsDrawnOverTabs() { + return false; +} + +export default useIsSettingsDrawnOverTabs; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 80dd9483a600..c544c8ed72ea 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -72,6 +72,9 @@ const SidebarOrderedReportsStateContext = createContext(undefined); + const SidebarOrderedReportsActionsContext = createContext({ clearLHNCache: () => {}, setActiveTab: () => {}, @@ -474,7 +477,9 @@ function SidebarOrderedReportsContextProvider({ return ( - {children} + + {children} + ); } @@ -483,6 +488,10 @@ function useSidebarOrderedReportsState() { return useContext(SidebarOrderedReportsStateContext); } +function useChatTabBrickRoad() { + return useContext(SidebarChatTabBrickRoadContext); +} + function useSidebarOrderedReportsActions() { return useContext(SidebarOrderedReportsActionsContext); } @@ -495,5 +504,5 @@ function useSidebarOrderedReports() { return useMemo(() => ({...state, ...actions}), [state, actions]); } -export {SidebarOrderedReportsContextProvider, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; +export {SidebarOrderedReportsContextProvider, useChatTabBrickRoad, useSidebarOrderedReports, useSidebarOrderedReportsState, useSidebarOrderedReportsActions}; export type {ReportsToDisplayInLHN}; diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx index 50672780db45..a794924c3020 100644 --- a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx @@ -1,7 +1,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -// On native, tab-level freezing is handled by the TabNavigator's `freezeOnBlur: true` option, -// which uses react-native-screens to natively freeze inactive tabs. +// On native, background tabs are not frozen: the native tab bar shows a tab before JS can unfreeze it, so a frozen +// tab would flash an empty screen on every switch. // On web, react-native-screens only sets `display: none` without actually freezing the React tree, // so the web FreezeWrapper uses `react-freeze` to suspend rendering of background tabs. function FreezeWrapper({children}: ChildrenProps) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts new file mode 100644 index 000000000000..495f8dfcdf42 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS.ts @@ -0,0 +1,84 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type NativeTabGlyphPath = { + d: string; + isEvenOdd?: boolean; +}; + +type NativeTabGlyph = { + /** Side of the square viewBox the paths are drawn in. */ + viewBoxSize: number; + paths: NativeTabGlyphPath[]; +}; + +/** + * Vector copies of the tab bar SVGs in `assets/images`, so Skia draws each glyph at the bar's size and the device's + * density on both platforms. They have to be kept in sync with those files. + */ +const NATIVE_TAB_GLYPHS = { + // home.svg + [SCREENS.HOME]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.386 1.477a1 1 0 0 1 1.229 0l7.613 5.922c.487.379.772.962.772 1.579V18a1 1 0 0 1-1 1h-5v-6a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v6H2a1 1 0 0 1-1-1V8.978c0-.617.285-1.2.772-1.579z', + }, + ], + }, + // inbox.svg + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M4.5 1.5a3 3 0 0 0-3 3v11a3 3 0 0 0 3 3h11a3 3 0 0 0 3-3v-11a3 3 0 0 0-3-3zm-1 3a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1V12H13c-.276 0-.495.226-.549.497a2.5 2.5 0 0 1-4.902 0C7.494 12.226 7.276 12 7 12H3.5z', + isEvenOdd: true, + }, + ], + }, + // receipt-multiple.svg + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M9.72 1.24c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.54-.83a.35.35 0 0 1 .64.14l2.18 15.49c.04.32-.33.52-.58.31l-.75-.65a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.84 1.28c-.12.18-.36.21-.52.07l-1.15-1a.35.35 0 0 0-.52.07l-.83 1.28c-.12.18-.36.21-.52.07l-1.16-1a.35.35 0 0 0-.52.07l-.54.83a.35.35 0 0 1-.64-.14L.97 3.11c-.04-.32.33-.52.58-.31l.75.65c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28c.12-.18.36-.21.52-.07l1.15 1c.16.14.41.1.52-.07l.83-1.28Zm1.37 10.99-5.2.73a.87.87 0 0 0-.74.99c.07.48.51.81.99.75l5.2-.73c.48-.07.81-.51.75-.99a.886.886 0 0 0-.99-.75ZM8.87 9.01 5.4 9.5c-.48.07-.81.51-.75.99.07.48.51.81.99.75l3.47-.49c.48-.07.81-.51.75-.99a.87.87 0 0 0-.99-.74Zm1.24-3.71-5.2.73c-.48.07-.81.51-.75.99.07.48.51.81.99.75l5.2-.73a.87.87 0 0 0 .74-.99.886.886 0 0 0-.99-.75Z', + isEvenOdd: true, + }, + { + d: 'M15.15 3.09c.1.16.32.19.46.07l1.03-.89c.14-.12.36-.09.46.06l.74 1.14c.1.16.32.19.46.07l.67-.58c.22-.19.55 0 .51.28l-1.94 13.79c-.04.28-.41.37-.57.13l-.49-.74s-.02-.03-.04-.04L14.42 2.03l.7 1.07Z', + }, + ], + }, + // pie-chart.svg + [SCREENS.INSIGHTS]: { + viewBoxSize: 24, + paths: [ + {d: 'M15.598.074C14.94-.008 14.4.537 14.4 1.2v8.4h8.4c.663 0 1.208-.54 1.126-1.198A9.605 9.605 0 0 0 15.598.074'}, + {d: 'M22.734 13.198C22.138 18.6 17.56 22.8 12 22.8 6.035 22.8 1.2 17.965 1.2 12c0-5.56 4.2-10.138 9.602-10.734C11.46 1.193 12 1.737 12 2.4V12h9.6c.663 0 1.207.54 1.134 1.198'}, + ], + }, + // buildings.svg + [NAVIGATORS.WORKSPACE_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + { + d: 'M1 3a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8v-3a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v3H3a2 2 0 0 1-2-2zm2 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m0 4a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3-7a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m5-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m3 1a1 1 0 1 1 2 0 1 1 0 0 1-2 0m1 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0m-3-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-1 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0', + isEvenOdd: true, + }, + ], + }, + // profile.svg, with its circle written as a path + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { + viewBoxSize: 20, + paths: [ + {d: 'M13 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'}, + {d: 'M10 1c-5 0-9 4-9 9s4 9 9 9 9-4 9-9-4-9-9-9m5.9 12.7C14.6 12.1 12.4 11 10 11s-4.6 1.1-5.9 2.7C3.4 12.6 3 11.4 3 10c0-3.9 3.1-7 7-7s7 3.1 7 7c0 1.4-.4 2.6-1.1 3.7'}, + ], + }, +} as const satisfies Record; + +/** A tab of the native tab bar, by its route name. */ +type NativeTabName = keyof typeof NATIVE_TAB_GLYPHS; + +export default NATIVE_TAB_GLYPHS; +export type {NativeTabGlyph, NativeTabName}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx new file mode 100644 index 000000000000..1328b8270df7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx @@ -0,0 +1,74 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React, {useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +import NativeTabBarShadow from './NativeTabBarShadow'; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +/** What a narrow layout draws over the tabs along with the native bar, kept apart so wide layouts skip its hooks. */ +function NativeTabBarOverlay({state, descriptors}: Pick) { + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + + // Cancel any in-flight tab-navigation span that doesn't match the new focused tab. The new tab's span is started at + // the tap, before navigation, so it is kept. On wide layouts the JS side bar does this. + useEffect(() => { + cancelTabNavigationSpans(NAVIGATION_TAB_TO_SPANS[selectedTab]); + }, [selectedTab]); + + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + return ( + <> + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + + + + )} + + ); +} + +export default NativeTabBarOverlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx new file mode 100644 index 000000000000..d3f248535c5b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.ios.tsx @@ -0,0 +1,6 @@ +/** UITabBar draws its own glass edge over the content. */ +function NativeTabBarShadow() { + return null; +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx new file mode 100644 index 000000000000..2ad21697e685 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarShadow/index.tsx @@ -0,0 +1,20 @@ +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +/** Material's bar shares the screen's background, so a shadow along its top edge marks where the content ends. */ +function NativeTabBarShadow() { + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + + return ( + + ); +} + +export default NativeTabBarShadow; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx new file mode 100644 index 000000000000..777c74986afd --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -0,0 +1,40 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; + +import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; + +import React from 'react'; +import {View} from 'react-native'; + +import NativeTabBarOverlay from './NativeTabBarOverlay'; +import TabPressListeners from './TabPressListeners'; + +type NativeTabLayoutProps = Parameters>[0]; + +function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + + // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. + return ( + + {!shouldUseNarrowLayout && } + {children} + + {shouldUseNarrowLayout && ( + + )} + + ); +} + +export default NativeTabLayout; +export type {NativeTabLayoutProps}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx new file mode 100644 index 000000000000..7e61362931a5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -0,0 +1,62 @@ +import {getStoredSearchTabParams} from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useOnyx from '@hooks/useOnyx'; +import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnNavigate'; + +import navigationRef from '@libs/Navigation/navigationRef'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; + +import {CommonActions} from '@react-navigation/native'; +import {useEffect} from 'react'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +/** + * The native bar's Spend and Workspaces handling that needs Onyx data, so the data re-renders only this component. + * Spend's first visit opens the latest search kept in Onyx, so the tab mounted at startup with the default search gets + * it before it is first shown, both for the native bar and for the side bar of a wide layout. Workspaces restores the + * last workspace, or the list for one that is gone, whenever the tab does not already show it. + */ +function TabPressListeners({state, descriptors}: Pick) { + const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + const getTabRoute = (routeName: string) => state.routes.find((tabRoute) => tabRoute.name === routeName); + const spendNavigatorState = getTabRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.state; + const spendNavigatorKey = spendNavigatorState?.key; + const spendRootRoute = spendNavigatorState?.routes.at(0); + const spendRootRouteKey = spendRootRoute?.key; + const spendRootQuery = spendRootRoute?.params && 'q' in spendRootRoute.params ? spendRootRoute.params.q : undefined; + const {query: storedQuery, searchKey: storedSearchKey} = getStoredSearchTabParams(lastSearchParams, lastExpensesSearchQuery); + const workspacesDescriptor = descriptors[getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)?.key ?? '']; + const workspacesNavigation = workspacesDescriptor?.navigation; + const isWorkspacesSwitchedByJS = workspacesDescriptor?.options.tabBarSelectionEnabled === false; + + useEffect(() => { + if (hasSpendBeenShown || !spendNavigatorKey || !spendRootRouteKey || spendRootQuery === storedQuery) { + return; + } + navigationRef.dispatch({...CommonActions.setParams({q: storedQuery, searchKey: storedSearchKey}), source: spendRootRouteKey, target: spendNavigatorKey}); + }, [hasSpendBeenShown, spendNavigatorKey, spendRootRouteKey, spendRootQuery, storedQuery, storedSearchKey]); + + // Runs on the focused tab too, where it returns from a workspace to the list. + useEffect( + () => + workspacesNavigation?.addListener('tabPress', () => { + if (!workspacesNavigation.isFocused() && !isWorkspacesSwitchedByJS) { + return; + } + navigateToWorkspaces(); + }), + [workspacesNavigation, isWorkspacesSwitchedByJS, navigateToWorkspaces], + ); + + return null; +} + +export default TabPressListeners; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts new file mode 100644 index 000000000000..502b8506ea4e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/getTabWithoutBarItem.ts @@ -0,0 +1,12 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * The native bar shows at most five of the six tabs. With the Insights beta, Insights takes the Account tab's place + * and Account moves to the top bar; without it, Insights has no item. + */ +function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) { + return isInsightsBetaEnabled ? NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR : SCREENS.INSIGHTS; +} + +export default getTabWithoutBarItem; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts new file mode 100644 index 000000000000..baeb69da5aaa --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -0,0 +1,211 @@ +import FontUtils from '@styles/utils/FontUtils'; +import variables from '@styles/variables'; + +import type {NativeBottomTabIcon, NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; +import type {SkCanvas, SkImage, SkParagraph, SkPath} from '@shopify/react-native-skia'; + +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia, TextAlign} from '@shopify/react-native-skia'; +import {PixelRatio} from 'react-native'; + +import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; +import type {TabAvatarImage} from './useTabAvatarImage'; + +import NATIVE_TAB_GLYPHS from './NATIVE_TAB_GLYPHS'; + +type TabIconLayout = { + /** Side of a glyph's box. */ + glyphSize: number; + /** Side of the account avatar's box. */ + avatarSize: number; +}; + +type TabIconLabel = { + text: string; + color: string; + isBold: boolean; + fontSize: number; + /** Gap between the glyph box and the label. */ + gap: number; + /** Width of the tab's slot in the bar, past which the label is truncated with an ellipsis. */ + maxWidth: number; +}; + +type TabIconParams = { + /** The glyph drawn unless an avatar is given. */ + name: NativeTabName; + color: string; + avatar?: TabAvatarImage; + dotColor?: string; + label?: TabIconLabel; +}; + +/** Upper bound on cached icons, enough for every tab in both selection states across a few status and theme changes. */ +const MAX_CACHED_ICONS = 64; + +const iconCache = new Map(); +const glyphPathCache = new Map(); + +function getGlyphPaths(name: NativeTabName): SkPath[] { + const cachedPaths = glyphPathCache.get(name); + if (cachedPaths) { + return cachedPaths; + } + const paths: SkPath[] = []; + const glyph: NativeTabGlyph = NATIVE_TAB_GLYPHS[name]; + for (const {d, isEvenOdd} of glyph.paths) { + const path = Skia.Path.MakeFromSVGString(d); + if (!path) { + continue; + } + if (isEvenOdd) { + path.setFillType(FillType.EvenOdd); + } + paths.push(path); + } + glyphPathCache.set(name, paths); + return paths; +} + +/** A paragraph rather than a single font, because only a paragraph falls back to a system font for scripts Expensify Neue lacks. */ +function makeLabelParagraph({text, color, isBold, fontSize, maxWidth}: TabIconLabel, scale: number): SkParagraph { + const paragraph = Skia.ParagraphBuilder.Make({maxLines: 1, ellipsis: '…', textAlign: TextAlign.Left}) + .pushStyle({ + color: Skia.Color(color), + fontFamilies: [FontUtils.fontFamily.single.EXP_NEUE.fontFamily], + fontSize: fontSize * scale, + fontStyle: {weight: isBold ? FontWeight.Bold : FontWeight.Normal}, + }) + .addText(text) + .build(); + paragraph.layout(maxWidth * scale); + return paragraph; +} + +function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { + const paint = Skia.Paint(); + paint.setColor(Skia.Color(color)); + paint.setAntiAlias(true); + const viewBoxScale = size / NATIVE_TAB_GLYPHS[name].viewBoxSize; + + canvas.save(); + canvas.translate(left, top); + canvas.scale(viewBoxScale, viewBoxScale); + for (const path of getGlyphPaths(name)) { + canvas.drawPath(path, paint); + } + canvas.restore(); + paint.dispose(); +} + +function drawCircularImage(canvas: SkCanvas, image: SkImage, left: number, top: number, size: number) { + const sourceSize = Math.min(image.width(), image.height()); + const circle = Skia.Path.Make(); + circle.addCircle(left + size / 2, top + size / 2, size / 2); + const paint = Skia.Paint(); + + canvas.save(); + canvas.clipPath(circle, ClipOp.Intersect, true); + canvas.drawImageRectOptions( + image, + Skia.XYWHRect((image.width() - sourceSize) / 2, (image.height() - sourceSize) / 2, sourceSize, sourceSize), + Skia.XYWHRect(left, top, size, size), + FilterMode.Linear, + MipmapMode.Linear, + paint, + ); + canvas.restore(); + paint.dispose(); + circle.dispose(); +} + +/** A cutout rather than a border in the bar's color, because the iOS 26 bar is glass and has no single color. */ +function drawStatusDot(canvas: SkCanvas, right: number, top: number, scale: number, color: string) { + const radius = variables.nativeTabIconDotRadius * scale; + const centerX = right - radius; + const centerY = top + radius; + const paint = Skia.Paint(); + paint.setAntiAlias(true); + paint.setBlendMode(BlendMode.Clear); + canvas.drawCircle(centerX, centerY, radius + variables.nativeTabIconDotCutout * scale, paint); + paint.setBlendMode(BlendMode.SrcOver); + paint.setColor(Skia.Color(color)); + canvas.drawCircle(centerX, centerY, radius, paint); + paint.dispose(); +} + +function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, label}: TabIconParams): NativeBottomTabIcon | undefined { + const scale = PixelRatio.get(); + const contentSize = (avatar ? layout.avatarSize : layout.glyphSize) * scale; + // With a label, every tab shares one row as tall as the avatar, so glyphs and the avatar share a centre and + // every label lands at the same height. + const rowSize = label ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; + const labelTop = (rowSize + layout.glyphSize * scale) / 2 + (label?.gap ?? 0) * scale; + const paragraph = label ? makeLabelParagraph(label, scale) : undefined; + const labelWidth = paragraph?.getLongestLine() ?? 0; + const labelHeight = paragraph?.getHeight() ?? 0; + const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); + const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); + + const surface = Skia.Surface.Make(canvasWidth, canvasHeight); + if (!surface) { + paragraph?.dispose(); + return undefined; + } + + const canvas = surface.getCanvas(); + const contentLeft = (canvasWidth - contentSize) / 2; + const contentTop = (rowSize - contentSize) / 2; + if (avatar) { + drawCircularImage(canvas, avatar.image, contentLeft, contentTop, contentSize); + } else { + drawGlyph(canvas, name, contentLeft, contentTop, contentSize, color); + } + if (dotColor) { + drawStatusDot(canvas, contentLeft + contentSize, contentTop, scale, dotColor); + } + if (paragraph) { + paragraph.paint(canvas, (canvasWidth - labelWidth) / 2, labelTop); + paragraph.dispose(); + } + + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(ImageFormat.PNG, 100); + snapshot.dispose(); + surface.dispose(); + + return { + type: 'image', + source: {uri: `data:image/png;base64,${base64}`, width: canvasWidth / scale, height: canvasHeight / scale, scale}, + tinted: false, + }; +} + +/** + * Draws a tab icon synchronously, so the bar never waits for it, and reuses it for as long as everything drawn into it + * stays the same. A status, theme or language change redraws only the icons it reaches. + */ +function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { + const {name, color, avatar, dotColor, label} = params; + // The layout and the label's font are fixed per platform, so they are left out of the key. + const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, label?.maxWidth].join('|'); + if (iconCache.has(key)) { + return iconCache.get(key); + } + if (iconCache.size >= MAX_CACHED_ICONS) { + iconCache.clear(); + } + const icon = drawTabIcon(layout, params); + iconCache.set(key, icon); + return icon; +} + +/** Both selection states as images, given as a function, because React Navigation derives the selected icon only from a function. */ +function getTabBarIcon(layout: TabIconLayout, getParams: (isSelected: boolean) => TabIconParams): NativeBottomTabNavigationOptions['tabBarIcon'] { + const inactiveIcon = getTabIcon(layout, getParams(false)); + const activeIcon = getTabIcon(layout, getParams(true)); + return inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined; +} + +export default getTabBarIcon; +export type {TabIconLayout}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts new file mode 100644 index 000000000000..50b556171740 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners.ts @@ -0,0 +1,87 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans} from '@libs/telemetry/startTabNavigationSpans'; + +import SCREENS from '@src/SCREENS'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {ValueOf} from 'type-fest'; + +type NavigationTab = ValueOf; + +/** Tabs the JS tab buttons keep from anonymous users, who get the sign-in modal instead. */ +const ANONYMOUS_GATED_TABS = new Set([NAVIGATION_TABS.SEARCH, NAVIGATION_TABS.INSIGHTS, NAVIGATION_TABS.WORKSPACES, NAVIGATION_TABS.SETTINGS]); + +type TabRoute = { + name: string; + state?: NavigationState | PartialState; +}; + +/** Whether Inbox shows its chat list, which is where its JS tab button opens it. */ +function isInboxTabAtChatList(inboxRoute: TabRoute | undefined) { + return getFocusedLeafScreenName(inboxRoute?.state) === SCREENS.INBOX; +} + +/** + * What a tap on the native bar does on top of the tab switch, mirroring the JS tab buttons the native bar replaces. + * Workspaces is handled by TabPressListeners, which needs Onyx data. + */ +const NAVIGATION_TAB_PRESS_HANDLERS: Partial void>> = { + [NAVIGATION_TABS.INBOX]: (route) => (isInboxTabAtChatList(route) ? startNavigateToInboxTabSpan({isWideLayout: false}) : navigateToInboxTab()), + [NAVIGATION_TABS.SEARCH]: () => interceptAnonymousUser(startNavigateToReportsTabSpans), + [NAVIGATION_TABS.INSIGHTS]: () => interceptAnonymousUser(() => {}), + [NAVIGATION_TABS.SETTINGS]: () => interceptAnonymousUser(() => {}), +}; + +type NativeTabSelectionParams = { + isAnonymousUser: boolean; + isInboxAtChatList: boolean; + /** Whether Workspaces shows what its JS tab button restores, see useIsWorkspacesTabRestored. */ + isWorkspacesTabRestored: boolean; +}; + +/** + * Whether the native bar switches to the tab itself, or leaves the switch to the tab's press handler. The native switch + * is kept whenever the tab already shows what its JS tab button opens, because a prevented selection makes the iOS bar + * slide back to the current tab before JS moves it to the new one. + */ +function isNativeTabSelectionEnabled(routeName: string, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}: NativeTabSelectionParams) { + const tab = ROUTE_TO_NAVIGATION_TAB[routeName]; + if (isAnonymousUser && ANONYMOUS_GATED_TABS.has(tab)) { + return false; + } + if (tab === NAVIGATION_TABS.INBOX) { + return isInboxAtChatList; + } + if (tab === NAVIGATION_TABS.WORKSPACES) { + return isWorkspacesTabRestored; + } + return true; +} + +type TabScreenListenerProps = { + route: TabRoute; + navigation: {isFocused: () => boolean}; +}; + +/** + * Native tabs emit `tabPress` when a user tap reaches JS, both for a tab the bar switched to and for one whose + * selection is disabled. A tap on the already focused tab does nothing, the same as a JS tab button pressed on its own tab. + */ +function tabScreenListeners({route, navigation}: TabScreenListenerProps) { + return { + tabPress: () => { + if (navigation.isFocused()) { + return; + } + NAVIGATION_TAB_PRESS_HANDLERS[ROUTE_TO_NAVIGATION_TAB[route.name]]?.(route); + }, + }; +} + +export default tabScreenListeners; +export {isInboxTabAtChatList, isNativeTabSelectionEnabled}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts new file mode 100644 index 000000000000..928dd7accded --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useIsWorkspacesTabRestored.ts @@ -0,0 +1,50 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import getFocusedLeafScreenName from '@libs/Navigation/helpers/getFocusedLeafScreenName'; +import {isPendingDeletePolicy, shouldShowPolicy} from '@libs/PolicyUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Domain, Policy} from '@src/types/onyx'; + +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; + +type WorkspacesTabRoute = { + state?: NavigationState | PartialState; +}; + +const domainExistsSelector = (domain: OnyxEntry) => !!domain?.accountID; + +/** + * Whether the Workspaces tab shows what its JS tab button restores on narrow layouts, so a plain switch to the tab + * lands on the same page: the workspaces list, the initial page of a workspace that is still shown, or a domain that + * still exists. Any other page is replaced by the tab button, see navigateToWorkspacesPage. + */ +function useIsWorkspacesTabRestored(workspacesTabRoute: WorkspacesTabRoute | undefined) { + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + const navigatorState = workspacesTabRoute?.state; + const topRoute = navigatorState?.routes.at(navigatorState.index ?? -1); + const splitRootParams = topRoute?.state?.routes.at(0)?.params; + const policyID = topRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR && splitRootParams && 'policyID' in splitRootParams ? String(splitRootParams.policyID) : undefined; + const domainAccountID = + topRoute?.name === NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR && splitRootParams && 'domainAccountID' in splitRootParams ? String(splitRootParams.domainAccountID) : undefined; + const isPolicyShownSelector = (policy: OnyxEntry) => shouldShowPolicy(policy, false, currentUserLogin) && !isPendingDeletePolicy(policy); + const [isPolicyShown = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isPolicyShownSelector}); + const [doesDomainExist = false] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainExistsSelector}); + + switch (topRoute?.name) { + case SCREENS.WORKSPACES_LIST: + return true; + case NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR: + return !!policyID && isPolicyShown && getFocusedLeafScreenName(topRoute.state) === SCREENS.WORKSPACE.INITIAL; + case NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR: + return !!domainAccountID && doesDomainExist; + default: + return false; + } +} + +export default useIsWorkspacesTabRestored; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts new file mode 100644 index 000000000000..795b7afa62b9 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -0,0 +1,60 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, +}; + +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). + const screenOptions: NativeBottomTabNavigationOptions = { + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarLabel: tabLabels[name], + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + })), + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts new file mode 100644 index 000000000000..f6142f252a13 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -0,0 +1,75 @@ +import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabBarIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** UITabBar sits above the home indicator inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.iosNativeTabBarFloatingButtonsBottom + bottomInset; + +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOS, + // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. + avatarSize: variables.avatarNativeTabBarIOS, +}; + +/** + * iOS 26 never applies the inactive icon color or the title color from `UITabBarItemAppearance`, and paints every badge + * in the color of the selected tab, so each icon is drawn with its own colors, its status dot and its label. Both + * selection states are images, because RNScreens rejects a tab whose icon and selectedIcon differ in type. + */ +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + const {windowWidth} = useWindowDimensions(); + // One of the tabs never has a bar item, so the bar splits its width between the others. + const barItemCount = Object.keys(tabLabels).length - 1; + const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; + + const screenOptions: NativeBottomTabNavigationOptions = { + // The labels are drawn into the icons. + tabBarLabel: '', + tabBarActiveTintColor: theme.iconMenu, + tabBarInactiveTintColor: theme.icon, + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + // The background only lands on iOS 18 and below; iOS 26 keeps its own glass material. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + tabBarControllerMode: 'tabBar', + // The bar stays put while the content scrolls, instead of collapsing the way iOS 26 does by default. + tabBarMinimizeBehavior: 'none', + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ + tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ + name, + color: isSelected ? theme.iconMenu : theme.icon, + avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, + dotColor: dotColors[name], + label: { + text: tabLabels[name], + color: isSelected ? theme.text : theme.textSupporting, + isBold: isSelected, + fontSize: variables.fontSizeSmall, + gap: variables.nativeTabIconLabelGap, + maxWidth: labelMaxWidth, + }, + })), + // The label is drawn into the icon, so VoiceOver reads it from here. + tabBarAccessibilityLabel: tabLabels[name], + }); + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts new file mode 100644 index 000000000000..2401d88a0b31 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -0,0 +1,18 @@ +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +type UseNativeTabBarOptions = (params: NativeTabBarOptionsParams) => { + screenOptions: NativeBottomTabNavigationOptions; + getTabOptions: (name: NativeTabName) => NativeBottomTabNavigationOptions; +}; + +/** Only iOS and Android have a native tab bar, so other platforms get no options. */ +const useNativeTabBarOptions: UseNativeTabBarOptions = () => ({screenOptions: {}, getTabOptions: () => ({})}); + +const getFloatingButtonsBottom = (bottomInset: number) => bottomInset; + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts new file mode 100644 index 000000000000..4091ff2f6522 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/types.ts @@ -0,0 +1,9 @@ +type NativeTabBarOptionsParams = { + shouldShowNativeTabBar: boolean; + /** Whether the account tab has an item in a narrow layout's bar, so its avatar is drawn. */ + isAccountAvatarShown: boolean; + dotColors: Record; + tabLabels: Record; +}; + +export default NativeTabBarOptionsParams; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts new file mode 100644 index 000000000000..4d2de8177d63 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabNavigator.ts @@ -0,0 +1,130 @@ +import {useFullScreenBlockingViewState} from '@components/FullScreenBlockingViewContextProvider'; + +import useAccountTabIndicatorStatus from '@hooks/useAccountTabIndicatorStatus'; +import useInboxTabIndicatorStatus from '@hooks/useInboxTabIndicatorStatus'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useWorkspacesTabIndicatorStatus from '@hooks/useWorkspacesTabIndicatorStatus'; + +import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; +import Navigation from '@libs/Navigation/Navigation'; +import type {TabNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationAction, NavigationState, PartialState, Router, TabNavigationState} from '@react-navigation/native'; + +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; +import {isAnonymousSessionSelector} from '@selectors/Session'; +import {useEffect} from 'react'; + +import getTabWithoutBarItem from './getTabWithoutBarItem'; +import {isInboxTabAtChatList, isNativeTabSelectionEnabled} from './tabScreenListeners'; +import useIsWorkspacesTabRestored from './useIsWorkspacesTabRestored'; + +/** + * Root-level tab screens where the swipe-back gesture should be disabled. + * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. + * WORKSPACE.INITIAL is intentionally excluded, since swiping back from it returns to the workspace list. + */ +const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); + +/** stale === false distinguishes a fully realized NavigationState from a PartialState. */ +function isRealizedNavigationState(state: NavigationState | PartialState | undefined): state is NavigationState { + return state?.stale === false; +} + +function useNativeTabNavigator() { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBlockingViewVisible} = useFullScreenBlockingViewState(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {translate} = useLocalize(); + const [isAnonymousUser = false] = useOnyx(ONYXKEYS.SESSION, {selector: isAnonymousSessionSelector}); + const isInsightsTabVisible = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + const tabWithoutBarItem = getTabWithoutBarItem(isInsightsTabVisible); + const {indicatorColor: inboxDotColor} = useInboxTabIndicatorStatus(); + const {indicatorColor: workspacesIndicatorColor, status: workspacesIndicatorStatus} = useWorkspacesTabIndicatorStatus(); + const {indicatorColor: accountIndicatorColor, status: accountIndicatorStatus} = useAccountTabIndicatorStatus(); + const navigation = useNavigation(); + const parentNavigation = navigation.getParent(); + const isFocusedRouteTabRoot = useNavigationState((state) => TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(findFocusedRoute(state)?.name ?? '')); + const route = useRoute(); + // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via + // `useNavigationState((s) => s)` here because the navigator's body runs before + // mounts, so the nearest navigation listener context is still the parent stack's. + const tabState = useNavigationState((parentState) => parentState.routes.find((parentRoute) => parentRoute.key === route.key)?.state); + const activeTabRoute = isRealizedNavigationState(tabState) ? tabState.routes[tabState.index] : undefined; + // A tab with no item in the bar is drawn over the other tabs as a full screen, so the bar hides while it is focused. + const isActiveTabWithoutBarItem = activeTabRoute?.name === tabWithoutBarItem; + const shouldShowNativeTabBar = shouldUseNarrowLayout && isTabRouteAtRoot(activeTabRoute) && !isBlockingViewVisible && !isActiveTabWithoutBarItem; + const getTabRoute = (tabName: string) => (isRealizedNavigationState(tabState) ? tabState.routes.find((tabRoute) => tabRoute.name === tabName) : undefined); + const isInboxAtChatList = isInboxTabAtChatList(getTabRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR)); + const isWorkspacesTabRestored = useIsWorkspacesTabRestored(getTabRoute(NAVIGATORS.WORKSPACE_NAVIGATOR)); + + const dotColors: Record = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: inboxDotColor, + [NAVIGATORS.WORKSPACE_NAVIGATOR]: workspacesIndicatorStatus ? workspacesIndicatorColor : undefined, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: accountIndicatorStatus ? accountIndicatorColor : undefined, + }; + const tabLabels: Record = { + [SCREENS.HOME]: translate('common.home'), + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: translate('common.inbox'), + [NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]: translate('common.spend'), + [SCREENS.INSIGHTS]: translate('common.insights'), + [NAVIGATORS.WORKSPACE_NAVIGATOR]: translate('common.workspacesTabTitle'), + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: translate('initialSettingsPage.account'), + }; + + useEffect(() => { + if (!shouldUseNarrowLayout || !parentNavigation) { + return; + } + parentNavigation.setOptions({gestureEnabled: !isFocusedRouteTabRoot}); + }, [isFocusedRouteTabRoot, shouldUseNarrowLayout, parentNavigation]); + + useEffect(() => { + if (!isRealizedNavigationState(tabState)) { + return; + } + setPreservedNavigatorState(route.key, tabState); + }, [tabState, route.key]); + + // Without the beta, Insights has no bar item and its page is not found, so a restored or deep-linked Insights tab + // would be a full screen with no way out. Home replaces it, so going back does not return to it. Betas that are + // still loading are not a missing beta. + const isInsightsTabFocusedWithoutBeta = isBetaEnabledOrUnknown(CONST.BETAS.INSIGHTS_PAGE) === false && activeTabRoute?.name === SCREENS.INSIGHTS; + useEffect(() => { + if (!isInsightsTabFocusedWithoutBeta) { + return; + } + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); + }, [isInsightsTabFocusedWithoutBeta]); + + // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount + // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved + // state by overriding the bottom-tab router's getInitialState, the same pattern SplitRouter + // uses for its split navigators. + const tabRouterOverride = ( + originalRouter: Router, Action>, + ): Partial, Action>> => ({ + getInitialState: (configOptions) => { + const preserved = getPreservedNavigatorState>(route.key); + return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); + }, + }); + + const isAccountAvatarShown = shouldUseNarrowLayout && tabWithoutBarItem !== NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR; + + const isTabSelectionEnabled = (routeName: string) => isNativeTabSelectionEnabled(routeName, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}); + + return {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled}; +} + +export default useNativeTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts new file mode 100644 index 000000000000..b38d210f3cd0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage.ts @@ -0,0 +1,54 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; + +import {getAvatarURL} from '@libs/UserAvatarUtils'; + +import type {SkImage} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; +import {useEffect, useState} from 'react'; + +type TabAvatarImage = {uri: string; image: SkImage}; + +/** Held outside any component, so a status or theme redraw and a remounted navigator reuse the decoded avatar. */ +let lastDecodedAvatar: TabAvatarImage | undefined; + +/** + * The current user's avatar, decoded once per URI for the account tab icon while that icon is shown. Until it is + * decoded, and for avatars that are not a remote image, the account glyph stands in. + */ +function useTabAvatarImage(isShown: boolean): TabAvatarImage | undefined { + const {avatar, accountID} = useCurrentUserPersonalDetails(); + const avatarSource = getAvatarURL({avatarSource: avatar, accountID}); + const uri = typeof avatarSource === 'string' ? avatarSource : undefined; + const [decodedAvatar, setDecodedAvatar] = useState(lastDecodedAvatar); + const decodedUri = decodedAvatar?.uri; + + useEffect(() => { + if (!isShown || !uri || decodedUri === uri) { + return; + } + let isActive = true; + Skia.Data.fromURI(uri) + .then((data) => { + const image = Skia.Image.MakeImageFromEncoded(data); + data.dispose(); + if (!image) { + return; + } + lastDecodedAvatar = {uri, image}; + if (isActive) { + setDecodedAvatar(lastDecodedAvatar); + } + }) + // A failed decode keeps the account glyph. + .catch(() => undefined); + return () => { + isActive = false; + }; + }, [isShown, uri, decodedUri]); + + return decodedAvatar?.uri === uri ? decodedAvatar : undefined; +} + +export default useTabAvatarImage; +export type {TabAvatarImage}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts index 84825c7babaa..cbb02a5c88d8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/TAB_SCREENS.ts @@ -1,6 +1,7 @@ /** * Ordered list of screen names registered inside TabNavigator. - * This must match the Tab.Screen order in TabNavigator.tsx. + * This must match the Tab.Screen order in TabNavigator.tsx and TabNavigator.native.tsx, where it is also the order + * the native tab bar draws its items in. * Used by getAdaptedStateFromPath to build complete tab navigator state for deep-links. */ import NAVIGATORS from '@src/NAVIGATORS'; @@ -11,8 +12,8 @@ const TAB_SCREENS = [ NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ] as const; export default TAB_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 8b0b7c7e1856..6c4b97d1fedc 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -1,104 +1,60 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; - -import {getPreservedNavigatorState, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {bottomTabScreenLayoutWrapper} from '@libs/Navigation/PlatformStackNavigation/ScreenLayout'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import HomePage from '@pages/home/HomePage'; import InsightsPage from '@pages/Insights/InsightsPage'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -/** - * Tab Navigator containing Home, Inbox (Reports), Search, Insights, Settings, and Workspaces pages. - */ -import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {NavigationAction, NavigationState, Router, TabNavigationState} from '@react-navigation/native'; +import {createNativeBottomTabNavigator} from '@react-navigation/bottom-tabs/unstable'; +import React from 'react'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import type {NativeTabName} from './NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import type {NativeTabLayoutProps} from './NativeTabNavigator/NativeTabLayout'; +import NativeTabLayout from './NativeTabNavigator/NativeTabLayout'; +import tabScreenListeners from './NativeTabNavigator/tabScreenListeners'; +import useNativeTabBarOptions from './NativeTabNavigator/useNativeTabBarOptions'; +import useNativeTabNavigator from './NativeTabNavigator/useNativeTabNavigator'; import ReportsSplitNavigator from './ReportsSplitNavigator'; import SearchFullscreenNavigator from './SearchFullscreenNavigator'; import SettingsSplitNavigator from './SettingsSplitNavigator'; -import TabNavigatorBar from './TabNavigatorBar'; import WorkspaceNavigator from './WorkspaceNavigator'; -const renderTabBar = ({state}: BottomTabBarProps) => ; - -const Tab = createBottomTabNavigator(); - /** - * Root-level tab screens where the swipe-back gesture should be disabled. - * Swiping from these screens would pop the entire TAB_NAVIGATOR, which feels wrong. - * WORKSPACE.INITIAL is intentionally excluded — swiping back from it returns to the workspace list. + * Tab Navigator backed by the platform's own tab bar: UITabBar with the liquid glass material on iOS 26, and + * Material's BottomNavigationView on Android. Wide layouts keep the JS side bar, since neither native bar can be + * moved to the side of the screen. */ -const TAB_ROOT_SCREENS_WITHOUT_GESTURE = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT]); - -const TAB_SCREEN_OPTIONS_BASE = { - headerShown: false, - lazy: true, - animation: 'none' as const, - freezeOnBlur: true, - tabBarPosition: 'bottom' as const, -} as const; - -function TabNavigator() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const theme = useTheme(); - const navigation = useNavigation(); - const parentNavigation = navigation.getParent(); - const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); - const route = useRoute(); - // The Tab.Navigator's own state lives at `parentState.routes[i].state`. We can't read it via - // `useNavigationState((s) => s)` here because TabNavigator's body runs before - // mounts, so the nearest navigation listener context is still the parent stack's. - const tabState = useNavigationState((parentState) => parentState.routes.find((r) => r.key === route.key)?.state as NavigationState | undefined); - - useEffect(() => { - if (!shouldUseNarrowLayout || !parentNavigation) { - return; - } - const isRootScreen = TAB_ROOT_SCREENS_WITHOUT_GESTURE.has(focusedRouteName ?? ''); - parentNavigation.setOptions({gestureEnabled: !isRootScreen}); - }, [focusedRouteName, shouldUseNarrowLayout, parentNavigation]); +const Tab = createNativeBottomTabNavigator(); - useEffect(() => { - // stale === false distinguishes a fully realized NavigationState from a PartialState. - if (!tabState || tabState.stale !== false) { - return; - } - setPreservedNavigatorState(route.key, tabState); - }, [tabState, route.key]); +const HIDDEN_TAB_OPTIONS = {tabBarItemHidden: true}; - // The slicing optimization in useCustomRootStackNavigatorState can unmount and later remount - // this TAB_NAVIGATOR. Without restoration it would default to index 0. We restore the saved - // state by overriding the bottom-tab router's getInitialState — the same pattern SplitRouter - // uses for its split navigators. - const tabRouterOverride = ( - originalRouter: Router, Action>, - ): Partial, Action>> => ({ - getInitialState: (configOptions) => { - const preserved = getPreservedNavigatorState>(route.key); - return preserved ? originalRouter.getRehydratedState(preserved, configOptions) : originalRouter.getInitialState(configOptions); - }, - }); +const renderNativeTabLayout = (props: NativeTabLayoutProps) => ; - const screenOptions = { - ...TAB_SCREEN_OPTIONS_BASE, - sceneStyle: {flex: 1, backgroundColor: theme.appBG}, - }; +function TabNavigator() { + const {shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels, tabWithoutBarItem, tabRouterOverride, isTabSelectionEnabled} = useNativeTabNavigator(); + const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); + // A tab with no bar item draws no icon. + const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => + route.name === tabWithoutBarItem + ? {...screenOptions, ...HIDDEN_TAB_OPTIONS} + : { + ...screenOptions, + ...getTabOptions(route.name), + tabBarSelectionEnabled: isTabSelectionEnabled(route.name), + }; return ( - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx index d77a12be149d..6bf2ea7f9df1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.tsx @@ -133,14 +133,14 @@ function TabNavigator() { name={SCREENS.INSIGHTS} component={InsightsPageScreen} /> - + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx index 9b46ffd6e246..327776eaa50b 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigatorBar.tsx @@ -10,21 +10,15 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import isTabRouteAtRoot from '@libs/Navigation/helpers/isTabRouteAtRoot'; -import cancelTabNavigationSpans, {INBOX_TAB_SPAN_IDS, REPORTS_TAB_SPAN_IDS} from '@libs/telemetry/cancelTabNavigationSpans'; +import cancelTabNavigationSpans, {NAVIGATION_TAB_TO_SPANS} from '@libs/telemetry/cancelTabNavigationSpans'; import SCREENS from '@src/SCREENS'; import type {BottomTabBarProps} from '@react-navigation/bottom-tabs'; -import type {ValueOf} from 'type-fest'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; -const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { - [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, - [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, -}; - /** * Custom tab bar rendered by the BottomTabNavigator. Only receives `state` (not the * full BottomTabBarProps) to avoid `descriptors` thrashing memoization. @@ -40,7 +34,7 @@ function TabNavigatorBar({state}: Pick) { const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; const isAtRoot = isTabRouteAtRoot(activeRoute); // --- Narrow-only animation logic (hooks must run unconditionally per Rules of Hooks) --- - // On native, screens also render the tab bar via bottomContent for swipe-back animations. + // Screens also render the tab bar via bottomContent for swipe-back animations. // Delay showing this navigator's tab bar only when navigating back from a deeper screen // (where the tab bar was hidden). Keep it visible during tab switches so it doesn't flash. // Guard with shouldUseNarrowLayout so prevShouldHide stays false in wide layout, diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 3af6e3f40087..75f82f60e761 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -168,11 +168,12 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { return result ? getRehydratedState(result, configOptions) : result; } if (isPushingSidebarOnCentralPane(state, action, options)) { - if (getIsNarrowLayout()) { + if (getIsNarrowLayout() && state.index > 0) { const newAction = StackActions.popToTop(); return stackRouter.getStateForAction(state, newAction, configOptions); } // On wide screen do nothing as we want to keep the central pane screen and the sidebar is visible. + // On narrow screen the sidebar is already on top, and the stack router reports popToTop with nothing to pop as unhandled. return state; } diff --git a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx index 821007eb556a..c50b7a92b33a 100644 --- a/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx @@ -1,7 +1,8 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; -import type {BottomTabNavigationOptions, BottomTabNavigationProp} from '@react-navigation/bottom-tabs'; +import type {BottomTabNavigationOptions} from '@react-navigation/bottom-tabs'; +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native'; import React, {useLayoutEffect, useRef} from 'react'; @@ -26,18 +27,18 @@ function screenLayoutWrapper({navigation, ...rest}: ScreenLayoutArgs>) { - return ( - - ); +// Same as screenLayoutWrapper above, but for bottom-tab navigators, JS and native. No cast needed here - their +// `navigation` props' `addListener` structurally satisfies TransitionAwareNavigation. +function bottomTabScreenLayoutWrapper(props: ScreenLayoutProps) { + return ; } -type ScreenLayoutProps = ScreenLayoutArgs; +type ScreenLayoutProps = ScreenLayoutArgs< + ParamListBase, + string, + PlatformSpecificNavigationOptions | PlatformStackNavigationOptions | BottomTabNavigationOptions | NativeBottomTabNavigationOptions, + TransitionAwareNavigation +>; function ScreenLayout({children, navigation}: ScreenLayoutProps) { const transitionHandleRef = useRef(null); diff --git a/src/libs/telemetry/cancelTabNavigationSpans.ts b/src/libs/telemetry/cancelTabNavigationSpans.ts index 364a0cf2ab0e..604131421a1c 100644 --- a/src/libs/telemetry/cancelTabNavigationSpans.ts +++ b/src/libs/telemetry/cancelTabNavigationSpans.ts @@ -1,5 +1,9 @@ +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; + import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import {cancelSpan} from './activeSpans'; // Spans grouped by the tab they belong to. The Reports tab owns three (legacy plus the FirstPaint/ContentLoad @@ -11,6 +15,10 @@ const REPORTS_TAB_SPAN_IDS: readonly string[] = [ ]; const INBOX_TAB_SPAN_IDS: readonly string[] = [CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB]; const TAB_NAVIGATION_SPAN_IDS: readonly string[] = [...REPORTS_TAB_SPAN_IDS, ...INBOX_TAB_SPAN_IDS]; +const NAVIGATION_TAB_TO_SPANS: Partial, readonly string[]>> = { + [NAVIGATION_TABS.INBOX]: INBOX_TAB_SPAN_IDS, + [NAVIGATION_TABS.SEARCH]: REPORTS_TAB_SPAN_IDS, +}; /** * Cancels running tab-navigation spans so an abandoned tap does not leave one ticking until the user returns. @@ -26,4 +34,4 @@ function cancelTabNavigationSpans(preserve: readonly string[] = []) { } export default cancelTabNavigationSpans; -export {REPORTS_TAB_SPAN_IDS, INBOX_TAB_SPAN_IDS, TAB_NAVIGATION_SPAN_IDS}; +export {TAB_NAVIGATION_SPAN_IDS, NAVIGATION_TAB_TO_SPANS}; diff --git a/src/libs/telemetry/startTabNavigationSpans.ts b/src/libs/telemetry/startTabNavigationSpans.ts new file mode 100644 index 000000000000..59817a74b9ba --- /dev/null +++ b/src/libs/telemetry/startTabNavigationSpans.ts @@ -0,0 +1,37 @@ +import {isReportsTabPreloaded} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import navigationRef from '@navigation/navigationRef'; + +import CONST from '@src/CONST'; + +import {startSpan} from './activeSpans'; +import {startNavigateToReportsSpans} from './navigateToReportsSpans'; + +/** + * Starts ManualNavigateToInboxTab at the tab tap. Call it before the tab navigation is dispatched, because the + * preloaded state it records is dropped once the tab is jumped to. + */ +function startNavigateToInboxTabSpan({isWideLayout}: {isWideLayout: boolean}) { + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_INBOX_TAB, + forceTransaction: true, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_WIDE_LAYOUT]: isWideLayout, + [CONST.TELEMETRY.ATTRIBUTE_IS_PRELOADED]: isReportsTabPreloaded(navigationRef.getRootState()), + [CONST.TELEMETRY.ATTRIBUTE_WAITED_ON_OPEN_REPORT]: false, + }, + }); +} + +/** Starts the legacy ManualNavigateToReports span and its FirstPaint/ContentLoad split at the tab tap. */ +function startNavigateToReportsTabSpans() { + startSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { + name: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + op: CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, + forceTransaction: true, + }); + startNavigateToReportsSpans(); +} + +export {startNavigateToInboxTabSpan, startNavigateToReportsTabSpans}; diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index f4673a15d8b0..d5a73cfefa54 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -1,7 +1,8 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -66,6 +67,8 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); + const isEmptyState = state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY; + const scrollProps = useTabRootScrollProps(isEmptyState ? [styles.flexGrow1, styles.flexShrink0] : [styles.flexGrow1, pageGutter, styles.pb5], true); const didRequestFail = state === INSIGHTS_DASHBOARD_STATE.ERROR || state === INSIGHTS_DASHBOARD_STATE.STALE; @@ -107,10 +110,10 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte ); } - if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES || state === INSIGHTS_DASHBOARD_STATE.EMPTY) { + if (isEmptyState) { return ( {state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? : } @@ -124,7 +127,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte return ( @@ -163,6 +166,7 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const {translate} = useLocalize(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {login} = useCurrentUserPersonalDetails(); @@ -211,7 +215,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } + {...tabRootScreenWrapperProps} testID="InsightsPage" > (undefined); - useSearchPageSetup(currentSearchQueryJSON); + // Until Spend is first shown, it neither requests a search nor saves its default query over the latest search that the + // Spend tab reopens. + const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + useSearchPageSetup(hasSpendBeenShown ? currentSearchQueryJSON : undefined); useSeedMyExpensesSearch(); useReleaseOptionListCaches(); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 5d12dda25a3d..d72088764373 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -1,7 +1,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import PulsingView from '@components/PulsingView'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -13,6 +13,7 @@ import SearchPageHeaderNarrow from '@components/Search/SearchPageHeader/SearchPa import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import SearchWithNavigationDeferredMount from '@components/Search/SearchWithNavigationDeferredMount'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useEndSubmitNavigationSpans from '@hooks/useEndSubmitNavigationSpans'; @@ -77,8 +78,6 @@ type SearchPageNarrowProps = { isOverlayActive: boolean; }; -const tabBarContent = ; - function SearchPageNarrow({ queryJSON, searchResults, @@ -101,6 +100,7 @@ function SearchPageNarrow({ const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SEARCH); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {shouldUseLiveData} = useSearchResultsContext(); @@ -189,6 +189,8 @@ function SearchPageNarrow({ }); const [isInteractive, setIsInteractive] = useState(!useStaticRendering); const [isHeaderInteractive, setIsHeaderInteractive] = useState(!useStaticRendering); + // A page mounted in the background, as native tabs mount Spend, keeps its skeleton still until it is first shown. + const [hasBeenFocused, setHasBeenFocused] = useState(!useStaticRendering); const isHeaderInteractiveRef = useRef(isHeaderInteractive); const [, startTransition] = useTransition(); useEffect(() => { @@ -211,6 +213,7 @@ function SearchPageNarrow({ // useFocusEffect avoids the extra re-renders that useIsFocused causes on every focus change. useFocusEffect( useCallback(() => { + setHasBeenFocused(true); if (isInteractive) { return; } @@ -264,10 +267,9 @@ function SearchPageNarrow({ {!isMobileSelectionModeEnabled ? ( @@ -345,6 +347,12 @@ function SearchPageNarrow({ hasFilterBars={hasFilterBars} /> )} + {!isInteractive && !searchOverlayContent && ( + + )} {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( findFocusedRoute(state)?.name); @@ -102,8 +105,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > Navigation.dismissModal()} @@ -121,7 +123,7 @@ function DomainInitialPage({route}: DomainInitialPageProps) { shouldDisplayAccountButton /> - + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a09a8bb0a4f1..d0e434275934 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,6 +1,7 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import QuickCreationActionsBar from '@components/Navigation/QuickCreationActionsBar'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -45,6 +46,8 @@ function HomePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); + const tabRootScrollProps = useTabRootScrollProps([styles.homePageContentContainer, pageGutter], true); const {translate} = useLocalize(); useDocumentTitle(translate('common.home')); const {isOffline} = useNetwork({onReconnect: openHomePage}); @@ -146,8 +149,7 @@ function HomePage() { shouldShowOfflineIndicatorInWideScreen testID="HomePage" enableEdgeToEdgeBottomSafeAreaPadding={false} - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index dd92d4ecd347..1cacee978912 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -1,5 +1,5 @@ import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -22,6 +22,7 @@ import SidebarLinksData from './SidebarLinksData'; function BaseSidebarScreen() { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INBOX); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldShowSkeleton = useAppLoadSkeletonState(); @@ -40,8 +41,7 @@ function BaseSidebarScreen() { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, isMobile() ? styles.userSelectNone : {}]} testID="BaseSidebarScreen" - bottomContent={} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {({insets}) => ( <> diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 2ecdae3d7e9f..6699d926b7f5 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -44,20 +44,35 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.ACCOUNT} > {({hovered}) => ( - <> - + + {translate('initialSettingsPage.account')} - + )} ); diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 5c8f1b0c08e6..8baaac6f2884 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -2,7 +2,8 @@ import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherButton from '@components/AccountSwitcherButton'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -11,6 +12,9 @@ import Text from '@components/Text'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useIsSettingsDrawnOverTabs from '@hooks/useIsSettingsDrawnOverTabs'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -27,13 +31,14 @@ import {openInitialSettingsPage} from '@userActions/Wallet'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; -import {findFocusedRoute, useNavigationState, useRoute} from '@react-navigation/native'; +import {findFocusedRoute, useNavigation, useNavigationState, useRoute} from '@react-navigation/native'; import {canSwitchAccountsSelector} from '@selectors/Account'; import React, {useContext, useEffect, useLayoutEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -48,11 +53,35 @@ type InitialSettingsPageProps = WithCurrentUserPersonalDetailsProps; function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPageProps) { const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const [canSwitchAccounts = false] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canSwitchAccountsSelector}); - const tabBarContent = ; const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.SETTINGS); + const tabRootScrollProps = useTabRootScrollProps(styles.w100); const {isExecuting, singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); + const navigation = useNavigation(); + const isDrawnOverTabs = useIsSettingsDrawnOverTabs(); + + // The tab navigator keeps the full tab history, so going back returns to the tab the user opened Account from. + // With nothing behind it, as after a deep link, Home replaces Account so that going back does not reopen it. + const goBackFromAccount = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); + }; + + useAndroidBackButtonHandler(() => { + if (!isDrawnOverTabs) { + return false; + } + + goBackFromAccount(); + return true; + }); + const isScreenFocused = useIsSidebarRouteActive(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, shouldUseNarrowLayout); const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const {accountMenuItemsData, generalMenuItemsData} = useInitialSettingsPageMenuData(currentUserPersonalDetails); @@ -67,9 +96,15 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr Navigation.clearPreloadedRoutes(); }, [hasAccountBeenSwitched]); + const hasAccountBeenShown = useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR); + useEffect(() => { + if (!hasAccountBeenShown) { + return; + } + openInitialSettingsPage(); - }, []); + }, [hasAccountBeenShown]); const getMenuItemsSection = (menuItemsData: MenuSection, sectionStyle: StyleProp) => { return ( @@ -154,13 +189,13 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr includeSafeAreaPaddingBottom testID="InitialSettingsPage" shouldEnableKeyboardAvoidingView={false} - bottomContent={tabBarContent} - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > {!shouldUseNarrowLayout && !isPersonalDetailsEmpty && ( /* The top bar row ends 12px from the screen edge, so add 8px to sit the button 20px in. */ @@ -173,7 +208,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr ref={scrollViewRef} onScroll={onScroll} scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} - contentContainerStyle={[styles.w100]} + {...tabRootScrollProps} > {headerContent} {accountMenuItems} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fbd5a0c4c18d..41623a7f8574 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -2,7 +2,8 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import useTabRootScreenWrapperProps from '@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -71,6 +72,8 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); + const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.WORKSPACES); + const tabRootScrollProps = useTabRootScrollProps(styles.flexColumn); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -234,8 +237,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac } - bottomContentStyle={styles.overflowVisible} + {...tabRootScreenWrapperProps} > - + dismissError(policyID, policy?.pendingAction)} diff --git a/src/selectors/Session.ts b/src/selectors/Session.ts index 95ffe111e926..e222de6f92a2 100644 --- a/src/selectors/Session.ts +++ b/src/selectors/Session.ts @@ -7,6 +7,8 @@ const isSupportalSessionSelector = (session: OnyxEntry) => session?.aut const isDelegateSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.DELEGATE; +const isAnonymousSessionSelector = (session: OnyxEntry) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + const emailSelector = (session: OnyxEntry) => session?.email; const accountIDSelector = (session: OnyxEntry) => session?.accountID; @@ -18,4 +20,13 @@ const authTokenSelector = (session: OnyxEntry) => session?.authToken; /** Whether the user is signed in to a real account, which excludes the anonymous sessions used to view public rooms */ const hasNonAnonymousSessionSelector = (session: OnyxEntry) => !!session?.authToken && session.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS; -export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, hasNonAnonymousSessionSelector, isSupportalSessionSelector, isDelegateSessionSelector}; +export { + emailSelector, + accountIDSelector, + sessionEmailAndAccountIDSelector, + authTokenSelector, + hasNonAnonymousSessionSelector, + isSupportalSessionSelector, + isDelegateSessionSelector, + isAnonymousSessionSelector, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 2eac028b4aa8..f43d80a9d9c5 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -757,20 +757,58 @@ const staticStyles = (theme: ThemeColors) => overflow: 'visible', }, + // Only the mobile web bar uses this; iOS and Android draw native tab bars. navigationTabBarContainer: { flexDirection: 'row', - height: variables.bottomTabHeight, - borderTopWidth: 1, - borderTopColor: theme.border, + height: variables.floatingTabBarHeight, + marginHorizontal: variables.floatingTabBarHorizontalInset, + borderRadius: variables.componentBorderRadiusCircle, backgroundColor: theme.appBG, + borderWidth: variables.hairlineBorderWidth, + borderColor: theme.border, + // Insets the row so the selected item's capsule keeps the same margin inside the bar's rounded edge on + // every side, past the overhang it reaches beyond the first and last tabs. + ...spacing.pv1, + paddingHorizontal: spacing.p1.padding + variables.floatingTabBarSelectedOverhang, + // Clips each item's hover and press background to the capsule's rounded ends. + overflow: 'hidden', + }, + + // Stretches every tab to its slot, so a long label truncates inside the slot instead of spilling into its neighbors. + navigationTabBarItemNarrow: { + alignSelf: 'stretch', + paddingHorizontal: variables.floatingTabBarLabelInset, + }, + + navigationTabBarItemSelected: { + backgroundColor: theme.floatingTabBarSelectedBG, + borderRadius: variables.componentBorderRadiusCircle, + // The extra padding matches the negative margin, so the label keeps the same width selected or not. + paddingHorizontal: variables.floatingTabBarLabelInset + variables.floatingTabBarSelectedOverhang, + marginHorizontal: -variables.floatingTabBarSelectedOverhang, + }, + + androidNativeTabBarSpacer: { + height: variables.androidNativeTabBarHeight, }, + // The gap between the floating bar and the bottom edge of the screen. + floatingTabBarBottomInset: { + paddingBottom: variables.floatingTabBarBottomInset, + }, + + // Used both for a tab's pressable and for the wrapper around its icon and label. navigationTabBarItem: { height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', - paddingHorizontal: 4, + }, + + // Lets the floating bar's avatar overflow the glyph box evenly above and below, so its label lines up + // with the labels of the icons next to it and its centre with theirs. + navigationTabBarAvatar: { + marginVertical: (variables.iconFloatingTabBar - variables.avatarFloatingTabBar) / 2, }, navigationTabBarFABItem: { @@ -6948,6 +6986,39 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // The tab bar is laid over the bottom of a tab root screen, so the offline indicator floats above it without + // shrinking the content that runs under the bar. + tabBarOfflineIndicator: (bottom: number) => + ({ + position: 'absolute', + right: 0, + bottom, + left: 0, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned + // against it without taking part in the tab screen's layout. + nativeTabBarFloatingButtons: (bottom: number) => + ({ + position: 'absolute', + right: 0, + bottom, + left: 0, + height: 0, + }) satisfies ViewStyle, + + // Covers Material's bar and the gesture inset under it, and casts the shadow up over the screen content. + androidNativeTabBarShadow: (bottomInset: number) => + ({ + position: 'absolute', + right: 0, + bottom: 0, + left: 0, + height: variables.androidNativeTabBarHeight + bottomInset, + boxShadow: theme.androidTabBarShadow, + }) satisfies ViewStyle, + getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({ marginLeft: inputMarginLeft, width: amountWidth, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 6fb517723de7..0b7713364b01 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -58,6 +58,10 @@ const darkTheme = { signInSidebar: colors.green800, sidebar: colors.productDark100, sidebarHover: colors.productDark300, + floatingTabBarSelectedBG: 'rgba(26,61,50,0.50)', + // selectedOptionBG (green700) at 40% + androidTabBarActiveIndicatorBG: 'rgba(8,82,57,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.24)', heading: colors.productDark900, textLight: colors.productDark900, textDark: colors.productDark100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index b83c5a565946..534aaa23ae05 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -58,6 +58,10 @@ const lightTheme = { signInSidebar: colors.green800, sidebar: colors.productLight100, sidebarHover: colors.productLight300, + floatingTabBarSelectedBG: 'rgba(230,225,218,0.50)', + // selectedOptionBG (green100) at 40% + androidTabBarActiveIndicatorBG: 'rgba(177,242,214,0.40)', + androidTabBarShadow: '0px -4px 12px 0px rgba(2,18,4,0.06)', heading: colors.productLight900, textLight: colors.white, textDark: colors.productLight900, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index da9f8e06987c..6b9da58c6a12 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -2,6 +2,7 @@ import type {NavBarButtonStyle} from '@libs/NavBarManager/types'; import type CONST from '@src/CONST'; +import type {ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {ColorScheme, StatusBarStyle} from '..'; @@ -65,6 +66,10 @@ type ThemeColors = { signInSidebar: Color; sidebar: Color; sidebarHover: Color; + floatingTabBarSelectedBG: Color; + androidTabBarActiveIndicatorBG: Color; + /** A CSS box-shadow, not a color. */ + androidTabBarShadow: NonNullable; heading: Color; textLight: Color; textDark: Color; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 895135311b65..285cdf62b964 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2525,9 +2525,8 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ getTabBarNarrowStyle: (safeAreaPaddingBottom: number): ViewStyle => ({ overflow: 'visible', - marginTop: -(variables.bottomTabHeight + safeAreaPaddingBottom), - paddingBottom: safeAreaPaddingBottom, - backgroundColor: theme.appBG, + marginTop: -(variables.floatingTabBarHeight + variables.floatingTabBarBottomInset + safeAreaPaddingBottom), + paddingBottom: variables.floatingTabBarBottomInset + safeAreaPaddingBottom, }), getStyleWithEnvSafeAreaPadding: (style: ViewStyle): ViewStyle => ({ diff --git a/src/styles/variables.ts b/src/styles/variables.ts index bdaae0061cf3..9d1be56ccd03 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -31,6 +31,13 @@ const avatarSizes = { export default { bottomTabHeight: 72, + floatingTabBarHeight: 60, + floatingTabBarHorizontalInset: 16, + // How far the selected pill reaches past its tab on each side, whatever the label's length. + floatingTabBarSelectedOverhang: 4, + // Space between a label's truncation edge and its tab's edge, so a neighbor's selected pill never reaches the label. + floatingTabBarLabelInset: 8, + floatingTabBarBottomInset: 8, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), @@ -45,6 +52,7 @@ export default { spacing2: 8, rulesDocumentThumbnailMaxWidth: 368, rulesDocumentThumbnailHeight: 200, + hairlineBorderWidth: 0.5, componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusMedium: 6, @@ -98,6 +106,31 @@ export default { iconSizeMegaLarge: 105, iconSizeMenuItem: 32, iconBottomBar: 24, + /** Glyph size in the floating tab bar. */ + iconFloatingTabBar: 20, + /** Account avatar size in the floating tab bar. */ + avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, + /** Glyph size in the iOS native tab bar. */ + iconNativeTabBarIOS: 22, + /** Account avatar size in the iOS native tab bar. */ + avatarNativeTabBarIOS: 26, + /** Radius of the status dot drawn into a native tab bar icon. */ + nativeTabIconDotRadius: 4, + /** Width of the ring cut out of a native tab bar icon around its status dot, as the JS tab bar's dot border does. */ + nativeTabIconDotCutout: 2, + /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ + nativeTabIconLabelGap: 6, + /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ + iosNativeTabBarHorizontalInset: 25, + // UITabBar sizes its items to their icons and gives the selected pill a minimum width, so a label as wide as an even + // share of the bar lets that pill reach over the label next to it. + iosNativeTabBarLabelInset: 8, + /** Material's BottomNavigationView row, without the gesture inset under it. */ + androidNativeTabBarHeight: 80, + /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ + androidNativeTabBarFloatingButtonsBottom: 96, + /** UITabBar's row above the home indicator inset, plus the gap to the floating buttons. */ + iosNativeTabBarFloatingButtonsBottom: 50, iconHeader: 48, iconSection: 68, iouAmountTextSize: 40, diff --git a/tests/navigation/buildTabNavigatorNestedStateTests.ts b/tests/navigation/buildTabNavigatorNestedStateTests.ts index 2220c0f73ac8..ea0f8b8127dd 100644 --- a/tests/navigation/buildTabNavigatorNestedStateTests.ts +++ b/tests/navigation/buildTabNavigatorNestedStateTests.ts @@ -14,14 +14,14 @@ describe('buildTabNavigatorNestedState', () => { NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.INSIGHTS, - NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, NAVIGATORS.WORKSPACE_NAVIGATOR, + NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, ]); }); - it('sets index to the position of the selected tab (SETTINGS = 4)', () => { + it('sets index to the position of the selected tab (SETTINGS = 5)', () => { const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}); - expect(result.index).toBe(4); + expect(result.index).toBe(5); }); it('falls back to index 0 for an unknown tab name', () => { @@ -40,7 +40,7 @@ describe('buildTabNavigatorNestedState', () => { it('copies params from selectedTabRoute onto the matched route only', () => { const params = {screen: 'SomeScreen'}; const result = buildTabNavigatorNestedState({name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, params}); - expect(result.routes.at(4)?.params).toEqual(params); + expect(result.routes.at(5)?.params).toEqual(params); expect(result.routes.at(0)?.params).toBeUndefined(); expect(result.routes.at(1)?.params).toBeUndefined(); }); diff --git a/tests/ui/BaseSidebarScreenTest.tsx b/tests/ui/BaseSidebarScreenTest.tsx index 1daf35d456c8..eaf55a117f57 100644 --- a/tests/ui/BaseSidebarScreenTest.tsx +++ b/tests/ui/BaseSidebarScreenTest.tsx @@ -43,7 +43,6 @@ jest.mock('@components/ScreenWrapper', () => { }); jest.mock('@components/Navigation/TopBarWithLoadingBar', () => () => null); -jest.mock('@components/Navigation/TabBarBottomContent', () => () => null); jest.mock('@pages/inbox/sidebar/InboxTabSelector', () => () => null); jest.mock('@components/OptionsListSkeletonView', () => { const ReactModule = jest.requireActual('react'); diff --git a/tests/ui/InitialSettingsPageTest.tsx b/tests/ui/InitialSettingsPageTest.tsx index a5cef1a0dfff..df1b2a4cbd94 100644 --- a/tests/ui/InitialSettingsPageTest.tsx +++ b/tests/ui/InitialSettingsPageTest.tsx @@ -67,6 +67,8 @@ jest.mock('@userActions/App', () => ({ jest.mock('@libs/Navigation/helpers/useIsSidebarRouteActive', () => jest.fn(() => false)); +jest.mock('@hooks/useHasTabBeenShown', () => jest.fn(() => true)); + jest.mock('@hooks/useSubscriptionPlan', () => jest.fn(() => null)); jest.mock('@components/AccountSwitcher', () => { @@ -85,14 +87,6 @@ jest.mock('@components/AccountSwitcherSkeletonView', () => { return MockAccountSwitcherSkeletonView; }); -jest.mock('@components/Navigation/TabBarBottomContent', () => { - function MockTabBarBottomContent() { - return null; - } - MockTabBarBottomContent.displayName = 'TabBarBottomContent'; - return MockTabBarBottomContent; -}); - jest.mock('@components/Navigation/TopBarWithLoadingBar', () => { function MockTopBarWithLoadingBar() { return null; diff --git a/tests/ui/WorkspaceInitialPageTest.tsx b/tests/ui/WorkspaceInitialPageTest.tsx index d056856626cb..7d62d218c221 100644 --- a/tests/ui/WorkspaceInitialPageTest.tsx +++ b/tests/ui/WorkspaceInitialPageTest.tsx @@ -66,7 +66,7 @@ jest.mock('@hooks/useWorkspaceAccountID', () => jest.fn()); jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {children: React.ReactNode; shouldShow: boolean}) => mockFullPageNotFoundView(props)); jest.mock('@components/HeaderWithBackButton', () => jest.fn()); jest.mock('@components/HighlightableMenuItem', () => jest.fn()); -jest.mock('@components/Navigation/TabBarBottomContent', () => jest.fn()); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps', () => () => ({})); jest.mock( '@components/OfflineWithFeedback', () => diff --git a/tests/unit/InboxTabButtonTest.tsx b/tests/unit/InboxTabButtonTest.tsx index 9391613c69c6..8ec1a0319d58 100644 --- a/tests/unit/InboxTabButtonTest.tsx +++ b/tests/unit/InboxTabButtonTest.tsx @@ -53,7 +53,7 @@ jest.mock('@components/Navigation/NavigationTabBar/TabBarItem', () => () => null jest.mock('@components/Navigation/NavigationTabBar/getLastRoute', () => () => mockLastRoute); jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Inbox: 'inbox-icon'})})); jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -jest.mock('@hooks/useSidebarOrderedReports', () => ({useSidebarOrderedReportsState: () => ({chatTabBrickRoad: undefined})})); +jest.mock('@hooks/useSidebarOrderedReports', () => ({useChatTabBrickRoad: () => undefined})); jest.mock('@hooks/useTheme', () => () => ({danger: 'danger', iconSuccessFill: 'success'})); jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {}, diff --git a/tests/unit/Navigation/createMemoryHistoryTest.ts b/tests/unit/Navigation/createMemoryHistoryTest.ts index 2d661b1ca11b..632e099fa5b4 100644 --- a/tests/unit/Navigation/createMemoryHistoryTest.ts +++ b/tests/unit/Navigation/createMemoryHistoryTest.ts @@ -1,6 +1,7 @@ import type {NavigationState} from '@react-navigation/native'; type MemoryHistory = { + get: (index: number) => {id: string} | undefined; replace: (options: {path: string; state: NavigationState}) => void; push: (options: {path: string; state: NavigationState}) => void; go: (distance: number) => Promise | undefined; @@ -42,14 +43,19 @@ describe('createMemoryHistory', () => { }); it('keeps a delayed internal popstate from being delivered to external listeners', async () => { + // Given a history with two entries and an external popstate listener const history = createHistoryWithTwoEntries(); const listener = jest.fn(); const stopListening = history.listen(listener); + // When the traversal back lands just before the fallback timeout, as Firefox does under load const navigation = history.go(-1); jest.advanceTimersByTime(900); + window.history.replaceState({id: history.get(0)?.id}, '', '/r/1'); window.dispatchEvent(new PopStateEvent('popstate')); + // Then the traversal resolves and its popstate is not treated as a user navigation + await expect(navigation).resolves.toBeUndefined(); expect(listener).not.toHaveBeenCalled(); @@ -57,22 +63,26 @@ describe('createMemoryHistory', () => { }); it('cleans up a timed-out traversal so a later external popstate is delivered', async () => { + // Given a history with two entries and an external popstate listener const history = createHistoryWithTwoEntries(); const listener = jest.fn(); const stopListening = history.listen(listener); - const resolved = jest.fn(); + const settled = jest.fn(); + // When the traversal never lands in the browser const navigation = history.go(-1); - navigation?.then(resolved); + navigation?.then(settled, settled); + // Then it stays pending until the fallback timeout, and is rejected once the timeout passes without landing jest.advanceTimersByTime(999); await Promise.resolve(); - expect(resolved).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); jest.advanceTimersByTime(1); - await expect(navigation).resolves.toBeUndefined(); - expect(resolved).toHaveBeenCalledTimes(1); + await expect(navigation).rejects.toThrow('History was changed during navigation.'); + expect(settled).toHaveBeenCalledTimes(1); + // Then a later popstate is delivered to external listeners window.dispatchEvent(new PopStateEvent('popstate')); expect(listener).toHaveBeenCalledTimes(1); diff --git a/tests/unit/SplitRouterTest.ts b/tests/unit/SplitRouterTest.ts new file mode 100644 index 000000000000..024c1bb67095 --- /dev/null +++ b/tests/unit/SplitRouterTest.ts @@ -0,0 +1,34 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import SplitRouter from '@libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter'; + +import SCREENS from '@src/SCREENS'; + +import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; + +import {CommonActions} from '@react-navigation/native'; + +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); + +describe('SplitRouter', () => { + it('handles navigating to the sidebar on a narrow layout when the sidebar is already the only screen', () => { + // Given a narrow layout split navigator that shows only its sidebar, as the Inbox tab does at its chat list + jest.mocked(getIsNarrowLayout).mockReturnValue(true); + const router = SplitRouter({sidebarScreen: SCREENS.INBOX, defaultCentralScreen: SCREENS.REPORT, parentRoute: {key: 'split-key', name: 'ReportsSplitNavigator'}}); + const routeNames = [SCREENS.INBOX, SCREENS.REPORT]; + const state: StackNavigationState = { + stale: false, + type: 'stack', + key: 'stack-key', + index: 0, + routeNames, + preloadedRoutes: [], + routes: [{key: 'inbox-key', name: SCREENS.INBOX}], + }; + + // When the sidebar is navigated to again, which happens when the tab's navigation params are applied to an already mounted navigator + const result = router.getStateForAction(state, CommonActions.navigate(SCREENS.INBOX, undefined, {pop: true}), {routeNames, routeParamList: {}, routeGetIdList: {}}); + + // Then the action is handled without changes, because an unhandled (null) result makes React Navigation log an error and ignore the navigation + expect(result).toBe(state); + }); +}); diff --git a/tests/unit/nativeTabScreenListenersTest.ts b/tests/unit/nativeTabScreenListenersTest.ts new file mode 100644 index 000000000000..c96f17635948 --- /dev/null +++ b/tests/unit/nativeTabScreenListenersTest.ts @@ -0,0 +1,45 @@ +import navigateToInboxTab from '@components/Navigation/NavigationTabBar/navigateToInboxTab'; + +import tabScreenListeners, {isNativeTabSelectionEnabled} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabScreenListeners'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +jest.mock('@components/Navigation/NavigationTabBar/navigateToInboxTab', () => jest.fn()); +jest.mock('@libs/telemetry/startTabNavigationSpans'); + +describe('tabScreenListeners', () => { + it('opens the Inbox chat list only when Inbox is pressed from another tab with a report open', () => { + // Given Inbox left with a report open, which the native bar does not switch to, so JS opens it at the chat list like InboxTabButton + const inboxWithReport = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT}], index: 1}}; + const inboxAtChatList = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, state: {routes: [{name: SCREENS.INBOX}], index: 0}}; + const pressInbox = (route: Parameters[0]['route'], isFocused: boolean) => tabScreenListeners({route, navigation: {isFocused: () => isFocused}}).tabPress(); + + // When the user presses Inbox while it is focused, from another tab at the chat list, and from another tab with a report open + pressInbox(inboxWithReport, true); + pressInbox(inboxAtChatList, false); + pressInbox(inboxWithReport, false); + + // Then only the last press runs the shared Inbox navigation, since the native switch already lands on the chat list + expect(navigateToInboxTab).toHaveBeenCalledTimes(1); + }); + + it('leaves to JS only the switches that would land somewhere else than the JS tab buttons', () => { + // Given a signed-in user whose Inbox and Workspaces tabs show what their tab buttons open, one whose do not, and an anonymous user + const ready = {isAnonymousUser: false, isInboxAtChatList: true, isWorkspacesTabRestored: true}; + const unrestored = {isAnonymousUser: false, isInboxAtChatList: false, isWorkspacesTabRestored: false}; + const anonymous = {...ready, isAnonymousUser: true}; + + // When the native selection is decided + // Then Inbox and Workspaces are switched natively only when ready, Spend always is, and anonymous users are kept from Spend, Insights, Workspaces and Account + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, ready)).toBe(true); + expect(isNativeTabSelectionEnabled(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, unrestored)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, unrestored)).toBe(true); + expect(isNativeTabSelectionEnabled(SCREENS.INSIGHTS, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.WORKSPACE_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, anonymous)).toBe(false); + expect(isNativeTabSelectionEnabled(SCREENS.HOME, anonymous)).toBe(true); + }); +}); diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 78284e673804..92959b872903 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -58,12 +58,7 @@ jest.mock('@components/ScrollView', () => { } return MockScrollView; }); -jest.mock('@components/Navigation/NavigationTabBar', () => { - function MockNavigationTabBar() { - return null; - } - return MockNavigationTabBar; -}); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps', () => () => ({})); jest.mock('@components/Navigation/QuickCreationActionsBar', () => { function MockQuickCreationActionsBar() { return null; diff --git a/tests/unit/useHasTabBeenShownTest.ts b/tests/unit/useHasTabBeenShownTest.ts new file mode 100644 index 000000000000..ab5d0105203b --- /dev/null +++ b/tests/unit/useHasTabBeenShownTest.ts @@ -0,0 +1,37 @@ +import {renderHook} from '@testing-library/react-native'; + +import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; +import useRootNavigationState from '@hooks/useRootNavigationState'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +jest.mock('@hooks/useRootNavigationState', () => jest.fn()); + +function buildRootState(selectedTabIndex: number): NavigationState { + const routeNames = [SCREENS.HOME, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR]; + const tabState: NavigationState = {stale: false, type: 'tab', key: 'tab', index: selectedTabIndex, routeNames, routes: routeNames.map((name) => ({key: name, name}))}; + return {stale: false, type: 'stack', key: 'root', index: 0, routeNames: [NAVIGATORS.TAB_NAVIGATOR], routes: [{key: 'tabs', name: NAVIGATORS.TAB_NAVIGATOR, state: tabState}]}; +} + +describe('useHasTabBeenShown', () => { + it('stays false for a background tab and true once the tab has been selected', () => { + // Given native tabs that mounted Spend at startup while Home is selected + let rootState = buildRootState(0); + jest.mocked(useRootNavigationState).mockImplementation((selector) => selector(rootState)); + const {result, rerender} = renderHook(() => useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)); + const wasShownInBackground = result.current; + + // When the user selects Spend and then goes back to Home + rootState = buildRootState(1); + rerender({}); + rootState = buildRootState(0); + rerender({}); + + // Then Spend held back its mount work in the background and keeps it running after the user leaves it + expect(wasShownInBackground).toBe(false); + expect(result.current).toBe(true); + }); +});