diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 51612078a88c..23a08b4306e3 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -99,7 +99,7 @@ function ConfirmModal({ image, shouldEnableNewFocusManagement, }: ConfirmModalProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); return ( @@ -109,7 +109,7 @@ function ConfirmModal({ isVisible={isVisible} shouldSetModalVisibility={shouldSetModalVisibility} onModalHide={onModalHide} - type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM} + type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM} innerContainerStyle={image ? styles.pt0 : {}} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} > diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 198e641840a7..e24c5b7c9c80 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -4,7 +4,6 @@ import ReactNativeModal from 'react-native-modal'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import useKeyboardState from '@hooks/useKeyboardState'; import usePrevious from '@hooks/usePrevious'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -17,6 +16,7 @@ import variables from '@styles/variables'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; import ModalContent from './ModalContent'; +import ModalContext from './ModalContext'; import type BaseModalProps from './types'; function BaseModal( @@ -55,8 +55,7 @@ function BaseModal( const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {windowWidth, windowHeight} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isSmallScreenWidth, windowWidth, windowHeight} = useWindowDimensions(); const keyboardStateContextValue = useKeyboardState(); const safeAreaInsets = useSafeAreaInsets(); @@ -163,13 +162,13 @@ function BaseModal( { windowWidth, windowHeight, - isSmallScreenWidth: shouldUseNarrowLayout, + isSmallScreenWidth, }, popoverAnchorPosition, innerContainerStyle, outerStyle, ), - [StyleUtils, type, windowWidth, windowHeight, shouldUseNarrowLayout, popoverAnchorPosition, innerContainerStyle, outerStyle], + [StyleUtils, type, windowWidth, windowHeight, isSmallScreenWidth, popoverAnchorPosition, innerContainerStyle, outerStyle], ); const { @@ -200,60 +199,69 @@ function BaseModal( paddingRight: safeAreaPaddingRight ?? 0, }; + const modalContextValue = useMemo( + () => ({ + activeModalType: isVisible ? type : undefined, + }), + [isVisible, type], + ); + return ( - // this is a workaround for modal not being visible on the new arch in some cases - // it's necessary to have a non-collapseable view as a parent of the modal to prevent - // a conflict between RN core and Reanimated shadow tree operations - // position absolute is needed to prevent the view from interfering with flex layout - - e.stopPropagation()} - onBackdropPress={handleBackdropPress} - // Note: Escape key on web/desktop will trigger onBackButtonPress callback - // eslint-disable-next-line react/jsx-props-no-multi-spaces - onBackButtonPress={Modal.closeTop} - onModalShow={handleShowModal} - propagateSwipe={propagateSwipe} - onModalHide={hideModal} - onModalWillShow={saveFocusState} - onDismiss={handleDismissModal} - onSwipeComplete={() => onClose?.()} - swipeDirection={swipeDirection} - isVisible={isVisible} - backdropColor={theme.overlay} - backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity} - backdropTransitionOutTiming={0} - hasBackdrop={fullscreen} - coverScreen={fullscreen} - style={modalStyle} - deviceHeight={windowHeight} - deviceWidth={windowWidth} - animationIn={animationIn ?? modalStyleAnimationIn} - animationOut={animationOut ?? modalStyleAnimationOut} - useNativeDriver={useNativeDriverProp && useNativeDriver} - useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver} - hideModalContentWhileAnimating={hideModalContentWhileAnimating} - animationInTiming={animationInTiming} - animationOutTiming={animationOutTiming} - statusBarTranslucent={statusBarTranslucent} - onLayout={onLayout} - avoidKeyboard={avoidKeyboard} - customBackdrop={shouldUseCustomBackdrop ? : undefined} + + - - - {children} - - - - + e.stopPropagation()} + onBackdropPress={handleBackdropPress} + // Note: Escape key on web/desktop will trigger onBackButtonPress callback + // eslint-disable-next-line react/jsx-props-no-multi-spaces + onBackButtonPress={Modal.closeTop} + onModalShow={handleShowModal} + propagateSwipe={propagateSwipe} + onModalHide={hideModal} + onModalWillShow={saveFocusState} + onDismiss={handleDismissModal} + onSwipeComplete={() => onClose?.()} + swipeDirection={swipeDirection} + isVisible={isVisible} + backdropColor={theme.overlay} + backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity} + backdropTransitionOutTiming={0} + hasBackdrop={fullscreen} + coverScreen={fullscreen} + style={modalStyle} + deviceHeight={windowHeight} + deviceWidth={windowWidth} + animationIn={animationIn ?? modalStyleAnimationIn} + animationOut={animationOut ?? modalStyleAnimationOut} + useNativeDriver={useNativeDriverProp && useNativeDriver} + useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver} + hideModalContentWhileAnimating={hideModalContentWhileAnimating} + animationInTiming={animationInTiming} + animationOutTiming={animationOutTiming} + statusBarTranslucent={statusBarTranslucent} + onLayout={onLayout} + avoidKeyboard={avoidKeyboard} + customBackdrop={shouldUseCustomBackdrop ? : undefined} + > + + + {children} + + + + + ); } diff --git a/src/components/Modal/ModalContext.ts b/src/components/Modal/ModalContext.ts new file mode 100644 index 000000000000..a5c2eb21ab1d --- /dev/null +++ b/src/components/Modal/ModalContext.ts @@ -0,0 +1,15 @@ +import {createContext} from 'react'; +import type ModalType from '@src/types/utils/ModalType'; + +type ModalContextType = { + // The type of the currently displayed modal, or undefined if there is no currently displayed modal. + // Note that React Native can only display one modal at a time. + activeModalType?: ModalType; +}; + +// This context is meant to inform modal children that they are rendering in a modal (and what type of modal they are rendering in) +// Note that this is different than ONYXKEYS.MODAL.isVisible data point in that that is a global variable for whether a modal is visible or not, +// whereas this context is provided by the BaseModal component, and thus is only available to components rendered inside a modal. +const ModalContext = createContext({}); + +export default ModalContext; diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 2af977caa969..3ef267292e90 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -28,7 +28,7 @@ function Popover(props: PopoverProps) { animationOut = 'fadeOut', } = props; - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isSmallScreenWidth} = useResponsiveLayout(); const withoutOverlayRef = useRef(null); const {close, popover} = React.useContext(PopoverContext); @@ -55,7 +55,7 @@ function Popover(props: PopoverProps) { onClose(); }; - if (!fullscreen && !shouldUseNarrowLayout) { + if (!fullscreen && !isSmallScreenWidth) { return createPortal( (null); const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); @@ -198,7 +198,7 @@ function PopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} > - + {!!headerText && {headerText}} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts index bf0b77ef915c..18b29653479c 100644 --- a/src/hooks/useResponsiveLayout.ts +++ b/src/hooks/useResponsiveLayout.ts @@ -1,15 +1,14 @@ -import {useEffect, useRef, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx} from 'react-native-onyx'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Modal} from '@src/types/onyx'; +import {useContext} from 'react'; +import ModalContext from '@components/Modal/ModalContext'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import useRootNavigationState from './useRootNavigationState'; import useWindowDimensions from './useWindowDimensions'; type ResponsiveLayoutResult = { shouldUseNarrowLayout: boolean; isSmallScreenWidth: boolean; - isInModal: boolean; + isInNarrowPaneModal: boolean; isExtraSmallScreenHeight: boolean; isMediumScreenWidth: boolean; isLargeScreenWidth: boolean; @@ -19,39 +18,47 @@ type ResponsiveLayoutResult = { /** * Hook to determine if we are on mobile devices or in the Modal Navigator. - * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInModal" for "in RHP/LHP". + * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP/LHP". + * + * There are two kinds of modals in this app: + * 1. Modal stack navigators from react-navigation + * 2. Modal components that use react-native-modal + * + * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true: + * 1. The device screen width is narrow + * 2. The consuming component is the child of a "right docked" react-native-modal component + * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-modal component. + * + * For more details on the various modal types we've defined for this app and implemented using react-native-modal, see `ModalType`. */ export default function useResponsiveLayout(): ResponsiveLayoutResult { const {isSmallScreenWidth, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen} = useWindowDimensions(); - const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: (value: OnyxEntry) => value?.willAlertModalBecomeVisible ?? false}); + // Note: activeModalType refers to our react-native-modal component wrapper, not react-navigation's modal stack navigators. + // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider. + const {activeModalType} = useContext(ModalContext); - const [isInModal, setIsInModal] = useState(false); - const hasSetIsInModal = useRef(false); - const updateModalStatus = () => { - if (hasSetIsInModal.current) { - return; - } - const isDisplayedInModal = Navigation.isDisplayedInModal(); - if (isInModal !== isDisplayedInModal) { - setIsInModal(isDisplayedInModal); - } - hasSetIsInModal.current = true; - }; - - useEffect(() => { - const unsubscribe = navigationRef?.current?.addListener('state', updateModalStatus); + // This refers to the state of the root navigator, and is true if and only if the topmost navigator is the "left modal navigator" or the "right modal navigator" + const isDisplayedInModalNavigator = !!useRootNavigationState(Navigation.isModalNavigatorActive); - if (navigationRef?.current?.isReady()) { - updateModalStatus(); - } + // The component calling this hook is in a "narrow pane modal" if: + const isInNarrowPaneModal = + // it's a child of the right-docked modal + activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED || + // or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack + // and the component calling this hook is not the child of another modal type, such as a confirm modal + (isDisplayedInModalNavigator && !activeModalType); - return () => { - unsubscribe?.(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal; - const shouldUseNarrowLayout = willAlertModalBecomeVisible ? isSmallScreenWidth : isSmallScreenWidth || isInModal; - return {shouldUseNarrowLayout, isSmallScreenWidth, isInModal, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen}; + return { + shouldUseNarrowLayout, + isSmallScreenWidth, + isInNarrowPaneModal, + isExtraSmallScreenHeight, + isExtraSmallScreenWidth, + isMediumScreenWidth, + isLargeScreenWidth, + isSmallScreen, + }; } diff --git a/src/hooks/useRootNavigationState.ts b/src/hooks/useRootNavigationState.ts new file mode 100644 index 000000000000..3f03e1ceb253 --- /dev/null +++ b/src/hooks/useRootNavigationState.ts @@ -0,0 +1,30 @@ +import type {EventListenerCallback, NavigationContainerEventMap} from '@react-navigation/native'; +import type {NavigationState} from '@react-navigation/routers'; +import {useCallback, useSyncExternalStore} from 'react'; +import {navigationRef} from '@libs/Navigation/Navigation'; + +/** + * This hook is a replacement for `useNavigationState` for nested navigators. + * If `useNavigationState` is used within a nested navigator then the state that's returned is the state of the nearest parent navigator, + * not the root navigator state representing the whole app's navigation tree. + * + * Use with caution, because re-rendering any component every time the root navigation state changes can be very costly for performance. + * That's why the selector is mandatory. + */ +function useRootNavigationState(selector: (state: NavigationState) => T): T | undefined { + const getSnapshot = useCallback(() => { + if (!navigationRef?.current) { + return; + } + return selector(navigationRef.current.getRootState()); + }, [selector]); + + const subscribeToRootState = useCallback((callback: EventListenerCallback) => { + const unsubscribe = navigationRef?.current?.addListener('state', callback); + return () => unsubscribe?.(); + }, []); + + return useSyncExternalStore(subscribeToRootState, getSnapshot); +} + +export default useRootNavigationState; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 3fa3e0c5c317..80ce5e65b702 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,5 +1,5 @@ import {findFocusedRoute} from '@react-navigation/core'; -import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native'; +import type {EventArg, NavigationContainerEventMap, NavigationState} from '@react-navigation/native'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; import Log from '@libs/Log'; import * as ReportUtils from '@libs/ReportUtils'; @@ -356,9 +356,12 @@ function navigateWithSwitchPolicyID(params: SwitchPolicyIDParams) { return switchPolicyID(navigationRef.current, params); } -/** Check if the modal is being displayed */ -function isDisplayedInModal() { - const state = navigationRef?.current?.getRootState(); +/** + * Check if the modal is being displayed. + * + * @param state - MUST be the state of the root navigator for this to work. Do not use a child navigator state. + */ +function isModalNavigatorActive(state: NavigationState) { const lastRoute = state?.routes?.at(-1); const lastRouteName = lastRoute?.name; return lastRouteName === NAVIGATORS.LEFT_MODAL_NAVIGATOR || lastRouteName === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; @@ -383,7 +386,7 @@ export default { parseHybridAppUrl, navigateWithSwitchPolicyID, resetToHome, - isDisplayedInModal, + isModalNavigatorActive, closeRHPFlow, setNavigationActionToMicrotaskQueue, }; diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index 4286a2603341..973eaa2d8907 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -64,7 +64,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false const firstBlurred = useRef(false); const isFocused = useIsFocused(); const isLoading = useRef(false); - const {shouldUseNarrowLayout, isInModal} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); /** * Validate the input value and set the error for formError @@ -167,7 +167,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false return; } let focusTimeout: NodeJS.Timeout; - if (isInModal) { + if (isInNarrowPaneModal) { focusTimeout = setTimeout(() => input.current?.focus(), CONST.ANIMATED_TRANSITION); } else { input.current.focus(); diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx index 16aeb7053726..112d350087b1 100644 --- a/src/pages/signin/SignInPage.tsx +++ b/src/pages/signin/SignInPage.tsx @@ -130,7 +130,7 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate, formatPhoneNumber} = useLocalize(); - const {shouldUseNarrowLayout, isInModal} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); const safeAreaInsets = useSafeAreaInsets(); const signInPageLayoutRef = useRef(null); const loginFormRef = useRef(null); @@ -250,7 +250,7 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc