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
1 change: 1 addition & 0 deletions src/CONST/runtimeConfigured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const CONST_RUNTIME: ConstRuntime = {
SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
SCREENS.PRE_MOUNT_BUFFER,
SCREENS.SHARE.ROOT,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
],
};
Expand Down
1 change: 1 addition & 0 deletions src/CONST/runtimeDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = {
'Money_Request_Step_Scan',
'Members_Move_To_Group',
'PreMountBuffer',
'Share_Root',
'Multifactor_Authentication_Validate_Code',
'Multifactor_Authentication_Outcome_Success',
'Multifactor_Authentication_Outcome_Failure',
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/useOnboardingFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {useInitialURLState} from '@components/InitialURLContextProvider';

import AccountUtils from '@libs/AccountUtils';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import {holdPendingShareIntentForOnboarding, openPendingShareIntentAfterSignIn} from '@libs/Navigation/helpers/pendingShareIntent';
import Navigation from '@libs/Navigation/Navigation';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
import {isLoggingInAsNewUser} from '@libs/SessionUtils';
Expand Down Expand Up @@ -31,6 +32,10 @@ import useShouldSuppressPromotionalUI from './useShouldSuppressPromotionalUI';
function useOnboardingFlowRouter() {
const currentUrl = getCurrentUrl();
const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP);
// IS_LOADING_APP survives sign-out, so right after sign-in it can still read `false` while the cleared onboarding NVP
// reads as completed. HAS_LOADED_APP is cleared on sign-out and set once OpenApp lands, so it tells us this session's
// onboarding status is known.
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP);
const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI();
const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING);
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
Expand Down Expand Up @@ -104,16 +109,28 @@ function useOnboardingFlowRouter() {
}
}

// Opens a share sent before sign-in for a user who won't onboard. Until OpenApp lands, a new user's
// onboarding NVP is still empty and reads as completed, and opening the share then would flash it and
// use it up before onboarding starts.
const openPendingShareWhenSkippingOnboarding = () => {
if (!hasLoadedApp) {
return;
}
openPendingShareIntentAfterSignIn();
};

const isMigratedUser = hasBeenAddedToNudgeMigration ?? false;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const isInvitedOrGroupMember = (hasNonPersonalPolicy || wasInvitedToNewDot) ?? false;
if (isMigratedUser || isInvitedOrGroupMember || shouldSuppressPromotionalUI) {
openPendingShareWhenSkippingOnboarding();
return;
}

// Test builds skip the onboarding UI entirely; the flag is absent from production env files.
// Gate only the auto-entry into onboarding here so unrelated behaviour (e.g. hybrid-app transitions above) is unaffected.
if (CONFIG.SKIP_ONBOARDING) {
openPendingShareWhenSkippingOnboarding();
return;
}

Expand All @@ -133,10 +150,15 @@ function useOnboardingFlowRouter() {
// navigate goes through the router where OnboardingGuard would block the navigation.
// isNavigationReady ensures navigation is ready, which is critical during fresh login.
if (isOnboardingCompleted === false) {
// A share sent before sign-in waits for onboarding to finish; navigateAfterOnboarding opens it.
holdPendingShareIntentForOnboarding();
Navigation.isNavigationReady().then(() => {
startOnboardingFlow(buildOnboardingFlowParams(account, onboardingValues, onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath));
});
return;
}

openPendingShareWhenSkippingOnboarding();
},
});

