Skip to content
Merged
25 changes: 2 additions & 23 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,6 @@ function Expensify() {
const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true});
const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true});
const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true});
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true});
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true});
Expand Down Expand Up @@ -348,16 +345,7 @@ function Expensify() {
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(
url,
currentOnboardingPurposeSelected,
currentOnboardingCompanySize,
onboardingInitialPath,
allReports,
isAuthenticated,
introSelected,
conciergeReportID,
);
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected);
} else {
Report.doneCheckingPublicRoom();
}
Expand All @@ -374,16 +362,7 @@ function Expensify() {
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(
state.url,
currentOnboardingPurposeSelected,
currentOnboardingCompanySize,
onboardingInitialPath,
allReports,
isCurrentlyAuthenticated,
introSelected,
conciergeReportID,
);
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected);
});

return () => {
Expand Down
58 changes: 5 additions & 53 deletions src/hooks/useOnboardingFlow.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {isSingleNewDotEntrySelector} from '@selectors/HybridApp';
import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
import {emailSelector} from '@selectors/Session';
import {useEffect, useMemo, useRef} from 'react';
import {useEffect, useMemo} from 'react';
import {InteractionManager} from 'react-native';
import {startOnboardingFlow} from '@libs/actions/Welcome/OnboardingFlow';
import Log from '@libs/Log';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {buildCannedSearchQuery} from '@libs/SearchQueryUtils';
Expand All @@ -29,20 +27,16 @@ function useOnboardingFlowRouter() {
const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
canBeMissing: true,
});
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
const [onboardingInitialPath, onboardingInitialPathMetadata] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
const isOnboardingLoading = isLoadingOnyxValue(onboardingInitialPathMetadata, accountMetadata);
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});

const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: emailSelector});
const isLoggingInAsNewSessionUser = isLoggingInAsNewUser(currentUrl, sessionEmail);
const startedOnboardingFlowRef = useRef(false);
const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {
selector: tryNewDotOnyxSelector,
canBeMissing: true,
});
const {isHybridAppOnboardingCompleted, hasBeenAddedToNudgeMigration} = tryNewDot ?? {};
const isOnboardingLoading = isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotMetadata);

const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true});

Expand All @@ -56,7 +50,7 @@ function useOnboardingFlowRouter() {
// This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes
// eslint-disable-next-line @typescript-eslint/no-deprecated
const handle = InteractionManager.runAfterInteractions(() => {
// Prevent starting the onboarding flow if we are logging in as a new user with short lived token
// Prevent showing onboarding if we are logging in as a new user with short lived token
if (currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS) && isLoggingInAsNewSessionUser) {
return;
}
Expand Down Expand Up @@ -89,12 +83,6 @@ function useOnboardingFlowRouter() {
return;
}

if (hasBeenAddedToNudgeMigration) {
return;
}

const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed !== false;

if (CONFIG.IS_HYBRID_APP) {
// For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding
if (isSingleNewDotEntry) {
Expand All @@ -105,37 +93,6 @@ function useOnboardingFlowRouter() {
if (isHybridAppOnboardingCompleted === false) {
Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT);
}

// But if the hybrid app onboarding is completed, but NewDot onboarding is not completed, we start NewDot onboarding flow
// This is a special case when user created an account from NewDot without finishing the onboarding flow and then logged in from OldDot
if (isHybridAppOnboardingCompleted === true && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) {
startedOnboardingFlowRef.current = true;
Log.info('[Onboarding] Hybrid app onboarding is completed, but NewDot onboarding is not completed, starting NewDot onboarding flow');
startOnboardingFlow({
onboardingValuesParam: onboardingValues,
isUserFromPublicDomain: !!account?.isFromPublicDomain,
hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies,
currentOnboardingCompanySize,
currentOnboardingPurposeSelected,
onboardingInitialPath,
onboardingValues,
});
}
}

// If the user is not transitioning from OldDot to NewDot, we should start NewDot onboarding flow if it's not completed yet
if (!CONFIG.IS_HYBRID_APP && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) {
startedOnboardingFlowRef.current = true;
Log.info('[Onboarding] Not a hybrid app, NewDot onboarding is not completed, starting NewDot onboarding flow');
startOnboardingFlow({
onboardingValuesParam: onboardingValues,
isUserFromPublicDomain: !!account?.isFromPublicDomain,
hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies,
currentOnboardingCompanySize,
currentOnboardingPurposeSelected,
onboardingInitialPath,
onboardingValues,
});
}
});

Expand All @@ -152,16 +109,11 @@ function useOnboardingFlowRouter() {
hasBeenAddedToNudgeMigration,
dismissedProductTrainingMetadata,
dismissedProductTraining?.migratedUserWelcomeModal,
onboardingValues,
dismissedProductTraining,
account?.isFromPublicDomain,
account?.hasAccessibleDomainPolicies,
currentUrl,
isLoggingInAsNewSessionUser,
currentOnboardingCompanySize,
currentOnboardingPurposeSelected,
onboardingInitialPath,
isOnboardingLoading,
onboardingValues,
]);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import {findFocusedRoute, StackRouter} from '@react-navigation/native';
import {CommonActions, StackRouter} from '@react-navigation/native';
import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName';
import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName';
import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator';
import * as Welcome from '@userActions/Welcome';
import {linkingConfig} from '@libs/Navigation/linkingConfig';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import {
Expand Down Expand Up @@ -56,20 +58,45 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
}

