diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index a3a041e65684..c68a950d3501 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -4,6 +4,7 @@ * */ export default { CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator', + LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator', RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator', FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator', } as const; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c1d2059cd3b0..c5107f1c9c29 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -81,10 +81,12 @@ const SCREENS = { SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', }, + LEFT_MODAL: { + SEARCH: 'Search', + }, RIGHT_MODAL: { SETTINGS: 'Settings', NEW_CHAT: 'NewChat', - SEARCH: 'Search', DETAILS: 'Details', PROFILE: 'Profile', REPORT_DETAILS: 'Report_Details', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index b6304cb3b1b7..fdef49d71eae 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -35,6 +35,7 @@ import createCustomStackNavigator from './createCustomStackNavigator'; import defaultScreenOptions from './defaultScreenOptions'; import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions'; import CentralPaneNavigator from './Navigators/CentralPaneNavigator'; +import LeftModalNavigator from './Navigators/LeftModalNavigator'; import RightModalNavigator from './Navigators/RightModalNavigator'; type AuthScreensProps = { @@ -295,6 +296,12 @@ function AuthScreens({lastUpdateIDAppliedToClient, session, lastOpenedPublicRoom component={RightModalNavigator} listeners={modalScreenListeners} /> + ({ +const ModalNavigatorScreenOptions = (themeStyles: ThemeStyles): StackNavigationOptions => ({ headerShown: false, animationEnabled: true, gestureDirection: 'horizontal', @@ -14,4 +14,4 @@ const RHPScreenOptions = (themeStyles: ThemeStyles): StackNavigationOptions => ( cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, }); -export default RHPScreenOptions; +export default ModalNavigatorScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx new file mode 100644 index 000000000000..b7385c930e2c --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx @@ -0,0 +1,45 @@ +import {createStackNavigator, StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import NoDropZone from '@components/DragAndDrop/NoDropZone'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; +import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import {AuthScreensParamList, LeftModalNavigatorParamList} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import Overlay from './Overlay'; + +type LeftModalNavigatorProps = StackScreenProps; + +const Stack = createStackNavigator(); + +function LeftModalNavigator({navigation}: LeftModalNavigatorProps) { + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useWindowDimensions(); + const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]); + + return ( + + {!isSmallScreenWidth && ( + + )} + + + + + + + ); +} + +LeftModalNavigator.displayName = 'LeftModalNavigator'; + +export default LeftModalNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx index 065de8da578b..a3fe1c657f34 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx @@ -9,15 +9,18 @@ import CONST from '@src/CONST'; type OverlayProps = { /* Callback to close the modal */ onPress: () => void; + + /* Returns whether a modal is displayed on the left side of the screen. By default, the modal is displayed on the right */ + isModalOnTheLeft?: boolean; }; -function Overlay({onPress}: OverlayProps) { +function Overlay({onPress, isModalOnTheLeft = false}: OverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); return ( - + {/* In the latest Electron version buttons can't be both clickable and draggable. That's why we added this workaround. Because of two Pressable components on the desktop app diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index bd790589c8d1..d7c31bcae7d9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -4,8 +4,8 @@ import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; -import RHPScreenOptions from '@libs/Navigation/AppNavigator/RHPScreenOptions'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -18,7 +18,7 @@ const Stack = createStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); const {isSmallScreenWidth} = useWindowDimensions(); - const screenOptions = useMemo(() => RHPScreenOptions(styles), [styles]); + const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]); return ( @@ -33,10 +33,6 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.NEW_CHAT} component={ModalStackNavigators.NewChatModalStackNavigator} /> - ({ rightModalNavigator: { ...commonScreenOptions, @@ -32,7 +34,23 @@ export default (isSmallScreenWidth: boolean, themeStyles: ThemeStyles): ScreenOp right: 0, }, }, + leftModalNavigator: { + ...commonScreenOptions, + cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), + presentation: 'transparentModal', + + // We want pop in LHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + // LHP should be displayed in place of the sidebar + left: isSmallScreenWidth ? 0 : -variables.sideBarWidth, + }, + }, homeScreen: { title: CONFIG.SITE_TITLE, ...commonScreenOptions, diff --git a/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts b/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts index eff88422cc5c..fd59b02e724d 100644 --- a/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts +++ b/src/libs/Navigation/AppNavigator/modalCardStyleInterpolator.ts @@ -3,11 +3,16 @@ import {Animated} from 'react-native'; import getCardStyles from '@styles/utils/cardStyles'; import variables from '@styles/variables'; -export default (isSmallScreenWidth: boolean, isFullScreenModal: boolean, {current: {progress}, inverted, layouts: {screen}}: StackCardInterpolationProps): StackCardInterpolatedStyle => { +export default ( + isSmallScreenWidth: boolean, + isFullScreenModal: boolean, + {current: {progress}, inverted, layouts: {screen}}: StackCardInterpolationProps, + outputRangeMultiplier = 1, +): StackCardInterpolatedStyle => { const translateX = Animated.multiply( progress.interpolate({ inputRange: [0, 1], - outputRange: [isSmallScreenWidth ? screen.width : variables.sideBarWidth, 0], + outputRange: [outputRangeMultiplier * (isSmallScreenWidth ? screen.width : variables.sideBarWidth), 0], extrapolate: 'clamp', }), inverted, diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 3552ff9e7410..a3e89a983f98 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,18 +1,17 @@ -import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; +import {findFocusedRoute} from '@react-navigation/core'; import {CommonActions, EventArg, getPathFromState, NavigationContainerEventMap, NavigationState, PartialState, StackActions} from '@react-navigation/native'; -import findLastIndex from 'lodash/findLastIndex'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES, {Route} from '@src/ROUTES'; -import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; -import getStateFromPath from './getStateFromPath'; +import {PROTECTED_SCREENS} from '@src/SCREENS'; +import originalDismissModal from './dismissModal'; import originalGetTopmostReportActionId from './getTopmostReportActionID'; import originalGetTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; -import {StackNavigationAction, StateOrRoute} from './types'; +import {StateOrRoute} from './types'; let resolveNavigationIsReadyPromise: () => void; const navigationIsReadyPromise = new Promise((resolve) => { @@ -44,6 +43,9 @@ const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopm // Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies. const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state); +// Re-exporting the dismissModal here to fill in default value for navigationRef. The dismissModal isn't defined in this file to avoid cyclic dependencies. +const dismissModal = (targetReportId = '', ref = navigationRef) => originalDismissModal(targetReportId, ref); + /** Method for finding on which index in stack we are. */ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number | undefined { if ('routes' in stateOrRoute && stateOrRoute.routes) { @@ -56,7 +58,7 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0); } - if ('name' in stateOrRoute && stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + if ('name' in stateOrRoute && (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR)) { return 0; } @@ -160,8 +162,8 @@ function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopTo if (isFirstRouteInNavigator) { const rootState = navigationRef.getRootState(); const lastRoute = rootState.routes.at(-1); - // If the user comes from a different flow (there is more than one route in RHP) we should go back to the previous flow on UP button press instead of using the fallbackRoute. - if (lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && (lastRoute.state?.index ?? 0) > 0) { + // If the user comes from a different flow (there is more than one route in ModalNavigator) we should go back to the previous flow on UP button press instead of using the fallbackRoute. + if ((lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || lastRoute?.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR) && (lastRoute.state?.index ?? 0) > 0) { navigationRef.current.goBack(); return; } @@ -200,45 +202,6 @@ function setParams(params: Record, routeKey: string) { }); } -/** - * Dismisses the last modal stack if there is any - * - * @param targetReportID - The reportID to navigate to after dismissing the modal - */ -function dismissModal(targetReportID?: string) { - if (!canNavigate('dismissModal')) { - return; - } - const rootState = navigationRef.getRootState(); - const lastRoute = rootState.routes.at(-1); - switch (lastRoute?.name) { - case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: - case SCREENS.NOT_FOUND: - case SCREENS.REPORT_ATTACHMENTS: - // if we are not in the target report, we need to navigate to it after dismissing the modal - if (targetReportID && targetReportID !== getTopmostReportId(rootState)) { - const state = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReportID)); - - const action: StackNavigationAction = getActionFromState(state, linkingConfig.config); - if (action) { - action.type = 'REPLACE'; - navigationRef.current?.dispatch(action); - } - // If not-found page is in the route stack, we need to close it - } else if (targetReportID && rootState.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { - const lastRouteIndex = rootState.routes.length - 1; - const centralRouteIndex = findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); - navigationRef.current?.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key}); - } else { - navigationRef.current?.dispatch({...StackActions.pop(), target: rootState.key}); - } - break; - default: { - Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); - } - } -} - /** * Returns the current active route without the URL params */ diff --git a/src/libs/Navigation/dismissModal.ts b/src/libs/Navigation/dismissModal.ts new file mode 100644 index 000000000000..37b4c6d9b9e6 --- /dev/null +++ b/src/libs/Navigation/dismissModal.ts @@ -0,0 +1,56 @@ +import {getActionFromState} from '@react-navigation/core'; +import {NavigationContainerRef, StackActions} from '@react-navigation/native'; +import {findLastIndex} from 'lodash'; +import Log from '@libs/Log'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import getStateFromPath from './getStateFromPath'; +import getTopmostReportId from './getTopmostReportId'; +import linkingConfig from './linkingConfig'; +import {RootStackParamList, StackNavigationAction} from './types'; + +// This function is in a separate file than Navigation.js to avoid cyclic dependency. + +/** + * Dismisses the last modal stack if there is any + * + * @param targetReportID - The reportID to navigate to after dismissing the modal + */ +function dismissModal(targetReportID: string, navigationRef: NavigationContainerRef) { + if (!navigationRef.isReady()) { + return; + } + + const state = navigationRef.getState(); + const lastRoute = state.routes.at(-1); + switch (lastRoute?.name) { + case NAVIGATORS.LEFT_MODAL_NAVIGATOR: + case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: + case SCREENS.NOT_FOUND: + case SCREENS.REPORT_ATTACHMENTS: + // if we are not in the target report, we need to navigate to it after dismissing the modal + if (targetReportID && targetReportID !== getTopmostReportId(state)) { + const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReportID)); + + const action: StackNavigationAction = getActionFromState(reportState, linkingConfig.config); + if (action) { + action.type = 'REPLACE'; + navigationRef.dispatch(action); + } + // If not-found page is in the route stack, we need to close it + } else if (targetReportID && state.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { + const lastRouteIndex = state.routes.length - 1; + const centralRouteIndex = findLastIndex(state.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); + navigationRef.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: state.key}); + } else { + navigationRef.dispatch({...StackActions.pop(), target: state.key}); + } + break; + default: { + Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); + } + } +} + +export default dismissModal; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 9694879f9aae..86558765a6e6 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -4,6 +4,7 @@ import {Writable} from 'type-fest'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import {Route} from '@src/ROUTES'; +import dismissModal from './dismissModal'; import getStateFromPath from './getStateFromPath'; import getTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; @@ -55,6 +56,10 @@ function getMinimalAction(action: NavigationAction, state: NavigationState): Wri return currentAction; } +function isModalNavigator(targetNavigator?: string) { + return targetNavigator === NAVIGATORS.LEFT_MODAL_NAVIGATOR || targetNavigator === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; +} + export default function linkTo(navigation: NavigationContainerRef | null, path: Route, type?: string, isActiveRoute?: boolean) { if (!navigation) { throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); @@ -75,6 +80,9 @@ export default function linkTo(navigation: NavigationContainerRef = { }, }, [SCREENS.NOT_FOUND]: '*', - + [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: { + screens: { + [SCREENS.LEFT_MODAL.SEARCH]: { + screens: { + [SCREENS.SEARCH_ROOT]: ROUTES.SEARCH, + }, + }, + }, + }, [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: { screens: { [SCREENS.RIGHT_MODAL.SETTINGS]: { @@ -338,11 +346,6 @@ const linkingConfig: LinkingOptions = { [SCREENS.I_AM_A_TEACHER]: ROUTES.I_AM_A_TEACHER, }, }, - [SCREENS.RIGHT_MODAL.SEARCH]: { - screens: { - [SCREENS.SEARCH_ROOT]: ROUTES.SEARCH, - }, - }, [SCREENS.RIGHT_MODAL.DETAILS]: { screens: { [SCREENS.DETAILS_ROOT]: ROUTES.DETAILS.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1217e2cfa6b1..e55fcc388870 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -334,10 +334,13 @@ type PrivateNotesNavigatorParamList = { }; }; +type LeftModalNavigatorParamList = { + [SCREENS.LEFT_MODAL.SEARCH]: NavigatorScreenParams; +}; + type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams; - [SCREENS.RIGHT_MODAL.SEARCH]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams; @@ -401,6 +404,7 @@ type AuthScreensParamList = { source: string; }; [SCREENS.NOT_FOUND]: undefined; + [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; }; @@ -416,6 +420,7 @@ export type { NavigationStateRoute, NavigationRoot, AuthScreensParamList, + LeftModalNavigatorParamList, RightModalNavigatorParamList, PublicScreensParamList, MoneyRequestNavigatorParamList, diff --git a/src/styles/index.ts b/src/styles/index.ts index da3c2bc2608c..fb1919b9f5d3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1422,6 +1422,12 @@ const styles = (theme: ThemeColors) => height: variables.lineHeightSizeh1, }, + LHPNavigatorContainer: (isSmallScreenWidth: boolean) => + ({ + ...modalNavigatorContainer(isSmallScreenWidth), + left: 0, + } satisfies ViewStyle), + RHPNavigatorContainer: (isSmallScreenWidth: boolean) => ({ ...modalNavigatorContainer(isSmallScreenWidth), @@ -1641,14 +1647,14 @@ const styles = (theme: ThemeColors) => marginBottom: 4, }, - overlayStyles: (current: OverlayStylesParams) => + overlayStyles: (current: OverlayStylesParams, isModalOnTheLeft: boolean) => ({ ...positioning.pFixed, // We need to stretch the overlay to cover the sidebar and the translate animation distance. - left: -2 * variables.sideBarWidth, + left: isModalOnTheLeft ? 0 : -2 * variables.sideBarWidth, top: 0, bottom: 0, - right: 0, + right: isModalOnTheLeft ? -2 * variables.sideBarWidth : 0, backgroundColor: theme.overlay, opacity: current.progress.interpolate({ inputRange: [0, 1],