diff --git a/src/CONST.ts b/src/CONST.ts index 00f2245a55c01..f6ff060543215 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3724,6 +3724,7 @@ const CONST = { }, EVENTS: { SCROLLING: 'scrolling', + ON_RETURN_TO_OLD_DOT: 'onReturnToOldDot', }, CHAT_HEADER_LOADER_HEIGHT: 36, diff --git a/src/components/HybridAppMiddleware.tsx b/src/components/HybridAppMiddleware.tsx deleted file mode 100644 index 5c6934f4fc3d5..0000000000000 --- a/src/components/HybridAppMiddleware.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {useNavigation} from '@react-navigation/native'; -import type {StackNavigationProp} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {NativeModules} from 'react-native'; -import useSplashScreen from '@hooks/useSplashScreen'; -import BootSplash from '@libs/BootSplash'; -import Log from '@libs/Log'; -import Navigation from '@libs/Navigation/Navigation'; -import type {RootStackParamList} from '@libs/Navigation/types'; -import * as Welcome from '@userActions/Welcome'; -import CONST from '@src/CONST'; -import type {Route} from '@src/ROUTES'; - -type HybridAppMiddlewareProps = { - children: React.ReactNode; -}; - -type HybridAppMiddlewareContextType = { - navigateToExitUrl: (exitUrl: Route) => void; - showSplashScreenOnNextStart: () => void; -}; -const HybridAppMiddlewareContext = React.createContext({ - navigateToExitUrl: () => {}, - showSplashScreenOnNextStart: () => {}, -}); - -/* - * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. - * It is crucial to make transitions between OldDot and NewDot look smooth. - */ -function HybridAppMiddleware(props: HybridAppMiddlewareProps) { - const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); - const [startedTransition, setStartedTransition] = useState(false); - const [finishedTransition, setFinishedTransition] = useState(false); - const navigation = useNavigation>(); - - /* - * Handles navigation during transition from OldDot. For ordinary NewDot app it is just pure navigation. - */ - const navigateToExitUrl = useCallback((exitUrl: Route) => { - if (NativeModules.HybridAppModule) { - setStartedTransition(true); - Log.info(`[HybridApp] Started transition to ${exitUrl}`, true); - } - - Navigation.navigate(exitUrl); - }, []); - - /** - * This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot, - * we need to artificially show the bootsplash because the app is only booted once. - */ - const showSplashScreenOnNextStart = useCallback(() => { - setIsSplashHidden(false); - setStartedTransition(false); - setFinishedTransition(false); - }, [setIsSplashHidden]); - - useEffect(() => { - if (!finishedTransition || isSplashHidden) { - return; - } - - Log.info('[HybridApp] Finished transtion', true); - BootSplash.hide().then(() => { - setIsSplashHidden(true); - Log.info('[HybridApp] Handling onboarding flow', true); - Welcome.handleHybridAppOnboarding(); - }); - }, [finishedTransition, isSplashHidden, setIsSplashHidden]); - - useEffect(() => { - if (!startedTransition) { - return; - } - - // On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout. - const timeout = setTimeout(() => { - setFinishedTransition(true); - }, CONST.SCREEN_TRANSITION_END_TIMEOUT); - - const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { - clearTimeout(timeout); - setFinishedTransition(true); - }); - - return () => { - clearTimeout(timeout); - unsubscribeTransitionEnd(); - }; - }, [navigation, startedTransition]); - - const contextValue = useMemo( - () => ({ - navigateToExitUrl, - showSplashScreenOnNextStart, - }), - [navigateToExitUrl, showSplashScreenOnNextStart], - ); - - return {props.children}; -} - -HybridAppMiddleware.displayName = 'HybridAppMiddleware'; - -export default HybridAppMiddleware; -export type {HybridAppMiddlewareContextType}; -export {HybridAppMiddlewareContext}; diff --git a/src/components/HybridAppMiddleware/index.ios.tsx b/src/components/HybridAppMiddleware/index.ios.tsx new file mode 100644 index 0000000000000..5b06e5626c6ef --- /dev/null +++ b/src/components/HybridAppMiddleware/index.ios.tsx @@ -0,0 +1,130 @@ +import type React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import {NativeEventEmitter, NativeModules} from 'react-native'; +import type {NativeModule} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; +import useExitTo from '@hooks/useExitTo'; +import useSplashScreen from '@hooks/useSplashScreen'; +import BootSplash from '@libs/BootSplash'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as SessionUtils from '@libs/SessionUtils'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {HybridAppRoute, Route} from '@src/ROUTES'; + +type HybridAppMiddlewareProps = { + authenticated: boolean; + children: React.ReactNode; +}; + +/* + * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. + * It is crucial to make transitions between OldDot and NewDot look smooth. + * The middleware assumes that the entry point for HybridApp is the /transition route. + */ +function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) { + const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); + const [startedTransition, setStartedTransition] = useState(false); + const [finishedTransition, setFinishedTransition] = useState(false); + + const initialURL = useContext(InitialURLContext); + const exitToParam = useExitTo(); + const [exitTo, setExitTo] = useState(); + + const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + + // In iOS, the HybridApp defines the `onReturnToOldDot` event. + // If we frequently transition from OldDot to NewDot during a single app lifecycle, + // we need to artificially display the bootsplash since the app is booted only once. + // Therefore, isSplashHidden needs to be updated at the appropriate time. + useEffect(() => { + if (!NativeModules.HybridAppModule) { + return; + } + const HybridAppEvents = new NativeEventEmitter(NativeModules.HybridAppModule as unknown as NativeModule); + const listener = HybridAppEvents.addListener(CONST.EVENTS.ON_RETURN_TO_OLD_DOT, () => { + Log.info('[HybridApp] `onReturnToOldDot` event received. Resetting state of HybridAppMiddleware', true); + setIsSplashHidden(false); + setStartedTransition(false); + setFinishedTransition(false); + setExitTo(undefined); + }); + + return () => { + listener.remove(); + }; + }, [setIsSplashHidden]); + + // Save `exitTo` when we reach /transition route. + // `exitTo` should always exist during OldDot -> NewDot transitions. + useEffect(() => { + if (!NativeModules.HybridAppModule || !exitToParam || exitTo) { + return; + } + + Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam}); + setExitTo(exitToParam); + + Log.info(`[HybridApp] Started transition`, true); + setStartedTransition(true); + }, [exitTo, exitToParam]); + + useEffect(() => { + if (!startedTransition || finishedTransition) { + return; + } + + const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + + // We need to wait with navigating to exitTo until all login-related actions are complete. + if (!authenticated || isLoggingInAsNewUser || isAccountLoading) { + return; + } + + if (exitTo) { + Navigation.isNavigationReady().then(() => { + // We need to remove /transition from route history. + // `useExitTo` returns undefined for routes other than /transition. + if (exitToParam) { + Log.info('[HybridApp] Removing /transition route from history', true); + Navigation.goBack(); + } + + Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo}); + Navigation.navigate(Navigation.parseHybridAppUrl(exitTo)); + setExitTo(undefined); + + setTimeout(() => { + Log.info('[HybridApp] Setting `finishedTransition` to true', true); + setFinishedTransition(true); + }, CONST.SCREEN_TRANSITION_END_TIMEOUT); + }); + } + }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]); + + useEffect(() => { + if (!finishedTransition || isSplashHidden) { + return; + } + + Log.info('[HybridApp] Finished transition, hiding BootSplash', true); + BootSplash.hide().then(() => { + setIsSplashHidden(true); + if (authenticated) { + Log.info('[HybridApp] Handling onboarding flow', true); + Welcome.handleHybridAppOnboarding(); + } + }); + }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]); + + return children; +} + +HybridAppMiddleware.displayName = 'HybridAppMiddleware'; + +export default HybridAppMiddleware; diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx new file mode 100644 index 0000000000000..b8c72d9200ac8 --- /dev/null +++ b/src/components/HybridAppMiddleware/index.tsx @@ -0,0 +1,107 @@ +import type React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import {NativeModules} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; +import useExitTo from '@hooks/useExitTo'; +import useSplashScreen from '@hooks/useSplashScreen'; +import BootSplash from '@libs/BootSplash'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as SessionUtils from '@libs/SessionUtils'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {HybridAppRoute, Route} from '@src/ROUTES'; + +type HybridAppMiddlewareProps = { + authenticated: boolean; + children: React.ReactNode; +}; + +/* + * HybridAppMiddleware is responsible for handling BootSplash visibility correctly. + * It is crucial to make transitions between OldDot and NewDot look smooth. + * The middleware assumes that the entry point for HybridApp is the /transition route. + */ +function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps) { + const {isSplashHidden, setIsSplashHidden} = useSplashScreen(); + const [startedTransition, setStartedTransition] = useState(false); + const [finishedTransition, setFinishedTransition] = useState(false); + + const initialURL = useContext(InitialURLContext); + const exitToParam = useExitTo(); + const [exitTo, setExitTo] = useState(); + + const [isAccountLoading] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.isLoading ?? false}); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + + // Save `exitTo` when we reach /transition route. + // `exitTo` should always exist during OldDot -> NewDot transitions. + useEffect(() => { + if (!NativeModules.HybridAppModule || !exitToParam || exitTo) { + return; + } + + Log.info('[HybridApp] Saving `exitTo` for later', true, {exitTo: exitToParam}); + setExitTo(exitToParam); + + Log.info(`[HybridApp] Started transition`, true); + setStartedTransition(true); + }, [exitTo, exitToParam]); + + useEffect(() => { + if (!startedTransition || finishedTransition) { + return; + } + + const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL ?? undefined, sessionEmail); + + // We need to wait with navigating to exitTo until all login-related actions are complete. + if (!authenticated || isLoggingInAsNewUser || isAccountLoading) { + return; + } + + if (exitTo) { + Navigation.isNavigationReady().then(() => { + // We need to remove /transition from route history. + // `useExitTo` returns undefined for routes other than /transition. + if (exitToParam) { + Log.info('[HybridApp] Removing /transition route from history', true); + Navigation.goBack(); + } + + Log.info('[HybridApp] Navigating to `exitTo` route', true, {exitTo}); + Navigation.navigate(Navigation.parseHybridAppUrl(exitTo)); + setExitTo(undefined); + + setTimeout(() => { + Log.info('[HybridApp] Setting `finishedTransition` to true', true); + setFinishedTransition(true); + }, CONST.SCREEN_TRANSITION_END_TIMEOUT); + }); + } + }, [authenticated, exitTo, exitToParam, finishedTransition, initialURL, isAccountLoading, sessionEmail, startedTransition]); + + useEffect(() => { + if (!finishedTransition || isSplashHidden) { + return; + } + + Log.info('[HybridApp] Finished transition, hiding BootSplash', true); + BootSplash.hide().then(() => { + setIsSplashHidden(true); + if (authenticated) { + Log.info('[HybridApp] Handling onboarding flow', true); + Welcome.handleHybridAppOnboarding(); + } + }); + }, [authenticated, finishedTransition, isSplashHidden, setIsSplashHidden]); + + return children; +} + +HybridAppMiddleware.displayName = 'HybridAppMiddleware'; + +export default HybridAppMiddleware; diff --git a/src/hooks/useExitTo.ts b/src/hooks/useExitTo.ts new file mode 100644 index 0000000000000..74226453d3f61 --- /dev/null +++ b/src/hooks/useExitTo.ts @@ -0,0 +1,17 @@ +import {findFocusedRoute, useNavigationState} from '@react-navigation/native'; +import type {PublicScreensParamList, RootStackParamList} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; + +export default function useExitTo() { + const activeRouteParams = useNavigationState((state) => { + const focusedRoute = findFocusedRoute(state); + + if (focusedRoute?.name !== SCREENS.TRANSITION_BETWEEN_APPS) { + return undefined; + } + + return focusedRoute?.params as PublicScreensParamList[typeof SCREENS.TRANSITION_BETWEEN_APPS]; + }); + + return activeRouteParams?.exitTo; +} diff --git a/src/hooks/useHybridAppMiddleware.ts b/src/hooks/useHybridAppMiddleware.ts deleted file mode 100644 index 18ebd97306301..0000000000000 --- a/src/hooks/useHybridAppMiddleware.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {useContext} from 'react'; -import {HybridAppMiddlewareContext} from '@components/HybridAppMiddleware'; - -type SplashScreenHiddenContextType = {isSplashHidden: boolean}; - -export default function useHybridAppMiddleware() { - const {navigateToExitUrl, showSplashScreenOnNextStart} = useContext(HybridAppMiddlewareContext); - return {navigateToExitUrl, showSplashScreenOnNextStart}; -} - -export type {SplashScreenHiddenContextType}; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index dd3a2890d0ec7..9a92557f83011 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -154,7 +154,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N }} > {/* HybridAppMiddleware needs to have access to navigation ref and SplashScreenHidden context */} - + diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index db78b94731ae4..828e044f05fb9 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -198,12 +198,12 @@ function hasAuthToken(): boolean { return !!session.authToken; } -function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean) { +function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) { Log.info('Redirecting to Sign In because signOut() was called'); hideContextMenu(false); if (!isAnonymousUser()) { // In the HybridApp, we want the Old Dot to handle the sign out process - if (NativeModules.HybridAppModule) { + if (NativeModules.HybridAppModule && killHybridApp) { NativeModules.HybridAppModule.closeReactNativeApp(); return; } diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 47843aa434fac..a9fd0ef393145 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -9,7 +9,6 @@ import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,7 +34,6 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {navigateToExitUrl} = useHybridAppMiddleware(); const {email = '', shortLivedAuthToken = '', shortLivedToken = '', authTokenType, exitTo, error} = route?.params ?? {}; useEffect(() => { @@ -64,10 +62,10 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA Session.setAccountError(error); } - if (exitTo) { + // For HybridApp we have separate logic to handle transitions. + if (!NativeModules.HybridAppModule && exitTo) { Navigation.isNavigationReady().then(() => { - const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route); - navigateToExitUrl(url); + Navigation.navigate(exitTo as Route); }); } // The only dependencies of the effect are based on props.route diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index d46b13459c469..9bcf567143e82 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -5,7 +5,6 @@ import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {InitialURLContext} from '@components/InitialURLContextProvider'; -import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware'; import * as SessionUtils from '@libs/SessionUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; @@ -33,7 +32,6 @@ type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreen // This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPreviousUserPageProps) { const initialURL = useContext(InitialURLContext); - const {navigateToExitUrl} = useHybridAppMiddleware(); useEffect(() => { const sessionEmail = session?.email; @@ -42,7 +40,8 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio const isSupportalLogin = route.params.authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; if (isLoggingInAsNewUser) { - SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin); + // We don't want to close react-native app in this particular case. + SessionActions.signOutAndRedirectToSignIn(false, isSupportalLogin, false); return; } @@ -78,12 +77,12 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, // which is already called when AuthScreens mounts. - if (exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) { + // For HybridApp we have separate logic to handle transitions. + if (!NativeModules.HybridAppModule && exitTo && exitTo !== ROUTES.WORKSPACE_NEW && !isAccountLoading && !isLoggingInAsNewUser) { Navigation.isNavigationReady().then(() => { // remove this screen and navigate to exit route - const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo; Navigation.goBack(); - navigateToExitUrl(exitUrl); + Navigation.navigate(exitTo); }); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx index a61875e76c2b9..db9f3199954f5 100644 --- a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx +++ b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx @@ -10,7 +10,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {MushroomTopHat} from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; -import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -36,7 +35,6 @@ type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenPr function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) { const {translate} = useLocalize(); - const {showSplashScreenOnNextStart} = useHybridAppMiddleware(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -89,7 +87,6 @@ function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitS ExitSurvey.switchToOldDot().then(() => { if (NativeModules.HybridAppModule) { Navigation.resetToHome(); - showSplashScreenOnNextStart(); NativeModules.HybridAppModule.closeReactNativeApp(); return; }