function shouldPreventReset(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType) {
if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
return false;
/**
* Evaluates navigation guards and handles BLOCK/REDIRECT results
*
* @param state - Current navigation state
* @param action - Navigation action being attempted
* @param configOptions - Router configuration options
* @param stackRouter - Stack router instance
* @returns Modified state if guard blocks/redirects, null if navigation should proceed
*/
function handleNavigationGuards(
state: StackNavigationState<ParamListBase>,
action: RootStackNavigatorAction,
configOptions: RouterConfigOptions,
stackRouter: ReturnType<typeof StackRouter>,
): ReturnType<ReturnType<typeof StackRouter>['getStateForAction']> | null {
const guardContext = createGuardContext();
const guardResult = evaluateGuards(state, action, guardContext);

if (guardResult.type === 'BLOCK') {
syncBrowserHistory(state);
return state;
}
const currentFocusedRoute = findFocusedRoute(state);
const targetFocusedRoute = findFocusedRoute(action?.payload);

// We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
Welcome.setOnboardingErrorMessage('onboarding.purpose.errorBackButton');
return true;
if (guardResult.type === 'REDIRECT') {
const redirectState = getAdaptedStateFromPath(guardResult.route, linkingConfig.config);

if (!redirectState || !redirectState.routes) {
return null;
}

const resetAction = CommonActions.reset({
index: redirectState.index ?? redirectState.routes.length - 1,
routes: redirectState.routes,
});

return stackRouter.getStateForAction(state, resetAction, configOptions);
}

return false;
return null;
}

function isNavigatingToModalFromModal(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType): action is PushActionType {
Expand All @@ -90,6 +117,14 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
return {
...stackRouter,
getStateForAction(state: StackNavigationState<ParamListBase>, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) {
// Evaluate navigation guards FIRST
const guardState = handleNavigationGuards(state, action, configOptions, stackRouter);
if (guardState) {
return guardState;
}

// Guards allowed navigation - continue with routing logic

if (isPreloadAction(action) && action.payload.name === state.routes.at(-1)?.name) {
return state;
}
Expand Down Expand Up @@ -121,12 +156,6 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
return handlePushFullscreenAction(state, action, configOptions, stackRouter);
}

// Don't let the user navigate back to a non-onboarding screen if they are currently on an onboarding screen and it's not finished.
if (shouldPreventReset(state, action)) {
syncBrowserHistory(state);
return state;
}

if (isNavigatingToModalFromModal(state, action)) {
return handleNavigatingToModalFromModal(state, action, configOptions, stackRouter);
}
Expand Down
32 changes: 2 additions & 30 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type {NavigationState} from '@react-navigation/native';
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding';
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
import {useOnboardingValues} from '@components/OnyxListItemProvider';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import {useCurrentReportIDActions} from '@hooks/useCurrentReportID';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -17,8 +16,6 @@ import shouldOpenLastVisitedPath from '@libs/shouldOpenLastVisitedPath';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import {updateOnboardingLastVisitedPath} from '@userActions/Welcome';
import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import {endSpan, getSpan, startSpan} from '@src/libs/telemetry/activeSpans';
import {navigationIntegration} from '@src/libs/telemetry/integrations';
Expand Down Expand Up @@ -102,20 +99,11 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
selector: hasCompletedGuidedSetupFlowSelector,
canBeMissing: true,
});
const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {
selector: wasInvitedToNewDotSelector,
canBeMissing: true,
});
const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY, {canBeMissing: true});
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
const onboardingValues = useOnboardingValues();

const previousAuthenticated = usePrevious(authenticated);

const initialState = useMemo(() => {
const path = initialUrl ? getPathFromURL(initialUrl) : null;

if (path?.includes(ROUTES.MIGRATED_USER_WELCOME_MODAL.route) && shouldOpenLastVisitedPath(lastVisitedPath) && isOnboardingCompleted && authenticated) {
Navigation.isNavigationReady().then(() => {
Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute());
Expand All @@ -136,22 +124,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
return undefined;
}

// 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, isn't part of a group workspace, isn't in the transition flow & wasn't invited to NewDot.
if (!CONFIG.IS_HYBRID_APP && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated) {
return getAdaptedStateFromPath(
getOnboardingInitialPath({
isUserFromPublicDomain: !!account.isFromPublicDomain,
hasAccessiblePolicies: !!account.hasAccessibleDomainPolicies,
currentOnboardingPurposeSelected,
currentOnboardingCompanySize,
onboardingInitialPath,
onboardingValues,
}),
linkingConfig.config,
);
}

if (shouldOpenLastVisitedPath(lastVisitedPath) && authenticated) {
// Only skip restoration if there's a specific deep link that's not the root
// This allows restoration when app is killed and reopened without a deep link
Expand Down
Loading
Loading