Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ 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 @@ -345,7 +348,16 @@ function Expensify() {
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected);
openReportFromDeepLink(
url,
currentOnboardingPurposeSelected,
currentOnboardingCompanySize,
onboardingInitialPath,
Comment on lines +353 to +355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use fresh onboarding state when handling deep links

The deep-link handler now passes onboarding purpose/company size/last path into openReportFromDeepLink(), but this effect still only re-subscribes on session/concierge/intro changes. If onboarding progress changes after mount, the listener keeps stale values and startOnboardingFlow() can restart users from an outdated onboarding step when a new deep link is opened.

Useful? React with 👍 / 👎.

allReports,
isAuthenticated,
introSelected,
conciergeReportID,
);
} else {
Report.doneCheckingPublicRoom();
}
Expand All @@ -362,7 +374,16 @@ function Expensify() {
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected);
openReportFromDeepLink(
state.url,
currentOnboardingPurposeSelected,
currentOnboardingCompanySize,
onboardingInitialPath,
allReports,
isCurrentlyAuthenticated,
introSelected,
conciergeReportID,
);
});

return () => {
Expand Down
58 changes: 53 additions & 5 deletions src/hooks/useOnboardingFlow.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {isSingleNewDotEntrySelector} from '@selectors/HybridApp';
import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
import {emailSelector} from '@selectors/Session';
import {useEffect, useMemo} from 'react';
import {useEffect, useMemo, useRef} 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 @@ -27,16 +29,20 @@ function useOnboardingFlowRouter() {
const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
canBeMissing: true,
});
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {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 [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 @@ -50,7 +56,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 showing onboarding if we are logging in as a new user with short lived token
// Prevent starting the onboarding flow 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 @@ -83,6 +89,12 @@ 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 @@ -93,6 +105,37 @@ 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 @@ -109,11 +152,16 @@ 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,11 +1,9 @@
import {CommonActions, StackRouter} from '@react-navigation/native';
import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import {findFocusedRoute, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName';
import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName';
import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator';
import {linkingConfig} from '@libs/Navigation/linkingConfig';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import {
Expand Down Expand Up @@ -58,45 +56,20 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
}

/**
* 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;
function shouldPreventReset(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType) {
if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
return false;
}
const currentFocusedRoute = findFocusedRoute(state);
const targetFocusedRoute = findFocusedRoute(action?.payload);

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);
// 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;
}

return null;
return false;
}

function isNavigatingToModalFromModal(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType): action is PushActionType {
Expand All @@ -117,14 +90,6 @@ 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 @@ -156,6 +121,12 @@ 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: 30 additions & 2 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {NavigationState} from '@react-navigation/native';
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} 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 @@ -16,6 +17,8 @@ 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 @@ -99,11 +102,20 @@ 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 @@ -124,6 +136,22 @@ 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