diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts new file mode 100644 index 000000000000..52c611804c86 --- /dev/null +++ b/src/libs/PendingConciergeDeepLink.ts @@ -0,0 +1,49 @@ +/** + * Tracks whether a logged-out user opened a /concierge deep link so the app can + * route them to Concierge after sign-up/onboarding. sessionStorage keeps the + * tab-scoped intent across page reloads while sign-out/consume paths clear it. + */ +const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; +let hasPendingConciergeDeepLink = false; + +function getSessionStorage() { + try { + return typeof window === 'undefined' ? undefined : window.sessionStorage; + } catch { + return undefined; + } +} + +function hasStoredPendingConciergeDeepLink() { + try { + return getSessionStorage()?.getItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function setPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = true; + try { + getSessionStorage()?.setItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY, 'true'); + } catch { + // Ignore storage failures and keep the in-memory intent for the current page lifecycle. + } +} + +function consumePendingConciergeDeepLink() { + const shouldNavigateToConcierge = hasPendingConciergeDeepLink || hasStoredPendingConciergeDeepLink(); + clearPendingConciergeDeepLink(); + return shouldNavigateToConcierge; +} + +function clearPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = false; + try { + getSessionStorage()?.removeItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + } catch { + // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. + } +} + +export {setPendingConciergeDeepLink, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index a995f6d2ce24..cb86eb613d09 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -451,6 +452,12 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } +function isConciergeRoute(route: string) { + const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); + const normalizedRoute = routeWithoutParams.replace(/\/$/, ''); + return normalizedRoute === normalizePath(ROUTES.CONCIERGE); +} + function openReportFromDeepLink( url: string, reports: OnyxCollection, @@ -496,6 +503,14 @@ function openReportFromDeepLink( route = ''; } + if (!isAuthenticated) { + if (isConciergeRoute(route)) { + setPendingConciergeDeepLink(); + } else if (!isPublicScreenRoute(route)) { + clearPendingConciergeDeepLink(); + } + } + // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { return; diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 874c2a083355..2ec1643244bc 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -1,6 +1,7 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; +import {clearPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -47,6 +48,8 @@ Onyx.connectWithoutView({ }); function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise { + clearPendingConciergeDeepLink(); + // Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out. // We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting // flashes of unwanted default state. diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index c88021706a80..1cbef294b5b8 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -16,6 +16,7 @@ import {setOnboardingRHPVariant} from './actions/Welcome'; import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; +import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; let onboardingRHPVariant: OnyxEntry; @@ -100,6 +101,8 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else if (!isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index cb3769c5dfa9..1fd31a3a3362 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,5 +1,7 @@ import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; +import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -88,6 +90,7 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); + clearPendingConciergeDeepLink(); mockIsReportTopmostSplitNavigator.mockReturnValue(false); return Onyx.clear(); }); @@ -191,4 +194,47 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); }); + + it('should navigate to Concierge instead of Home when a pending Concierge deep link is available', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should navigate to Concierge route when pending deep link is set but conciergeReportID is empty', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.CONCIERGE); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should consume the pending Concierge deep link after onboarding navigation', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); + }); + + it('should preserve the pending Concierge deep link across a module reload', () => { + setPendingConciergeDeepLink(); + + jest.isolateModules(() => { + const {consumePendingConciergeDeepLink: consumePendingConciergeDeepLinkAfterReload} = jest.requireActual('@libs/PendingConciergeDeepLink'); + expect(consumePendingConciergeDeepLinkAfterReload()).toBe(true); + }); + + expect(window.sessionStorage.getItem('PENDING_CONCIERGE_DEEP_LINK')).toBeNull(); + clearPendingConciergeDeepLink(); + }); });