diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2a0372fd9c7..240463522854 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -336,6 +336,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', + // Stores onboarding last visited path + ONBOARDING_LAST_VISITED_PATH: 'onboardingLastVisitedPath', + // Max width supported for HTML element MAX_CANVAS_WIDTH: 'maxCanvasWidth', @@ -894,6 +897,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; + [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index c6294f600993..73290c43d39a 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -3,8 +3,8 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { @@ -18,7 +18,7 @@ function ExplanationModal() { onNotCompleted: () => { setTimeout(() => { Navigation.isNavigationReady().then(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }); }, variables.welcomeVideoDelay); }, diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index f432d863704e..d06be872c70a 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -13,9 +13,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import linkingConfig from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; import {isCentralPaneName} from '@libs/NavigationUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -26,6 +24,7 @@ import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -95,10 +94,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { } Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); - navigationRef.resetRoot(adaptedState); - }, + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), }); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index ba489d67aeb5..a1aa53bc0b7e 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -15,6 +15,8 @@ import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFl import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; +import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; +import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -59,6 +61,9 @@ function parseAndLogRoute(state: NavigationState) { if (focusedRoute && !CONST.EXCLUDE_FROM_LAST_VISITED_PATH.includes(focusedRoute?.name)) { updateLastVisitedPath(currentPath); + if (currentPath.startsWith(`/${ROUTES.ONBOARDING_ROOT.route}`)) { + updateOnboardingLastVisitedPath(currentPath); + } } // Don't log the route transitions from OldDot because they contain authTokens @@ -99,7 +104,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 (!NativeModules.HybridAppModule && !hasCompletedGuidedSetupFlow && authenticated && !shouldShowRequire2FAModal) { - const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT.route, linkingConfig.config); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config); return adaptedState; } diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index a0e1b9a25d35..5c38a4b1acfa 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -107,8 +107,9 @@ const config: LinkingOptions['config'] = { }, }, [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: { + // Don't set the initialRouteName, because when the user continues from the last visited onboarding page, + // the onboarding purpose page will be briefly visible. path: ROUTES.ONBOARDING_ROOT.route, - initialRouteName: SCREENS.ONBOARDING.PURPOSE, screens: { [SCREENS.ONBOARDING.PURPOSE]: { path: ROUTES.ONBOARDING_PURPOSE.route, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 10e68ad4a6a8..2c96e5796309 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -47,7 +47,7 @@ type GetAdaptedStateReturnType = { metainfo: Metainfo; }; -type GetAdaptedStateFromPath = (...args: Parameters) => GetAdaptedStateReturnType; +type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; // The function getPathFromState that we are using in some places isn't working correctly without defined index. const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); @@ -365,7 +365,7 @@ function getAdaptedState(state: PartialState }; } -const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { +const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => { const normalizedPath = !path.startsWith('/') ? `/${path}` : path; const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath); const isAnonymous = isAnonymousUser(); @@ -374,7 +374,9 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options) => { const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path); const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>; - replacePathInNestedState(state, path); + if (shouldReplacePathInNestedState) { + replacePathInNestedState(state, path); + } if (state === undefined) { throw new Error('Unable to parse path'); } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 923d23ca9636..7209d106e1c6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -111,6 +111,7 @@ import * as Modal from './Modal'; import navigateFromNotification from './navigateFromNotification'; import * as Session from './Session'; import * as Welcome from './Welcome'; +import * as OnboardingFlow from './Welcome/OnboardingFlow'; type SubscriberCallback = (isFromCurrentUser: boolean, reportActionID: string | undefined) => void; @@ -2704,7 +2705,9 @@ function openReportFromDeepLink(url: string) { // We need skip deeplinking if the user hasn't completed the guided setup flow. if (!hasCompletedGuidedSetupFlow) { - Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT.getRoute())}); + Welcome.isOnboardingFlowCompleted({ + onNotCompleted: () => OnboardingFlow.startOnboardingFlow(), + }); return; } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts new file mode 100644 index 000000000000..4e780090299d --- /dev/null +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -0,0 +1,126 @@ +import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import linkingConfig from '@libs/Navigation/linkingConfig'; +import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +let selectedPurpose: string | undefined = ''; +Onyx.connect({ + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + callback: (value) => { + selectedPurpose = value; + }, +}); + +let onboardingInitialPath = ''; +const onboardingLastVisitedPathConnection = Onyx.connect({ + key: ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, + callback: (value) => { + if (value === undefined) { + return; + } + onboardingInitialPath = value; + Onyx.disconnect(onboardingLastVisitedPathConnection); + }, +}); + +/** + * Build the correct stack order for `onboardingModalNavigator`, + * based on onboarding data (currently from the selected purpose). + * The correct stack order will ensure that navigation and + * the `goBack` navigatoin work properly. + */ +function adaptOnboardingRouteState() { + const currentRoute: NavigationPartialRoute | undefined = navigationRef.getCurrentRoute(); + if (!currentRoute || currentRoute?.name === SCREENS.ONBOARDING.PURPOSE) { + return; + } + + const rootState = navigationRef.getRootState(); + const adaptedState = rootState; + const lastRouteIndex = (adaptedState?.routes?.length ?? 0) - 1; + const onBoardingModalNavigatorState = adaptedState?.routes[lastRouteIndex]?.state; + if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1) { + return; + } + + let adaptedOnboardingModalNavigatorState = {} as Readonly>; + if (currentRoute?.name === SCREENS.ONBOARDING.PERSONAL_DETAILS && selectedPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + adaptedOnboardingModalNavigatorState = { + index: 2, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + { + name: SCREENS.ONBOARDING.WORK, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly>; + } else { + adaptedOnboardingModalNavigatorState = { + index: 1, + routes: [ + { + name: SCREENS.ONBOARDING.PURPOSE, + params: currentRoute?.params, + }, + {...currentRoute}, + ], + } as Readonly>; + } + + adaptedState.routes[lastRouteIndex].state = adaptedOnboardingModalNavigatorState; + navigationRef.resetRoot(adaptedState); +} + +/** + * Start a new onboarding flow or continue from the last visited onboarding page. + */ +function startOnboardingFlow() { + const currentRoute = navigationRef.getCurrentRoute(); + const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(), linkingConfig.config, false); + const focusedRoute = findFocusedRoute(adaptedState as PartialState>); + if (focusedRoute?.name === currentRoute?.name) { + return; + } + navigationRef.resetRoot(adaptedState); +} + +function getOnboardingInitialPath(): string { + const state = getStateFromPath(onboardingInitialPath, linkingConfig.config); + if (state?.routes?.at(-1)?.name !== NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR) { + return `/${ROUTES.ONBOARDING_ROOT.route}`; + } + + return onboardingInitialPath; +} + +function clearInitialPath() { + onboardingInitialPath = ''; +} + +/** + * Onboarding flow: Go back to the previous page. + * Since there is no `initialRoute` for `onBoardingModalNavigator`, + * firstly, adjust the current onboarding modal navigator to establish the correct stack order. + * Then, navigate to the previous onboarding page using the usual `goBack` function. + */ +function goBack() { + adaptOnboardingRouteState(); + Navigation.isNavigationReady().then(() => { + Navigation.goBack(); + }); +} + +export {getOnboardingInitialPath, startOnboardingFlow, clearInitialPath, goBack}; diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome/index.ts similarity index 92% rename from src/libs/actions/Welcome.ts rename to src/libs/actions/Welcome/index.ts index d54314ae6f05..f5995aa1e2a9 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome/index.ts @@ -10,6 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; +import * as OnboardingFlow from './OnboardingFlow'; type OnboardingData = Onboarding | [] | undefined; @@ -46,6 +47,7 @@ function onServerDataReady(): Promise { return isServerDataReadyPromise; } +let isOnboardingInProgress = false; function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { isOnboardingFlowStatusKnownPromise.then(() => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { @@ -53,8 +55,10 @@ function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOn } if (onboarding?.hasCompletedGuidedSetupFlow) { + isOnboardingInProgress = false; onCompleted?.(); - } else { + } else if (!isOnboardingInProgress) { + isOnboardingInProgress = true; onNotCompleted?.(); } }); @@ -97,7 +101,7 @@ function handleHybridAppOnboarding() { isOnboardingFlowCompleted({ onNotCompleted: () => setTimeout(() => { - Navigation.navigate(ROUTES.ONBOARDING_ROOT.route); + OnboardingFlow.startOnboardingFlow(); }, variables.explanationModalDelay), }), }); @@ -152,6 +156,10 @@ function setOnboardingPolicyID(policyID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, policyID ?? null); } +function updateOnboardingLastVisitedPath(path: string) { + Onyx.merge(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, path); +} + function completeHybridAppOnboarding() { const optimisticData: OnyxUpdate[] = [ { @@ -213,12 +221,15 @@ function resetAllChecks() { resolveOnboardingFlowStatus = resolve; }); isLoadingReportData = true; + isOnboardingInProgress = false; + OnboardingFlow.clearInitialPath(); } export { onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, + updateOnboardingLastVisitedPath, resetAllChecks, setOnboardingAdminsChatReportID, setOnboardingPolicyID, diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index d2dbd7eff953..9f3414d1a061 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -22,6 +22,7 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; +import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; @@ -131,7 +132,7 @@ function BaseOnboardingPersonalDetails({