diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cb83432efc8e..5bff0bec7271 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -143,6 +143,12 @@ const createExpenseOnboardingChoices = { SUBMIT: backendOnboardingChoices.SUBMIT, } as const; +// Values accepted by the `intent` param on the onboarding deeplink. Kept separate from the internal onboarding +// choice strings because they are embedded in emails we can't redeploy. +const onboardingIntents = { + SUBMIT: 'submit', +} as const; + const signupQualifiers = { INDIVIDUAL: 'individual', VSB: 'vsb', @@ -6689,6 +6695,7 @@ const CONST = { EXPENSIFY_ICON_NAME: 'Expensify', ONBOARDING_CHOICES: {...onboardingChoices}, + ONBOARDING_INTENTS: {...onboardingIntents}, SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, CREATE_EXPENSE_ONBOARDING_CHOICES: {...createExpenseOnboardingChoices}, ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers}, @@ -9614,6 +9621,9 @@ type IOUActionParams = ValueOf; type SubscriptionType = ValueOf; type CancellationType = ValueOf; +/** Valid values for the `intent` param on the onboarding deeplink */ +type OnboardingIntent = ValueOf; + /** Valid `page` values for the Enable Payments flow */ type EnablePaymentsPageType = ValueOf; @@ -9635,6 +9645,7 @@ export type { CancellationType, OnboardingInvite, OnboardingAccounting, + OnboardingIntent, IOUActionParams, EnablePaymentsPageType, EnablePaymentsSubPageType, diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index f674b3fe6f44..ec56561e18ff 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -11,6 +11,7 @@ import useOnyx from './hooks/useOnyx'; import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; +import getOnboardingIntentFromUrl from './libs/getOnboardingIntentFromUrl'; import Log from './libs/Log'; import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; @@ -142,10 +143,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url}); } const isCurrentlyAuthenticated = hasAuthToken(); - // A Submit-via-PDF secure access link can arrive while the app is already running (warm), where - // getInitialURL() is empty. Record it so onboarding suppression has a session-sticky signal, the same - // way the cold path does via onInitialUrl above. Scoped to secure links so other deep links are unaffected. - if (hasSecureLinkKey(state.url)) { + // A Submit-via-PDF secure access link, or an onboarding deeplink carrying an intent, can arrive while the + // app is already running (warm), where getInitialURL() is empty. Record it so the handlers reading the + // initial URL have a session-sticky signal, the same way the cold path does via onInitialUrl above. + if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) { onInitialUrl(state.url as Route); } openReportFromDeepLink( diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 96ec95acb992..4ab557be02b3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -9,7 +9,7 @@ import type {UpperCaseCharacters} from 'type-fest/source/internal'; import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types'; import type CONST from './CONST'; -import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType} from './CONST'; +import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType, OnboardingIntent} from './CONST'; import type {ReplacementReason} from './libs/actions/Card'; import type {RootNavigatorParamList} from './libs/Navigation/types'; import type {Screen} from './SCREENS'; @@ -3964,7 +3964,7 @@ const ROUTES = { ONBOARDING_ROOT: { route: 'onboarding', - getRoute: () => 'onboarding' as const, + getRoute: (intent?: OnboardingIntent) => (intent ? (`onboarding?intent=${intent}` as const) : ('onboarding' as const)), }, ONBOARDING_PERSONAL_DETAILS: { route: 'onboarding/personal-details', diff --git a/src/hooks/useOnboardingDeeplinkIntent.ts b/src/hooks/useOnboardingDeeplinkIntent.ts new file mode 100644 index 000000000000..893fd5e467ba --- /dev/null +++ b/src/hooks/useOnboardingDeeplinkIntent.ts @@ -0,0 +1,23 @@ +import {useInitialURLState} from '@components/InitialURLContextProvider'; + +import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; + +import type {OnboardingIntent} from '@src/CONST'; + +import {useState} from 'react'; + +/** + * Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`. + * + * The URL is latched at mount because the app rewrites it as soon as it navigates. The initial URL covers native, + * where the browser URL is empty, and links opened while the app is already running. + */ +function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { + const {initialURL} = useInitialURLState(); + const [urlAtMount] = useState(getCurrentUrl); + + return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL); +} + +export default useOnboardingDeeplinkIntent; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9f674c2f27d8..2a49ffd1bc84 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -72,6 +72,7 @@ import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator'; import SubmitPlanWelcomeModalNavigator from './Navigators/SubmitPlanWelcomeModalNavigator'; import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator'; import {loadRightModalNavigator, loadSearchRouterPage} from './searchRouterLazyLoaders'; +import SubmitIntentDeeplinkHandler from './SubmitIntentDeeplinkHandler'; import TestDriveDemoNavigator from './TestDriveDemoNavigator'; import ThreeDSAuthHandler from './ThreeDSAuthHandler'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; @@ -164,6 +165,7 @@ function AuthScreens() { <> + diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx new file mode 100644 index 000000000000..2464d891e544 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -0,0 +1,65 @@ +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import {suppressWelcomeModalForSubmitDeeplink} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; + +import {setSubmitMigrationModalShown} from '@userActions/User'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; +import {isSupportalSessionSelector} from '@selectors/Session'; +import {useEffect} from 'react'; + +// Module scope rather than a ref so it survives this component remounting. The deeplink is read from the initial URL, +// which the provider above the navigator keeps for the life of the process, so signing out and into another account +// remounts this component with the same intent still readable and would create a workspace for that second account. +let hasAppliedIntent = false; + +/** + * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. + * + * The link only goes to existing users, so it acts solely on recipients who have finished guided setup. Anyone who + * still has onboarding ahead of them is left to it, since that flow already offers the Submit outcome. + */ +function ApplySubmitOnboardingIntent() { + const {firstName, lastName} = useCurrentUserPersonalDetails(); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); + + // Waiting on HAS_LOADED_APP keeps the eligibility checks inside useAutoCreateSubmitWorkspace from running + // against a half-populated store, where they would miss an existing workspace and create a duplicate. + const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); + const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); + const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); + + // Runs at mount rather than alongside the work below, which waits on HAS_LOADED_APP — the same signal the guard + // uses to decide whether to open its modal. + useEffect(() => { + suppressWelcomeModalForSubmitDeeplink(); + }, []); + + useEffect(() => { + if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined) { + return; + } + hasAppliedIntent = true; + + // Marked applied above before these checks, since they are reasons to drop the intent rather than to wait for + // it to become actionable. Returning without consuming would leave it live for whoever signs in next. + if (!isOnboardingCompleted || isSupportalSession) { + return; + } + + // Persists the suppression applied above, so the modal stays away in later sessions too. + setSubmitMigrationModalShown(); + + // `false` skips CompleteGuidedSetup, which is already done. The hook navigates to the user's existing Submit + // workspace rather than creating a second one, which is what makes repeat clicks idempotent. + autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); + }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); + + return null; +} + +export default ApplySubmitOnboardingIntent; diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx new file mode 100644 index 000000000000..436403abd3de --- /dev/null +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx @@ -0,0 +1,22 @@ +import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent'; + +/** + * Recognizes the `intent=submit` onboarding deeplink and hands off to the component that acts on it. + */ +function SubmitIntentDeeplinkHandler() { + const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); + + if (onboardingDeeplinkIntent !== CONST.ONBOARDING_INTENTS.SUBMIT) { + return null; + } + + return ; +} + +export default SubmitIntentDeeplinkHandler; diff --git a/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts b/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts index 7a6cdde709a5..81f2138ff01d 100644 --- a/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts +++ b/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts @@ -38,6 +38,15 @@ let hasLoadedApp = false; let hasRedirectedToSubmitPlanModal = false; let isEvaluationScheduled = false; +// An `intent=submit` deeplink delivers the same outcome as this modal without asking, so the modal must not open on +// top of it. Recorded outside Onyx because the deeplink can only mark the modal shown from a React effect, which runs +// after the microtask that schedules the proactive redirect below. +let hasPendingSubmitDeeplink = false; + +function suppressWelcomeModalForSubmitDeeplink() { + hasPendingSubmitDeeplink = true; +} + const SUBMIT_PLAN_WELCOME_ENTRY_SCREENS = new Set(DYNAMIC_ROUTES.SUBMIT_PLAN_WELCOME.entryScreens); /** @@ -73,6 +82,7 @@ function getSubmitPlanWelcomeModalRoute(basePath?: string): Route { function resetSessionFlag() { hasRedirectedToSubmitPlanModal = false; + hasPendingSubmitDeeplink = false; } /** @@ -114,6 +124,7 @@ function isPolicyCreationRestricted(): boolean { */ function navigateToSubmitPlanWelcomeModalIfReady() { if ( + hasPendingSubmitDeeplink || isSupportalSessionSelector(session) || !session?.authToken || isLoadingApp || @@ -285,7 +296,7 @@ const SubmitPlanWelcomeModalGuard: NavigationGuard = { return {type: 'ALLOW'}; } - if (context.isSupportalSession || !shouldShowSubmitPlanWelcomeModal()) { + if (hasPendingSubmitDeeplink || context.isSupportalSession || !shouldShowSubmitPlanWelcomeModal()) { return {type: 'ALLOW'}; } @@ -299,4 +310,4 @@ const SubmitPlanWelcomeModalGuard: NavigationGuard = { }; export default SubmitPlanWelcomeModalGuard; -export {resetSessionFlag}; +export {resetSessionFlag, suppressWelcomeModalForSubmitDeeplink}; diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts new file mode 100644 index 000000000000..4f91414712d3 --- /dev/null +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -0,0 +1,38 @@ +/** + * Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`). + * + * It arrives either directly, or nested in the `exitTo` of a transition or magic link. Nesting it in `exitTo` is what + * carries the intent across the logged-out to logged-in transition, since the deeplink outlives the sign-in itself. + */ +import type {OnboardingIntent} from '@src/CONST'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import {getRouteFromLink} from './ReportUtils'; +import {getSearchParamFromPath} from './Url'; + +const ONBOARDING_INTENT_VALUES = new Set(Object.values(CONST.ONBOARDING_INTENTS)); + +function isOnboardingIntent(value: string | null): value is OnboardingIntent { + return !!value && ONBOARDING_INTENT_VALUES.has(value); +} + +function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined { + if (!url) { + return undefined; + } + + // getRouteFromLink strips whichever linking-config prefix matched, so web URLs, the desktop `app://-/` origin and + // the native scheme all reduce to the same route. It leaves the leading slash on in-app paths. + const pathWithQuery = getRouteFromLink(url).replace(/^\/+/, ''); + const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo'); + + if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) { + return undefined; + } + + const intent = getSearchParamFromPath(onboardingPathWithQuery, 'intent'); + return isOnboardingIntent(intent) ? intent : undefined; +} + +export default getOnboardingIntentFromUrl; diff --git a/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts b/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts index 435201e2483a..15ccc03e97df 100644 --- a/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts +++ b/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts @@ -1,4 +1,4 @@ -import SubmitPlanWelcomeModalGuard, {resetSessionFlag} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; +import SubmitPlanWelcomeModalGuard, {resetSessionFlag, suppressWelcomeModalForSubmitDeeplink} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; import type {GuardContext} from '@libs/Navigation/guards/types'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; // eslint-disable-next-line no-restricted-imports -- type-only namespace import used solely to type jest.requireActual for the module mock below @@ -160,6 +160,14 @@ describe('SubmitPlanWelcomeModalGuard', () => { expect(secondResult.type).toBe('ALLOW'); }); + it('should allow when an intent=submit deeplink is creating the workspace already', async () => { + await setUpEligibleUser(); + suppressWelcomeModalForSubmitDeeplink(); + + const result = SubmitPlanWelcomeModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('ALLOW'); + }); + it('should allow when already on the submit plan welcome modal screen', async () => { await setUpEligibleUser(); @@ -192,6 +200,16 @@ describe('SubmitPlanWelcomeModalGuard', () => { expect(mockNavigate).toHaveBeenCalledWith(submitPlanWelcomeRoute); }); + it('should not navigate when an intent=submit deeplink is creating the workspace already', async () => { + await setUpEligibleUser(); + suppressWelcomeModalForSubmitDeeplink(); + mockNavigate.mockClear(); + + await markSessionReady({authToken: 'test-token', accountID: 123}); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it('should not navigate when there is no session', async () => { await setUpEligibleUser(); mockNavigate.mockClear(); diff --git a/tests/unit/getOnboardingIntentFromUrlTest.ts b/tests/unit/getOnboardingIntentFromUrlTest.ts new file mode 100644 index 000000000000..213a70c0137d --- /dev/null +++ b/tests/unit/getOnboardingIntentFromUrlTest.ts @@ -0,0 +1,66 @@ +import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; + +import CONST from '@src/CONST'; + +describe('getOnboardingIntentFromUrl', () => { + const encodedOnboardingRoute = encodeURIComponent('onboarding?intent=submit'); + + it('reads the intent from a direct onboarding link', () => { + expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from a custom scheme link, where the route sits where a host would', () => { + expect(getOnboardingIntentFromUrl('new-expensify://onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from a custom scheme link with a placeholder host', () => { + expect(getOnboardingIntentFromUrl('app://-/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of a custom scheme magic link', () => { + expect(getOnboardingIntentFromUrl(`new-expensify://v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from a link served on a port, as the dev server does', () => { + expect(getOnboardingIntentFromUrl('https://dev.new.expensify.com:8082/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from an in-app path without an origin', () => { + expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + // HybridApp hands NewDot the deeplink as a bare path rather than a full URL, which is the shape the emailed + // magic link arrives in on mobile. + it('reads the intent from the exitTo of a magic link passed as a bare path', () => { + expect(getOnboardingIntentFromUrl(`v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of an OldDot transition link', () => { + const url = `https://new.expensify.com/transition?email=${encodeURIComponent('me@example.com')}&shortLivedAuthToken=abc123&exitTo=${encodedOnboardingRoute}`; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of a magic link', () => { + const url = `https://new.expensify.com/v/12345/678910?exitTo=${encodedOnboardingRoute}`; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from an unencoded exitTo, which OldDot mobile does not encode', () => { + const url = 'https://new.expensify.com/transition?shortLivedAuthToken=abc123&exitTo=onboarding?intent=submit'; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it.each([ + ['no url', undefined], + ['an empty url', ''], + ['an onboarding link without an intent', 'https://new.expensify.com/onboarding'], + ['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'], + ['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'], + ['an intent on a non-onboarding exitTo', `https://new.expensify.com/transition?exitTo=${encodeURIComponent('workspace/new?intent=submit')}`], + ])('returns undefined for %s', (_description, url) => { + expect(getOnboardingIntentFromUrl(url)).toBeUndefined(); + }); +});