Expand All @@ -145,6 +167,7 @@ function useOnboardingFlowRouter() {
};
}, [
isLoadingApp,
hasLoadedApp,
isHybridAppOnboardingCompleted,
isOnboardingCompletedMetadata,
tryNewDotMetadata,
Expand Down
65 changes: 65 additions & 0 deletions src/libs/Navigation/helpers/pendingShareIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Navigation from '@libs/Navigation/Navigation';

import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';

import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import Onyx from 'react-native-onyx';

// A share received while signed out is kept out of navigation (see linkingConfig.filter) and parked here instead.
// It opens right after sign-in when the user doesn't have to onboard, or once onboarding finishes when they do.
type PendingShareIntent = 'none' | 'afterSignIn' | 'afterOnboarding';

let pendingShareIntent: PendingShareIntent = 'none';

function setPendingShareIntent() {
pendingShareIntent = 'afterSignIn';
}

/**
* Keeps a parked share closed until onboarding finishes, because the user is about to start onboarding.
*/
function holdPendingShareIntentForOnboarding() {
if (pendingShareIntent !== 'afterSignIn') {
return;
}
pendingShareIntent = 'afterOnboarding';
}

/**
* Opens a share parked at sign-in, for a user who doesn't have to onboard. A share held for onboarding stays parked.
*/
function openPendingShareIntentAfterSignIn() {
if (pendingShareIntent !== 'afterSignIn') {
return;
}
pendingShareIntent = 'none';
Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SHARE_ROOT));
}

/**
* Opens a parked share once onboarding finishes. Returns whether a share was parked, so the caller can skip its usual
* post-onboarding destination.
*/
function openPendingShareIntentAfterOnboarding(afterTransition?: () => void): boolean {
if (pendingShareIntent === 'none') {
return false;
}
pendingShareIntent = 'none';

// OnboardingGuard sends every navigation back to onboarding until it sees the completed onboarding NVP, and its
// redirect drops the share modal. The guard reads the NVP through its own connection, so we wait for the same signal.
const connection = Onyx.connectWithoutView({
key: ONYXKEYS.NVP_ONBOARDING,
callback: (onboarding) => {
if (!onboarding || !hasCompletedGuidedSetupFlowSelector(onboarding)) {
return;
}
Onyx.disconnect(connection);
Navigation.navigate(ROUTES.SHARE_ROOT, afterTransition ? {afterTransition} : undefined);
},
});
return true;
}

export {setPendingShareIntent, holdPendingShareIntentForOnboarding, openPendingShareIntentAfterSignIn, openPendingShareIntentAfterOnboarding};
6 changes: 5 additions & 1 deletion src/libs/Navigation/linkingConfig/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {hasAuthToken} from '@libs/actions/Session';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
import type {RootNavigatorParamList} from '@libs/Navigation/types';
Expand All @@ -6,6 +7,7 @@ import type {LinkingOptions} from '@react-navigation/native';

import {config} from './config';
import isNativeOAuthCallbackURL from './isNativeOAuthCallbackURL';
import isShareRootURL from './isShareRootURL';
import prefixes from './prefixes';
import subscribe from './subscribe';

Expand All @@ -20,7 +22,9 @@ const linkingConfig: LinkingOptions<RootNavigatorParamList> = {
// initial URL and later `url` events. The native OAuth callback is consumed by the auth session that opened
// it, so routing it would only land on NotFound and tear down the returning screen. The signed-out path goes
// through openReportFromDeepLink instead, which has its own guard.
filter: (url) => !isNativeOAuthCallbackURL(url),
// A share received while signed out is parked by openReportFromDeepLink instead. If react-navigation kept it, the
// share page would flash when the user signs in, before a new user is sent to onboarding.
filter: (url) => !isNativeOAuthCallbackURL(url) && !(isShareRootURL(url) && !hasAuthToken()),
};

// eslint-disable-next-line import/prefer-default-export
Expand Down
18 changes: 18 additions & 0 deletions src/libs/Navigation/linkingConfig/isShareRootURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import ROUTES from '@src/ROUTES';

import prefixes from './prefixes';

