diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 3deeabf6df2a..4c2a86818e9b 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -26,7 +26,6 @@ jest.doMock('react-native', () => { type ReactNativeMock = typeof ReactNative & { NativeModules: typeof ReactNative.NativeModules & { BootSplash: { - getVisibilityStatus: typeof BootSplash.getVisibilityStatus; hide: typeof BootSplash.hide; logoSizeRatio: number; navigationBarHeight: number; @@ -46,7 +45,6 @@ jest.doMock('react-native', () => { NativeModules: { ...ReactNative.NativeModules, BootSplash: { - getVisibilityStatus: jest.fn(), hide: jest.fn(), logoSizeRatio: 1, navigationBarHeight: 0, diff --git a/patches/react-native+0.75.2+017+redactAppParameters.patch b/patches/react-native+0.75.2+017+redactAppParameters.patch new file mode 100644 index 000000000000..7c4273c6d0c1 --- /dev/null +++ b/patches/react-native+0.75.2+017+redactAppParameters.patch @@ -0,0 +1,40 @@ +diff --git a/node_modules/react-native/Libraries/ReactNative/AppRegistry.js b/node_modules/react-native/Libraries/ReactNative/AppRegistry.js +index 68bd389..be9b5bf 100644 +--- a/node_modules/react-native/Libraries/ReactNative/AppRegistry.js ++++ b/node_modules/react-native/Libraries/ReactNative/AppRegistry.js +@@ -232,12 +232,34 @@ const AppRegistry = { + appParameters: Object, + displayMode?: number, + ): void { ++ const redactAppParameters = (parameters) => { ++ const initialProps = parameters['initialProps']; ++ const url = initialProps['url']; ++ ++ if(!url) { ++ return parameters; ++ } ++ ++ const sensitiveParams = ['authToken', 'autoGeneratedPassword', 'autoGeneratedLogin']; ++ const [urlBase, queryString] = url.split('?'); ++ ++ if (!queryString) { ++ return parameters; ++ } ++ ++ const redactedSearchParams = queryString.split('&').map((param) => { ++ const [key, value] = param.split('='); ++ return `${key}=${sensitiveParams.includes(key) ? '' : value}` ++ }); ++ return {...parameters, initialProps: {...initialProps, url: `${urlBase}?${redactedSearchParams.join('&')}`}}; ++ } ++ + if (appKey !== 'LogBox') { + const msg = + 'Updating props for Surface "' + + appKey + + '" with ' + +- JSON.stringify(appParameters); ++ JSON.stringify(redactAppParameters(appParameters)); + infoLog(msg); + BugReporting.addSource( + 'AppRegistry.setSurfaceProps' + runCount++, diff --git a/src/App.tsx b/src/App.tsx index 2480d169980b..7f77c0bec676 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import {ReportIDsContextProvider} from './hooks/useReportIDs'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; +import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; type AppProps = { /** URL passed to our top-level React Native component by HybridApp. Will always be undefined in "pure" NewDot builds. */ @@ -64,47 +65,49 @@ function App({url}: AppProps) { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + ); } diff --git a/src/CONST.ts b/src/CONST.ts index 5f3fe783f1ac..ba76c71aa92e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5495,6 +5495,12 @@ const CONST = { }, }, + BOOT_SPLASH_STATE: { + VISIBLE: 'visible', + READY_TO_BE_HIDDEN: 'readyToBeHidden', + HIDDEN: `hidden`, + }, + CSV_IMPORT_COLUMNS: { EMAIL: 'email', NAME: 'name', diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 10389f69a44c..62e7839b21f0 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -1,5 +1,5 @@ import {Audio} from 'expo-av'; -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, NativeModules, Platform} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -20,8 +20,8 @@ import {updateLastRoute} from './libs/actions/App'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import * as Report from './libs/actions/Report'; import * as User from './libs/actions/User'; +import {handleHybridAppOnboarding} from './libs/actions/Welcome'; import * as ActiveClientManager from './libs/ActiveClientManager'; -import BootSplash from './libs/BootSplash'; import FS from './libs/Fullstory'; import * as Growl from './libs/Growl'; import Log from './libs/Log'; @@ -42,6 +42,7 @@ import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/Popo import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; import type {Route} from './ROUTES'; import ROUTES from './ROUTES'; +import SplashScreenStateContext from './SplashScreenStateContext'; import type {ScreenShareRequest} from './types/onyx'; Onyx.registerLogger(({level, message}) => { @@ -80,13 +81,6 @@ type ExpensifyOnyxProps = { type ExpensifyProps = ExpensifyOnyxProps; -// HybridApp needs access to SetStateAction in order to properly hide SplashScreen when React Native was booted before. -type SplashScreenHiddenContextType = {isSplashHidden?: boolean; setIsSplashHidden: React.Dispatch>}; - -const SplashScreenHiddenContext = React.createContext({ - setIsSplashHidden: () => {}, -}); - function Expensify({ isCheckingPublicRoom = true, updateAvailable, @@ -99,12 +93,13 @@ function Expensify({ const appStateChangeListener = useRef(null); const [isNavigationReady, setIsNavigationReady] = useState(false); const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); - const [isSplashHidden, setIsSplashHidden] = useState(false); + const {splashScreenState, setSplashScreenState} = useContext(SplashScreenStateContext); const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); const {translate} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); + const [tryNewDotData] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false); useEffect(() => { @@ -123,11 +118,21 @@ function Expensify({ setAttemptedToOpenPublicRoom(true); }, [isCheckingPublicRoom]); + useEffect(() => { + if (splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN || tryNewDotData === undefined) { + return; + } + + handleHybridAppOnboarding(); + }, [splashScreenState, tryNewDotData]); + const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]); const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]); const shouldInit = isNavigationReady && hasAttemptedToOpenPublicRoom; - const shouldHideSplash = shouldInit && !isSplashHidden; + const shouldHideSplash = + shouldInit && + (NativeModules.HybridAppModule ? splashScreenState === CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN && isAuthenticated : splashScreenState === CONST.BOOT_SPLASH_STATE.VISIBLE); const initializeClient = () => { if (!Visibility.isVisible()) { @@ -145,17 +150,9 @@ function Expensify({ }, []); const onSplashHide = useCallback(() => { - setIsSplashHidden(true); + setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN); Performance.markEnd(CONST.TIMING.SIDEBAR_LOADED); - }, []); - - const contextValue = useMemo( - () => ({ - isSplashHidden, - setIsSplashHidden, - }), - [isSplashHidden, setIsSplashHidden], - ); + }, [setSplashScreenState]); useLayoutEffect(() => { // Initialize this client as being an active client @@ -177,24 +174,22 @@ function Expensify({ useEffect(() => { setTimeout(() => { - BootSplash.getVisibilityStatus().then((status) => { - const appState = AppState.currentState; - Log.info('[BootSplash] splash screen status', false, {appState, status}); - - if (status === 'visible') { - const propsToLog: Omit = { - isCheckingPublicRoom, - updateRequired, - updateAvailable, - isSidebarLoaded, - screenShareRequest, - focusModeNotification, - isAuthenticated, - lastVisitedPath, - }; - Log.alert('[BootSplash] splash screen is still visible', {propsToLog}, false); - } - }); + const appState = AppState.currentState; + Log.info('[BootSplash] splash screen status', false, {appState, splashScreenState}); + + if (splashScreenState === CONST.BOOT_SPLASH_STATE.VISIBLE) { + const propsToLog: Omit = { + isCheckingPublicRoom, + updateRequired, + updateAvailable, + isSidebarLoaded, + screenShareRequest, + focusModeNotification, + isAuthenticated, + lastVisitedPath, + }; + Log.alert('[BootSplash] splash screen is still visible', {propsToLog}, false); + } }, 30 * 1000); // This timer is set in the native layer when launching the app and we stop it here so we can measure how long @@ -304,18 +299,15 @@ function Expensify({ {hasAttemptedToOpenPublicRoom && ( - - - + )} - {/* HybridApp has own middleware to hide SplashScreen */} - {!NativeModules.HybridAppModule && shouldHideSplash && } + {shouldHideSplash && } ); } @@ -349,5 +341,3 @@ export default withOnyx({ key: ONYXKEYS.LAST_VISITED_PATH, }, })(Expensify); - -export {SplashScreenHiddenContext}; diff --git a/src/SplashScreenStateContext.tsx b/src/SplashScreenStateContext.tsx new file mode 100644 index 000000000000..90a858f70c42 --- /dev/null +++ b/src/SplashScreenStateContext.tsx @@ -0,0 +1,34 @@ +import React, {useContext, useMemo, useState} from 'react'; +import type {ValueOf} from 'type-fest'; +import CONST from './CONST'; +import type ChildrenProps from './types/utils/ChildrenProps'; + +type SplashScreenStateContextType = { + splashScreenState: ValueOf; + setSplashScreenState: React.Dispatch>>; +}; + +const SplashScreenStateContext = React.createContext({ + splashScreenState: CONST.BOOT_SPLASH_STATE.VISIBLE, + setSplashScreenState: () => {}, +}); + +function SplashScreenStateContextProvider({children}: ChildrenProps) { + const [splashScreenState, setSplashScreenState] = useState>(CONST.BOOT_SPLASH_STATE.VISIBLE); + const splashScreenStateContext = useMemo( + () => ({ + splashScreenState, + setSplashScreenState, + }), + [splashScreenState], + ); + + return {children}; +} + +function useSplashScreenStateContext() { + return useContext(SplashScreenStateContext); +} + +export default SplashScreenStateContext; +export {SplashScreenStateContextProvider, useSplashScreenStateContext}; diff --git a/src/components/ErrorBoundary/BaseErrorBoundary.tsx b/src/components/ErrorBoundary/BaseErrorBoundary.tsx index 75f65a06a2e6..f56441316f7c 100644 --- a/src/components/ErrorBoundary/BaseErrorBoundary.tsx +++ b/src/components/ErrorBoundary/BaseErrorBoundary.tsx @@ -4,6 +4,7 @@ import BootSplash from '@libs/BootSplash'; import GenericErrorPage from '@pages/ErrorPage/GenericErrorPage'; import UpdateRequiredView from '@pages/ErrorPage/UpdateRequiredView'; import CONST from '@src/CONST'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; import type {BaseErrorBoundaryProps, LogError} from './types'; /** @@ -14,10 +15,12 @@ import type {BaseErrorBoundaryProps, LogError} from './types'; function BaseErrorBoundary({logError = () => {}, errorMessage, children}: BaseErrorBoundaryProps) { const [errorContent, setErrorContent] = useState(''); + const {setSplashScreenState} = useSplashScreenStateContext(); + const catchError = (errorObject: Error, errorInfo: React.ErrorInfo) => { logError(errorMessage, errorObject, JSON.stringify(errorInfo)); // We hide the splash screen since the error might happened during app init - BootSplash.hide(); + BootSplash.hide().then(() => setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN)); setErrorContent(errorObject.message); }; const updateRequired = errorContent === CONST.ERROR.UPDATE_REQUIRED; diff --git a/src/components/HybridAppMiddleware/index.ios.tsx b/src/components/HybridAppMiddleware/index.ios.tsx index aee837e02dea..0fb61fb9ad7a 100644 --- a/src/components/HybridAppMiddleware/index.ios.tsx +++ b/src/components/HybridAppMiddleware/index.ios.tsx @@ -1,21 +1,13 @@ import type React from 'react'; -import {useContext, useEffect, useRef, useState} from 'react'; +import {useEffect} from 'react'; import {NativeEventEmitter, NativeModules} from 'react-native'; import type {NativeModule} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} 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'; -import ROUTES from '@src/ROUTES'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; import type {TryNewDot} from '@src/types/onyx'; type HybridAppMiddlewareProps = { @@ -38,33 +30,10 @@ const onboardingStatusSelector = (tryNewDot: OnyxEntry) => { * 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}); +function HybridAppMiddleware({children}: HybridAppMiddlewareProps) { + const {setSplashScreenState} = useSplashScreenStateContext(); const [completedHybridAppOnboarding] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT, {selector: onboardingStatusSelector}); - const maxTimeoutRef = useRef(null); - - // We need to ensure that the BootSplash is always hidden after a certain period. - useEffect(() => { - if (!NativeModules.HybridAppModule) { - return; - } - - maxTimeoutRef.current = setTimeout(() => { - Log.info('[HybridApp] Forcing transition due to unknown problem', true); - setStartedTransition(true); - setExitTo(ROUTES.HOME); - }, 3000); - }, []); /** * This useEffect tracks changes of `nvp_tryNewDot` value. * We propagate it from OldDot to NewDot with native method due to limitations of old app. @@ -94,79 +63,13 @@ function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps 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); + setSplashScreenState(CONST.BOOT_SPLASH_STATE.VISIBLE); }); 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]); + }, [setSplashScreenState]); return children; } diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx index bb5d7803e52e..1ebe1347df8e 100644 --- a/src/components/HybridAppMiddleware/index.tsx +++ b/src/components/HybridAppMiddleware/index.tsx @@ -1,24 +1,13 @@ import type React from 'react'; -import {useContext, useEffect, useRef, useState} from 'react'; +import {useEffect} from 'react'; import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} 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'; -import ROUTES from '@src/ROUTES'; import type {TryNewDot} from '@src/types/onyx'; type HybridAppMiddlewareProps = { - authenticated: boolean; children: React.ReactNode; }; @@ -37,33 +26,9 @@ const onboardingStatusSelector = (tryNewDot: OnyxEntry) => { * 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}); +function HybridAppMiddleware({children}: HybridAppMiddlewareProps) { const [completedHybridAppOnboarding] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT, {selector: onboardingStatusSelector}); - const maxTimeoutRef = useRef(null); - - // We need to ensure that the BootSplash is always hidden after a certain period. - useEffect(() => { - if (!NativeModules.HybridAppModule) { - return; - } - - maxTimeoutRef.current = setTimeout(() => { - Log.info('[HybridApp] Forcing transition due to unknown problem', true); - setStartedTransition(true); - setExitTo(ROUTES.HOME); - }, 3000); - }, []); /** * This useEffect tracks changes of `nvp_tryNewDot` value. * We propagate it from OldDot to NewDot with native method due to limitations of old app. @@ -77,71 +42,6 @@ function HybridAppMiddleware({children, authenticated}: HybridAppMiddlewareProps NativeModules.HybridAppModule.completeOnboarding(completedHybridAppOnboarding); }, [completedHybridAppOnboarding]); - // 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 && Navigation.getActiveRoute().includes(ROUTES.TRANSITION_BETWEEN_APPS)) { - Log.info('[HybridApp] Removing /transition route from history', true); - Navigation.goBack(); - } - - if (exitTo !== ROUTES.HOME) { - 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; } diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx index 4aa76edd3e62..85ad54ca6c94 100644 --- a/src/components/InitialURLContextProvider.tsx +++ b/src/components/InitialURLContextProvider.tsx @@ -1,10 +1,21 @@ -import React, {createContext, useEffect, useState} from 'react'; +import React, {createContext, useEffect, useMemo, useState} from 'react'; import type {ReactNode} from 'react'; import {Linking} from 'react-native'; +import {signInAfterTransitionFromOldDot} from '@libs/actions/Session'; +import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; + +type InitialUrlContextType = { + initialURL: Route | undefined; + setInitialURL: React.Dispatch>; +}; /** Initial url that will be opened when NewDot is embedded into Hybrid App. */ -const InitialURLContext = createContext(undefined); +const InitialURLContext = createContext({ + initialURL: undefined, + setInitialURL: () => {}, +}); type InitialURLContextProviderProps = { /** URL passed to our top-level React Native component by HybridApp. Will always be undefined in "pure" NewDot builds. */ @@ -15,17 +26,30 @@ type InitialURLContextProviderProps = { }; function InitialURLContextProvider({children, url}: InitialURLContextProviderProps) { - const [initialURL, setInitialURL] = useState(url); + const [initialURL, setInitialURL] = useState(url); + const {setSplashScreenState} = useSplashScreenStateContext(); + useEffect(() => { if (url) { - setInitialURL(url); + const route = signInAfterTransitionFromOldDot(url); + setInitialURL(route); + setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN); return; } Linking.getInitialURL().then((initURL) => { setInitialURL(initURL as Route); }); - }, [url]); - return {children}; + }, [setSplashScreenState, url]); + + const initialUrlContext = useMemo( + () => ({ + initialURL, + setInitialURL, + }), + [initialURL], + ); + + return {children}; } InitialURLContextProvider.displayName = 'InitialURLContextProvider'; diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index dd46b33a8400..7c85837644a2 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -6,8 +6,9 @@ import {View} from 'react-native'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import useAppState from '@hooks/useAppState'; import useNetwork from '@hooks/useNetwork'; -import useSplashScreen from '@hooks/useSplashScreen'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; type Props = { source: DotLottieAnimation; @@ -15,7 +16,7 @@ type Props = { function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef) { const appState = useAppState(); - const {isSplashHidden} = useSplashScreen(); + const {splashScreenState} = useSplashScreenStateContext(); const styles = useThemeStyles(); const [isError, setIsError] = React.useState(false); @@ -33,7 +34,7 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef; } diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index 26f045ebd579..5509d3635299 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -1,10 +1,10 @@ import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useContext, useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import type {TextInput} from 'react-native'; import {InteractionManager} from 'react-native'; import CONST from '@src/CONST'; -import * as Expensify from '@src/Expensify'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; type UseAutoFocusInput = { inputCallbackRef: (ref: TextInput | null) => void; @@ -15,13 +15,13 @@ export default function useAutoFocusInput(): UseAutoFocusInput { const [isInputInitialized, setIsInputInitialized] = useState(false); const [isScreenTransitionEnded, setIsScreenTransitionEnded] = useState(false); - const {isSplashHidden} = useContext(Expensify.SplashScreenHiddenContext); + const {splashScreenState} = useSplashScreenStateContext(); const inputRef = useRef(null); const focusTimeoutRef = useRef(null); useEffect(() => { - if (!isScreenTransitionEnded || !isInputInitialized || !inputRef.current || !isSplashHidden) { + if (!isScreenTransitionEnded || !isInputInitialized || !inputRef.current || splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN) { return; } const focusTaskHandle = InteractionManager.runAfterInteractions(() => { @@ -32,7 +32,7 @@ export default function useAutoFocusInput(): UseAutoFocusInput { return () => { focusTaskHandle.cancel(); }; - }, [isScreenTransitionEnded, isInputInitialized, isSplashHidden]); + }, [isScreenTransitionEnded, isInputInitialized, splashScreenState]); useFocusEffect( useCallback(() => { diff --git a/src/hooks/useExitTo.ts b/src/hooks/useExitTo.ts deleted file mode 100644 index 74226453d3f6..000000000000 --- a/src/hooks/useExitTo.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/useSplashScreen.ts b/src/hooks/useSplashScreen.ts deleted file mode 100644 index 8838ac1289c7..000000000000 --- a/src/hooks/useSplashScreen.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {useContext} from 'react'; -import {SplashScreenHiddenContext} from '@src/Expensify'; - -type SplashScreenHiddenContextType = {isSplashHidden: boolean; setIsSplashHidden: React.Dispatch>}; - -export default function useSplashScreen() { - const {isSplashHidden, setIsSplashHidden} = useContext(SplashScreenHiddenContext) as SplashScreenHiddenContextType; - return {isSplashHidden, setIsSplashHidden}; -} - -export type {SplashScreenHiddenContextType}; diff --git a/src/libs/BootSplash/index.native.ts b/src/libs/BootSplash/index.native.ts index 9d472aec4a96..3c1ccb2ce7ce 100644 --- a/src/libs/BootSplash/index.native.ts +++ b/src/libs/BootSplash/index.native.ts @@ -10,7 +10,6 @@ function hide(): Promise { export default { hide, - getVisibilityStatus: BootSplash.getVisibilityStatus, logoSizeRatio: BootSplash.logoSizeRatio || 1, navigationBarHeight: BootSplash.navigationBarHeight || 0, }; diff --git a/src/libs/BootSplash/index.ts b/src/libs/BootSplash/index.ts index 774c5f7b06ac..a859f545abca 100644 --- a/src/libs/BootSplash/index.ts +++ b/src/libs/BootSplash/index.ts @@ -1,5 +1,4 @@ import Log from '@libs/Log'; -import type {VisibilityStatus} from './types'; function resolveAfter(delay: number): Promise { return new Promise((resolve) => { @@ -25,13 +24,8 @@ function hide(): Promise { }); } -function getVisibilityStatus(): Promise { - return Promise.resolve(document.getElementById('splash') ? 'visible' : 'hidden'); -} - export default { hide, - getVisibilityStatus, logoSizeRatio: 1, navigationBarHeight: 0, }; diff --git a/src/libs/BootSplash/types.ts b/src/libs/BootSplash/types.ts index b50b5a3397aa..106535453ed8 100644 --- a/src/libs/BootSplash/types.ts +++ b/src/libs/BootSplash/types.ts @@ -4,7 +4,6 @@ type BootSplashModule = { logoSizeRatio: number; navigationBarHeight: number; hide: () => Promise; - getVisibilityStatus: () => Promise; }; export type {BootSplashModule, VisibilityStatus}; diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index faf6563a0ef3..cfd41a4b1fa0 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -1,7 +1,9 @@ import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; +import {NativeModules} from 'react-native'; import type {PublicScreensParamList} from '@navigation/types'; import ConnectionCompletePage from '@pages/ConnectionCompletePage'; +import SessionExpiredPage from '@pages/ErrorPage/SessionExpiredPage'; import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage'; import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage'; import GoogleSignInDesktopPage from '@pages/signin/GoogleSignInDesktopPage'; @@ -22,7 +24,7 @@ function PublicScreens() { { - if (!NativeModules.HybridAppModule || !initUrl || !initUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS)) { + if (!NativeModules.HybridAppModule || !initialURL) { return; } Navigation.isNavigationReady().then(() => { - Navigation.navigate(initUrl); + Navigation.navigate(initialURL); }); - }, [initUrl]); + }, [initialURL]); if (authenticated) { const AuthScreens = require('./AuthScreens').default; diff --git a/src/libs/Navigation/AppNavigator/index.tsx b/src/libs/Navigation/AppNavigator/index.tsx index e0f1dae94f62..1901a51563e9 100644 --- a/src/libs/Navigation/AppNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/index.tsx @@ -14,17 +14,17 @@ type AppNavigatorProps = { }; function AppNavigator({authenticated}: AppNavigatorProps) { - const initUrl = useContext(InitialURLContext); + const {initialURL} = useContext(InitialURLContext); useEffect(() => { - if (!NativeModules.HybridAppModule || !initUrl || !initUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS)) { + if (!NativeModules.HybridAppModule || !initialURL || !initialURL.includes(ROUTES.TRANSITION_BETWEEN_APPS)) { return; } Navigation.isNavigationReady().then(() => { - Navigation.navigate(initUrl); + Navigation.navigate(initialURL); }); - }, [initUrl]); + }, [initialURL]); if (authenticated) { // These are the protected screens and only accessible when an authToken is present diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 152594ba6b3e..a253b6f2316e 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,6 +1,7 @@ import type {NavigationState} from '@react-navigation/native'; import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HybridAppMiddleware from '@components/HybridAppMiddleware'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; @@ -97,7 +98,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. // We also make sure that the user is authenticated. - if (!hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) { + if (!NativeModules.HybridAppModule && !hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) { const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); return adaptedState; } @@ -181,7 +182,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh }} > {/* 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 e905464e551f..1d7e695fa2e3 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -39,6 +39,7 @@ import * as SessionUtils from '@libs/SessionUtils'; import Timers from '@libs/Timers'; import {hideContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import {KEYS_TO_PRESERVE, openApp} from '@userActions/App'; +import * as App from '@userActions/App'; import * as Device from '@userActions/Device'; import * as PriorityMode from '@userActions/PriorityMode'; import redirectToSignIn from '@userActions/SignInRedirect'; @@ -460,6 +461,32 @@ function signUpUser() { API.write(WRITE_COMMANDS.SIGN_UP_USER, params, {optimisticData, successData, failureData}); } +function signInAfterTransitionFromOldDot(transitionURL: string) { + const [route, queryParams] = transitionURL.split('?'); + + const {email, authToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart} = Object.fromEntries( + queryParams.split('&').map((param) => { + const [key, value] = param.split('='); + return [key, value]; + }), + ); + + const setSessionDataAndOpenApp = () => { + Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email, authToken, accountID: Number(accountID)}, + [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, + }).then(App.openApp); + }; + + if (clearOnyxOnStart === 'true') { + Onyx.clear(KEYS_TO_PRESERVE).then(setSessionDataAndOpenApp); + } else { + setSessionDataAndOpenApp(); + } + + return route as Route; +} + /** * Given an idToken from Sign in with Apple, checks the API to see if an account * exists for that email address and signs the user in if so. @@ -1094,4 +1121,5 @@ export { isSupportAuthToken, hasStashedSession, signUpUser, + signInAfterTransitionFromOldDot, }; diff --git a/src/pages/ErrorPage/SessionExpiredPage.tsx b/src/pages/ErrorPage/SessionExpiredPage.tsx new file mode 100644 index 000000000000..a6b555e29077 --- /dev/null +++ b/src/pages/ErrorPage/SessionExpiredPage.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {NativeModules, View} from 'react-native'; +import Icon from '@components/Icon'; +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 useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as Session from '@userActions/Session'; + +function SessionExpiredPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const theme = useTheme(); + + return ( + + + + + + {translate('deeplinkWrapper.launching')} + + + {translate('deeplinkWrapper.expired')}{' '} + { + if (!NativeModules.HybridAppModule) { + Session.clearSignInData(); + Navigation.navigate(); + return; + } + NativeModules.HybridAppModule.closeReactNativeApp(true, false); + }} + > + {translate('deeplinkWrapper.signIn')} + + + + + + + + + ); +} + +SessionExpiredPage.displayName = 'SessionExpiredPage'; + +export default SessionExpiredPage; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 6eb6a6cc7161..fcbeadaa4a47 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -1,17 +1,9 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useEffect} from 'react'; -import {NativeModules, View} from 'react-native'; +import {NativeModules} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import Icon from '@components/Icon'; -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 useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PublicScreensParamList} from '@libs/Navigation/types'; @@ -22,6 +14,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Account} from '@src/types/onyx'; +import SessionExpiredPage from './ErrorPage/SessionExpiredPage'; type LogInWithShortLivedAuthTokenPageOnyxProps = { /** The details about the account that the user is signing in with */ @@ -31,9 +24,6 @@ type LogInWithShortLivedAuthTokenPageOnyxProps = { type LogInWithShortLivedAuthTokenPageProps = LogInWithShortLivedAuthTokenPageOnyxProps & StackScreenProps; function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedAuthTokenPageProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); const {email = '', shortLivedAuthToken = '', shortLivedToken = '', authTokenType, exitTo, error} = route?.params ?? {}; useEffect(() => { @@ -76,41 +66,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA return ; } - return ( - - - - - - {translate('deeplinkWrapper.launching')} - - - {translate('deeplinkWrapper.expired')}{' '} - { - Session.clearSignInData(); - Navigation.navigate(); - }} - > - {translate('deeplinkWrapper.signIn')} - - - - - - - - - ); + return ; } LogInWithShortLivedAuthTokenPage.displayName = 'LogInWithShortLivedAuthTokenPage'; diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index f5b96e2d57c5..deb95a576c3d 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -31,7 +31,7 @@ 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 {initialURL} = useContext(InitialURLContext); useEffect(() => { const sessionEmail = session?.email; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 230e81fbd859..eeacd56f8d5c 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -11,6 +11,7 @@ import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {InitialURLContext} from '@components/InitialURLContextProvider'; import MenuItem from '@components/MenuItem'; import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -104,6 +105,7 @@ function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms const activeCentralPaneRoute = useActiveCentralPaneRoute(); const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; const [allConnectionSyncProgresses] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}`); + const {setInitialURL} = useContext(InitialURLContext); const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const subscriptionPlan = useSubscriptionPlan(); @@ -149,6 +151,7 @@ function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms ? { action: () => { NativeModules.HybridAppModule.closeReactNativeApp(false, true); + setInitialURL(undefined); }, } : { @@ -184,7 +187,7 @@ function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms }; return defaultMenu; - }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors]); + }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors, setInitialURL]); /** * Retuns a list of menu items data for workspace section diff --git a/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx b/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx index 3aab8a003bc9..de9260dac537 100644 --- a/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx +++ b/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx @@ -4,10 +4,10 @@ import type {ImageSourcePropType} from 'react-native'; import Reanimated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import DesktopBackgroundImage from '@assets/images/home-background--desktop.svg'; import MobileBackgroundImage from '@assets/images/home-background--mobile-new.svg'; -import useSplashScreen from '@hooks/useSplashScreen'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; import type BackgroundImageProps from './types'; function BackgroundImage({width, transitionDuration, isSmallScreen = false}: BackgroundImageProps) { @@ -26,10 +26,10 @@ function BackgroundImage({width, transitionDuration, isSmallScreen = false}: Bac }); } - const {isSplashHidden} = useSplashScreen(); + const {splashScreenState} = useSplashScreenStateContext(); // Prevent rendering the background image until the splash screen is hidden. // See issue: https://github.com/Expensify/App/issues/34696 - if (!isSplashHidden) { + if (splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN) { return; }