/**
* Whether the URL opens the share flow. Android launches `new-expensify://share/root` after a file is shared to the app.
*/
function isShareRootURL(url: string): boolean {
const prefix = prefixes.find((linkPrefix) => url.startsWith(linkPrefix));
if (!prefix) {
return false;
}

const path = url.slice(prefix.length).split(/[?#]/).at(0) ?? '';
return path.replaceAll(/^\/+|\/+$/g, '') === ROUTES.SHARE_ROOT;
}

export default isShareRootURL;
10 changes: 10 additions & 0 deletions src/libs/actions/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Log from '@libs/Log';
import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath';
import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName';
import normalizePath from '@libs/Navigation/helpers/normalizePath';
import {setPendingShareIntent} from '@libs/Navigation/helpers/pendingShareIntent';
import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom';
import swapBackgroundTabForRHPTarget from '@libs/Navigation/helpers/swapBackgroundTabForRHPTarget';
import willRouteNavigateToRHP from '@libs/Navigation/helpers/willRouteNavigateToRHP';
Expand Down Expand Up @@ -502,8 +503,17 @@ function openReportFromDeepLink(
return;
}

const isShareIntentRoute = normalizePath(route) === normalizePath(ROUTES.SHARE_ROOT);

// Navigate to the report after sign-in/sign-up.
waitForUserSignIn().then(() => {
// A share received while signed out never reached react-navigation (see linkingConfig.filter). Park it, so it
// opens once we know whether the user has to onboard first. Navigating now would flash the share page.
if (isShareIntentRoute && !isAuthenticated) {
setPendingShareIntent();
return;
}

// A Submit-via-PDF secure access link must reach the report regardless of onboarding status: the report screen
// is where JoinReportViaSecureLink runs, and onboarding is suppressed for secure-link visitors. The generic
// handling below intentionally drops deep links for users who still need to onboard, so branch out first.
Expand Down
10 changes: 10 additions & 0 deletions src/libs/navigateAfterOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SidePanelActions from './actions/SidePanel';
import {setOnboardingRHPVariant} from './actions/Welcome';
import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator';
import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils';
import {openPendingShareIntentAfterOnboarding} from './Navigation/helpers/pendingShareIntent';
import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom';
import Navigation from './Navigation/Navigation';
import {findLastAccessedReport} from './ReportUtils';
Expand Down Expand Up @@ -86,6 +87,11 @@ function navigateAfterOnboarding(
) {
setDisableDismissOnEscape(false);

// A share started before sign-in was parked while the user onboarded, so it replaces the usual destination.
if (openPendingShareIntentAfterOnboarding(options?.afterTransition)) {
return;
}

// On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant
// should navigate directly to the Concierge DM (which contains onboarding tasks).
// This check is outside shouldOpenRHPVariant because that function returns false on native
Expand Down Expand Up @@ -162,6 +168,10 @@ function navigateAfterOnboardingWithMicrotaskQueue(
function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) {
setDisableDismissOnEscape(false);

if (openPendingShareIntentAfterOnboarding()) {
return;
}

if (!policyID) {
Navigation.navigate(ROUTES.HOME);
return;
Expand Down
14 changes: 13 additions & 1 deletion src/pages/Share/ShareRootPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,22 @@ function ShareRootPage() {

const handleProcessFiles = useCallback(() => {
ShareActionHandler.processFiles((processedFiles) => {
const tempFile = Array.isArray(processedFiles) ? processedFiles.at(0) : (JSON.parse(processedFiles) as ShareTempFile);
if (errorTitle) {
return;
}

let tempFile: ShareTempFile | undefined;
try {
tempFile = Array.isArray(processedFiles) ? processedFiles.at(0) : (JSON.parse(processedFiles) as ShareTempFile);
} catch (error) {
// The native module passes a plain string such as "No data found" when there is no shared file.
// Without this, the throw leaves the page on the loading skeleton forever.
Log.warn('[ShareRootPage] Failed to read the shared file', {error});
setErrorTitle(translate('attachmentPicker.attachmentError'));
setErrorMessage(translate('attachmentPicker.errorWhileSelectingAttachment'));
return;
}

if (!tempFile?.mimeType || !shareFileMimeTypes.includes(tempFile?.mimeType)) {
setErrorTitle(translate('attachmentPicker.wrongFileType'));
setErrorMessage(translate('attachmentPicker.notAllowedExtension'));
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/Navigation/linkingConfigFilterTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {hasAuthToken} from '@libs/actions/Session';
import {linkingConfig} from '@libs/Navigation/linkingConfig';

jest.mock('@libs/actions/Session', () => ({
hasAuthToken: jest.fn(),
}));

const mockedHasAuthToken = jest.mocked(hasAuthToken);
const SHARE_URL = 'new-expensify://share/root';

describe('linkingConfig.filter', () => {
it('drops a share link while signed out', () => {
// Given a signed-out user, so openReportFromDeepLink parks the share until we know whether they must onboard
mockedHasAuthToken.mockReturnValue(false);

// When Android hands the share link to react-navigation
// Then it is dropped, so the share page doesn't flash when the user signs in
expect(linkingConfig.filter?.(SHARE_URL)).toBe(false);
});

it('keeps a share link while signed in', () => {
// Given a signed-in user, whose share is handled by react-navigation as before
mockedHasAuthToken.mockReturnValue(true);

// When Android hands the share link to react-navigation
// Then it is kept, so the share page opens right away
expect(linkingConfig.filter?.(SHARE_URL)).toBe(true);
});

it('keeps other links while signed out', () => {
// Given a signed-out user
mockedHasAuthToken.mockReturnValue(false);

// When a link that is not the share flow arrives, including one that only starts with the share path
// Then it is kept, because only the share flow is parked
expect(linkingConfig.filter?.('new-expensify://settings/profile')).toBe(true);
expect(linkingConfig.filter?.('https://new.expensify.com/share/root/submit')).toBe(true);
});
});
54 changes: 54 additions & 0 deletions tests/unit/navigateAfterOnboardingTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding';
import {holdPendingShareIntentForOnboarding, openPendingShareIntentAfterSignIn, setPendingShareIntent} from '@libs/Navigation/helpers/pendingShareIntent';
import Navigation from '@libs/Navigation/Navigation';
import type * as ReportUtils from '@libs/ReportUtils';

Expand Down Expand Up @@ -191,6 +192,59 @@ describe('navigateAfterOnboarding', () => {
expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined);
});

it('should open a share that was parked during onboarding once the completed onboarding is saved, only once', async () => {
const navigate = jest.spyOn(Navigation, 'navigate');

// Given a user who shared a file while signed out, so the share was held until onboarding finished
setPendingShareIntent();
holdPendingShareIntentForOnboarding();
await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false});

// When onboarding finishes, before the completed onboarding NVP is saved
navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID);
await waitForBatchedUpdates();

// Then nothing opens yet, because OnboardingGuard would still send the share back to onboarding
expect(navigate).not.toHaveBeenCalled();

// When the completed onboarding NVP is saved
await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true});
await waitForBatchedUpdates();

// Then the share flow opens instead of the usual post-onboarding destination
expect(navigate).toHaveBeenCalledWith(ROUTES.SHARE_ROOT, undefined);
expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined);

// When it runs again, because the parked share must not reopen a second time
navigate.mockClear();
navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID);
await waitForBatchedUpdates();

// Then the usual destination is used
expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined);
expect(navigate).not.toHaveBeenCalledWith(ROUTES.SHARE_ROOT, undefined);
});

it('should not open a share held for onboarding from the sign-in path', async () => {
const navigate = jest.spyOn(Navigation, 'navigate');

// Given a share held for onboarding because the user started onboarding after sign-in
setPendingShareIntent();
holdPendingShareIntentForOnboarding();

// When the sign-in path tries to open it, as useOnboardingFlowRouter does once onboarding is complete
openPendingShareIntentAfterSignIn();
await waitForBatchedUpdates();

// Then it stays parked, so navigateAfterOnboarding still owns it and nothing opens a second share page
expect(navigate).not.toHaveBeenCalled();
await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true});
navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID);
await waitForBatchedUpdates();
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(ROUTES.SHARE_ROOT, undefined);
});

it('should land on Home instead of the admin room for the homePageNoRHP variant', () => {
// Given an admin whose #admins room exists, which the control and inboxAdminsBespoke arms open after onboarding
const navigate = jest.spyOn(Navigation, 'navigate');
Expand Down
Loading
Loading