diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e1b860e3c4d7..562d2a24b8bd 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1560,6 +1560,7 @@ const CONST = { MERGE: 'merge', DUPLICATE: 'duplicate', MOVE_EXPENSE: 'moveExpense', + SEND_TO_SOMEONE: 'sendToSomeone', }, SELECTED_TRANSACTIONS_BULK_ACTION_TYPES: { HOLD: 'hold', diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 37d6b8009728..7f2b02bff6ff 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -1,3 +1,4 @@ +import useActivePolicy from '@hooks/useActivePolicy'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -18,6 +19,7 @@ import useParticipantsPolicyTags from '@hooks/useParticipantsPolicyTags'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; @@ -41,11 +43,12 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {sortAndSectionPopoverMenuItems, TRANSACTION_MORE_MENU_SECTIONS} from '@libs/PopoverMenuSections'; -import {getOriginalMessage, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, getTrackExpenseActionableWhisper, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {getTransactionThreadPrimaryAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryTransactionThreadActions} from '@libs/ReportSecondaryActionUtils'; import { changeMoneyRequestHoldStatus, + createDraftTransactionAndNavigateToParticipantSelector, generateReportID, getPolicyExpenseChat, isCurrentUserSubmitter, @@ -82,8 +85,9 @@ import {useRoute} from '@react-navigation/native'; import {shouldFailAllRequestsSelector} from '@selectors/Network'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {personalDetailsLoginSelector} from '@selectors/PersonalDetails'; +import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import React, {useRef, useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; @@ -127,6 +131,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money 'DocumentMerge', 'ExpenseCopy', 'Info', + 'Send', 'Stopwatch', 'ThumbsDown', 'Trashcan', @@ -179,6 +184,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); + const activePolicy = useActivePolicy(); + const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); + const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserLogin), [currentUserLogin]); + const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const draftTransactionIDs = useMemo(() => Object.keys(transactionDrafts ?? {}), [transactionDrafts]); + // Custom hooks const defaultExpensePolicy = useDefaultExpensePolicy(); const [defaultPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(defaultExpensePolicy?.id)}`); @@ -190,6 +201,9 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const isParentReportArchived = useReportIsArchived(report?.parentReportID); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserLogin, isSubmit2026BetaEnabled), [currentUserLogin, isSubmit2026BetaEnabled]); + const [hasWorkspaceToSubmitTo = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {showConfirmModal} = useConfirmModal(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -332,6 +346,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money isChatReportArchived: isChatIOUReportArchived, grandParentReport, isProduction, + hasWorkspaceToSubmitTo, }); })(); @@ -605,6 +620,38 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money ); }, }, + [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE]: { + text: translate('iou.sendToSomeone'), + icon: expensifyIcons.Send, + value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE, + onSelected: () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: parentReport?.reportID, + actionName: CONST.IOU.ACTION.SUBMIT, + reportActionID: getTrackExpenseActionableWhisper(transaction?.transactionID, parentReport?.reportID, parentReportActions)?.reportActionID, + reportActions: parentReportActions, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + isRestrictedToPreferredPolicy, + preferredPolicyID, + transaction, + currentUserAccountID: accountID, + currentUserEmail: currentUserLogin ?? '', + currentUserLocalCurrency: localCurrencyCode ?? CONST.CURRENCY.USD, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + }); + }, + }, }; const applicableSecondaryActions = sortAndSectionPopoverMenuItems( diff --git a/src/languages/de.ts b/src/languages/de.ts index 671463f45aff..cf8a7b821723 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1546,6 +1546,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `hat mit dem Ausgleich begonnen. Die Zahlung ist ausgesetzt, bis ${submitterDisplayName} das Wallet aktiviert.`, enableWallet: 'Wallet aktivieren', hold: 'Warteschleife', + sendToSomeone: 'An jemanden senden', unhold: 'Zurückhalten aufheben', holdExpense: () => ({ one: 'Ausgabe zurückhalten', diff --git a/src/languages/el.ts b/src/languages/el.ts index b6dcc8eddc55..2bfdef639c3b 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1605,6 +1605,7 @@ const translations: TranslationDeepObject = { `ξεκίνησε τη διαδικασία τακτοποίησης. Η πληρωμή έχει τεθεί σε αναμονή μέχρι ο/η ${submitterDisplayName} να ενεργοποιήσει το πορτοφόλι του/της.`, enableWallet: 'Ενεργοποίηση πορτοφολιού', hold: 'Σε αναμονή', + sendToSomeone: 'Αποστολή σε κάποιον', unhold: 'Αφαίρεση κράτησης', holdExpense: () => ({ one: 'Αναστολή δαπάνης', diff --git a/src/languages/en.ts b/src/languages/en.ts index f083414ef702..76d23200e60b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1626,6 +1626,7 @@ const translations = { waitingOnEnabledWallet: (submitterDisplayName: string) => `started settling up. Payment is on hold until ${submitterDisplayName} enables their wallet.`, enableWallet: 'Enable wallet', hold: 'Hold', + sendToSomeone: 'Send to someone', unhold: 'Remove hold', holdExpense: () => ({ one: 'Hold expense', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2edd8cb93b03..aab86e37c222 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1599,6 +1599,7 @@ const translations: TranslationDeepObject = { payOnly: 'Solo pagar', approveOnly: 'Solo aprobar', hold: 'Retener', + sendToSomeone: 'Enviar a alguien', unhold: 'Desbloquear', holdEducationalTitle: '¿Deberías retener este gasto?', whatIsHoldExplain: 'Retener es como presionar "pausa" en un gasto hasta que estés listo para enviarlo.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7468a28abfb3..dc5ec8306e20 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1551,6 +1551,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `a commencé à régler. Le paiement est en attente jusqu’à ce que ${submitterDisplayName} active son portefeuille.`, enableWallet: 'Activer le portefeuille', hold: 'En attente', + sendToSomeone: 'Envoyer à quelqu’un', unhold: 'Supprimer la mise en attente', holdExpense: () => ({ one: 'Mettre la dépense en attente', diff --git a/src/languages/it.ts b/src/languages/it.ts index a7b8b777aaff..0d2e192d102a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1545,6 +1545,7 @@ const translations: TranslationDeepObject = { `ha iniziato a saldare il conto. Il pagamento è in sospeso finché ${submitterDisplayName} non abilita il proprio portafoglio.`, enableWallet: 'Abilita portafoglio', hold: 'Metti in attesa', + sendToSomeone: 'Invia a qualcuno', unhold: 'Rimuovi blocco', holdExpense: () => ({ one: 'Metti in sospeso la spesa', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b8cb7a550c87..483e90c9a270 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1527,6 +1527,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `清算を開始しました。${submitterDisplayName} がウォレットを有効にするまで、支払いは保留されます。`, enableWallet: 'ウォレットを有効にする', hold: '保留', + sendToSomeone: '誰かに送る', unhold: '保留を解除', holdExpense: () => ({ one: '経費を保留', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8fd574249a88..51b013ebcd08 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1541,6 +1541,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `is begonnen met afrekenen. Betaling wordt vastgehouden totdat ${submitterDisplayName} hun wallet inschakelt.`, enableWallet: 'Portemonnee inschakelen', hold: 'Vasthouden', + sendToSomeone: 'Naar iemand sturen', unhold: 'Blokkering opheffen', holdExpense: () => ({ one: 'Uitgave vasthouden', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0c428e2f01bd..ba6445d4f577 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1537,6 +1537,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `rozpoczął rozliczanie. Płatność jest wstrzymana, dopóki ${submitterDisplayName} nie włączy swojego portfela.`, enableWallet: 'Włącz portfel', hold: 'Wstrzymaj', + sendToSomeone: 'Wyślij do kogoś', unhold: 'Usuń blokadę', holdExpense: () => ({ one: 'Wstrzymaj wydatek', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 500f04df05c6..09aca88c7cc2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1540,6 +1540,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `começou a acertar as contas. O pagamento está em espera até ${submitterDisplayName} ativar a carteira.`, enableWallet: 'Ativar carteira', hold: 'Reter', + sendToSomeone: 'Enviar para alguém', unhold: 'Remover bloqueio', holdExpense: () => ({ one: 'Reter despesa', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f98945ab108d..8d2a3469ed53 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1487,6 +1487,7 @@ const translations: TranslationDeepObject = { waitingOnEnabledWallet: (submitterDisplayName: string) => `已开始结算。在 ${submitterDisplayName} 启用其钱包之前,付款将被暂挂。`, enableWallet: '启用钱包', hold: '暂挂', + sendToSomeone: '发送给某人', unhold: '解除保留', holdExpense: () => ({ one: '暂挂报销', diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index ea382b12590b..4bfc4fa0b781 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -37,6 +37,7 @@ import { getExistingTransactionID, isMovingTransactionFromTrackExpense, isParticipantP2P, + isSelfDMSoleDestination, navigateToConfirmationPage, navigateToParticipantPage, resolveOptimisticChatReportID, @@ -338,6 +339,9 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount const optimisticTransactionID = rand64(); const {optimisticChatReportID} = resolveOptimisticChatReportID([participant?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountID], report); const isTrackExpenseSubmit = iouType === CONST.IOU.TYPE.TRACK; + // Whether this expense's sole destination is the current user's self-DM. Scopes the LOOKING_AROUND + // "route to Spend > Expenses" behaviour to the self-DM case (matches the confirmation step). + const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserAccountID); const draftTransactionIDsList = Object.keys(transactionDrafts ?? {}); const isSelfTourViewed = hasSeenTourSelector(onboarding) ?? false; const executeExpenseWrite = (overrides: WriteOverrides) => { @@ -425,11 +429,16 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount backToReport, optimisticChatReportID, linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + isLookingAroundUser: introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, + isSelfDMDestination, }); }; submitWithDismissFirst({ executeWrite: executeExpenseWrite, destinationReportID: isTrackExpenseSubmit ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + isLookingAroundUser: introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, + isSelfDMDestination, telemetryContext: { scenario: isTrackExpenseSubmit ? CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.TRACK_EXPENSE : CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.REQUEST_MONEY_MANUAL, iouType, diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index d8db226cca7c..422fe99b3e2d 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -21,6 +21,18 @@ type CleanupAndNavigateAfterExpenseCreateParams = { isInvoice?: boolean; linkedTrackedExpenseReportAction?: OnyxEntry; action: DeepValueOf; + + /** + * Whether the current user selected the "Looking around / Something else" (LOOKING_AROUND) onboarding choice. + * Read from Onyx in render context by the calling component/hook and forwarded to `navigateAfterExpenseCreate`. + */ + isLookingAroundUser?: boolean; + + /** + * Whether the sole destination for this expense is the current user's self-DM (Personal Space). Forwarded to + * `navigateAfterExpenseCreate` so the LOOKING_AROUND "route to Spend > Expenses" behaviour only fires for the self-DM case. + */ + isSelfDMDestination?: boolean; /** When false, runs cleanup only — use when dismiss/reveal already handled navigation. * IMPORTANT: Caller must own telemetry span lifecycle. SubmitExpenseOrchestrator starts * SPAN_SUBMIT_EXPENSE before calling createTransaction; when shouldNavigate=false, caller @@ -45,6 +57,8 @@ function cleanupAndNavigateAfterExpenseCreate({ isInvoice, linkedTrackedExpenseReportAction, action, + isLookingAroundUser, + isSelfDMDestination, shouldNavigate = true, navigationReportID, }: CleanupAndNavigateAfterExpenseCreateParams) { @@ -70,6 +84,8 @@ function cleanupAndNavigateAfterExpenseCreate({ isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, + isLookingAroundUser, + isSelfDMDestination, shouldNavigate, }); } diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 9cea646bc6c1..05b151c1b967 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -23,6 +23,21 @@ type NavigateAfterExpenseCreateParams = { hasMultipleTransactions: boolean; shouldAddPendingNewTransactionIDs?: boolean; shouldNavigate?: boolean; + + /** + * Whether the current user selected the "Looking around / Something else" (LOOKING_AROUND) onboarding choice. + * The caller reads the onboarding choice from Onyx in render context (via `useOnyx`) and passes the result in, + * so this helper stays a pure function instead of subscribing to Onyx itself. + */ + isLookingAroundUser?: boolean; + + /** + * Whether the sole destination for this expense is the current user's self-DM (Personal Space). The LOOKING_AROUND + * "route to Spend > Expenses" behaviour is scoped to this so it only applies when the expense actually lands in the + * self-DM. A LOOKING_AROUND user who later has a workspace and submits to a real report/friend keeps their normal + * destination instead of being permanently sent to Search by mistake. + */ + isSelfDMDestination?: boolean; }; function getNavigateAfterCreateSearchNavigatorState() { @@ -48,8 +63,15 @@ function navigateAfterExpenseCreate({ hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, shouldNavigate = true, + isLookingAroundUser = false, + isSelfDMDestination = false, }: NavigateAfterExpenseCreateParams) { - const isUserOnInbox = isReportTopmostSplitNavigator(); + // "Looking around / Something else" (LOOKING_AROUND) users have no workspace, so after they create an expense + // from the Inbox (HOME) that lands in their self-DM we want to drop them into Spend > Expenses rather than that + // self-DM (Personal Space). Treating them as "not on inbox" lets them fall through to the Search navigation below. + // Scoped to isSelfDMDestination so a LOOKING_AROUND user who later has a workspace and submits to a real + // report/friend still opens that report instead of being permanently sent to Search by mistake. + const isUserOnInbox = isReportTopmostSplitNavigator() && !(isLookingAroundUser && isSelfDMDestination); // If the expense is not created from global create or is currently on the inbox tab, // we just need to dismiss the money request flow screens @@ -92,7 +114,11 @@ function navigateAfterExpenseCreate({ } else if (getIsNarrowLayout()) { const isRHPStillOnTop = navigationRef.getRootState()?.routes?.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; if (!alreadyOnSearchRoot || !isSameSearchType || isRHPStillOnTop) { - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); + // forceReplace makes linkTo dispatch a REPLACE against TAB_NAVIGATOR, but SEARCH.ROOT is in linkTo's + // ROOT_TAB_SCREENS, so the cross-tab PUSH branch is skipped and the REPLACE resolves to a no-op, leaving + // the user on the tab they submitted from. Skipped only for the LOOKING_AROUND self-DM flow so every + // other caller keeps its existing history behaviour. + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: !(isLookingAroundUser && isSelfDMDestination)}); } else { Log.info('[IOU] navigateToSearch: already on matching Search root with RHP dismissed - no-op'); } diff --git a/src/libs/Navigation/helpers/submitWithDismissFirst.ts b/src/libs/Navigation/helpers/submitWithDismissFirst.ts index de854b12f26e..4ed2455cc950 100644 --- a/src/libs/Navigation/helpers/submitWithDismissFirst.ts +++ b/src/libs/Navigation/helpers/submitWithDismissFirst.ts @@ -24,6 +24,12 @@ type SubmitWithDismissFirstParams = { destinationReportID: string | undefined; /** Telemetry metadata for the submit-expense performance span. */ telemetryContext: SubmitExpenseContext; + /** Whether the expense was initiated from the global FAB (no pre-existing report context). */ + isFromGlobalCreate?: boolean; + /** Whether the user onboarded as "Something else" (LOOKING_AROUND) - they have no workspace. */ + isLookingAroundUser?: boolean; + /** Whether the sole destination for this expense is the current user's self-DM (Personal Space). */ + isSelfDMDestination?: boolean; }; function startDismissFirstTracking( @@ -47,14 +53,22 @@ function startDismissFirstTracking( * * 1. Search topmost -> dismiss modal, defer write for Search skeleton * 2. Route pre-inserted -> dismiss modal, write after transition (route already staged) - * 3. Destination already shown -> dismiss modal, write after transition - * 4. Destination loaded -> reveal destination then dismiss, write after transition - * 5. Destination not loaded -> write immediately, then reveal-and-dismiss - * 6. Fallback -> start tracking with default fast path, write with defaults + * 3. Looking-Around self-DM -> write owns navigation (routes to Search via navigateAfterExpenseCreate) + * 4. Destination already shown -> dismiss modal, write after transition + * 5. Destination loaded -> reveal destination then dismiss, write after transition + * 6. Destination not loaded -> write immediately, then reveal-and-dismiss + * 7. Fallback -> start tracking with default fast path, write with defaults * * Must not be called from `src/libs/actions/` — view-layer only. */ -function submitWithDismissFirst({executeWrite, destinationReportID, telemetryContext}: SubmitWithDismissFirstParams): void { +function submitWithDismissFirst({ + executeWrite, + destinationReportID, + telemetryContext, + isFromGlobalCreate = false, + isLookingAroundUser = false, + isSelfDMDestination = false, +}: SubmitWithDismissFirstParams): void { const shouldStayOnSearch = isSearchTopmostFullScreenRoute(); if (shouldStayOnSearch) { @@ -75,6 +89,18 @@ function submitWithDismissFirst({executeWrite, destinationReportID, telemetryCon return; } + // "Something else" (LOOKING_AROUND) users have no workspace, so a global-create expense lands in their self-DM, but + // the product requirement is to route them to Spend > Expenses instead. Every dismiss-first branch below calls + // executeWrite with shouldHandleNavigation: false, and cleanupAfterSkipConfirmSubmit drops the routing flags in that + // case - so navigateAfterExpenseCreate never runs and the destinationReportID branch reveals the self-DM. Hand + // navigation back to the write, same as the fallback below, so navigateAfterExpenseCreate owns the Search routing. + if (isFromGlobalCreate && isLookingAroundUser && isSelfDMDestination) { + startTracking(telemetryContext, {skipSubmitExpenseSpan: true}); + setFastPath(CONST.TELEMETRY.FAST_PATH_HANDLER.DEFAULT); + executeWrite({shouldHandleNavigation: true}); + return; + } + if (destinationReportID) { const isDestinationLoaded = !!getReportOrDraftReport(destinationReportID)?.reportID; const isNarrow = getIsNarrowLayout(); diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 475c0f0442e4..f71136d1efec 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -28,6 +28,8 @@ const REPORT_MORE_MENU_SECTIONS = [ ]; const TRANSACTION_MORE_MENU_SECTIONS = [ + // "Send to someone" sits in its own top section so it renders first with a divider beneath it. + [TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE], [ TRANSACTION_SECONDARY_ACTIONS.HOLD, TRANSACTION_SECONDARY_ACTIONS.REMOVE_HOLD, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index c95e71108a03..7788ea84a032 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -81,6 +81,7 @@ import { isReportManager as isReportManagerUtils, isSelfDM as isSelfDMReportUtils, isSettled, + isTrackExpenseReportNew, isWorkspaceEligibleForReportChange, } from './ReportUtils'; import { @@ -1167,6 +1168,7 @@ function getSecondaryTransactionThreadActions({ isChatReportArchived, grandParentReport, isProduction, + hasWorkspaceToSubmitTo = false, }: { currentUserLogin: string; currentUserAccountID: number; @@ -1181,6 +1183,8 @@ function getSecondaryTransactionThreadActions({ isChatReportArchived: boolean; grandParentReport?: OnyxEntry; isProduction: boolean; + /** Whether the user belongs to a workspace they can submit an expense to (self-DM split expenses can only be submitted to a workspace). */ + hasWorkspaceToSubmitTo?: boolean; }): Array> { const options: Array> = []; @@ -1227,6 +1231,23 @@ function getSecondaryTransactionThreadActions({ options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MOVE_EXPENSE); } + // Show "Send to someone" only for an unreported self-tracked expense in personal space (a track expense not yet + // submitted to a workspace/report), where the same convert-from-track "Choose a recipient" flow from the + // track-expense whisper applies. Once submitted, parentReport is no longer a self-DM and reportAction is no longer + // a track action, so this correctly hides. + // Self-DM split expenses can only be submitted to a workspace, so hide the action for a split unless the user has a + // workspace to submit to - matching the track-expense whisper and the report-details menu, which suppress it for the + // same unsupported case (otherwise the recipient picker opens onto a flow with no valid workspace destination). + // Also require write access (as MOVE_EXPENSE above does) so the row hides on an archived self-DM. + const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(reportTransaction, originalTransaction); + if ( + isTrackExpenseReportNew(transactionThreadReport, parentReport, reportAction) && + (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) && + canUserPerformWriteActionReportUtils(parentReport, isChatReportArchived) + ) { + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE); + } + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, reportAction ? [reportAction] : [])) { diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 67156c392ea6..82aa05acc0e3 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -3,7 +3,6 @@ import {handleRHPVariantNavigation, shouldOpenRHPVariant} from '@components/Side import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; import type {OnboardingRHPVariant, ReportNameValuePairs} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -18,6 +17,7 @@ import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingN import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; +import {buildCannedSearchQuery} from './SearchQueryUtils'; let onboardingRHPVariant: OnyxEntry; Onyx.connectWithoutView({ @@ -140,9 +140,8 @@ function navigateAfterOnboardingWithMicrotaskQueue( } /** - * After creating or joining a Submit workspace during onboarding, - * navigate to Workspace > Categories with the side panel open so - * the #admins room is visible in Concierge Anywhere. + * After creating or joining a Submit workspace during onboarding, navigate to Spend > Expenses + * with the side panel open so the #admins room is visible in Concierge Anywhere. */ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); @@ -153,11 +152,7 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa } setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - - const categoriesRoute = ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID); - const backToRoute = shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACES_LIST.route; - Navigation.navigate(`${categoriesRoute}?backTo=${encodeURIComponent(backToRoute)}` as Route); - + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})})); SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index f032f040ba9b..7ef1bd196dc8 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -151,6 +151,7 @@ function SubmitDetailsPage({ const fileType = shouldUsePreValidatedFile ? (validFilesToUpload?.type ?? CONST.RECEIPT_ALLOWED_FILE_TYPES.JPEG) : (currentAttachment?.mimeType ?? ''); const [hasOnlyPersonalPolicies = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesUtil}); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); + const isLookingAroundUser = introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; const hasEndedOpenSubmitFlowSpan = useRef(false); const endOpenSubmitFlowSpan = () => { @@ -294,6 +295,7 @@ function SubmitDetailsPage({ iouType, isCreatingTrackExpense, isSelfDMDestination: isSelfDM(report), + isLookingAroundUser, }); const {reveal: revealPreMountDestination, cleanupPreMount} = usePreMountDestination(preMountDestinationRoute, { @@ -473,6 +475,8 @@ function SubmitDetailsPage({ optimisticChatReportID: routeReportID, navigationReportID: postSubmitNavigationReportID, linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination: isSelfDM(report), }; const runExpenseCreateAndCleanup = (shouldNavigate: boolean) => { diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 816495ee080e..3da031edd08f 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -16,12 +16,12 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getIsP2PForAmount, submitAmount} from '@libs/IOUAmountSubmission'; -import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; +import {isMovingTransactionFromTrackExpense, isSelfDMSoleDestination} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {getAmountHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; +import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; import {getRequestType, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; @@ -40,7 +40,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {isLookingAroundUserSelector, isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; @@ -95,6 +95,7 @@ function IOURequestStepAmount({ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [isLookingAroundUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isLookingAroundUserSelector}); const isEditing = action === CONST.IOU.ACTION.EDIT; @@ -158,7 +159,17 @@ function IOURequestStepAmount({ return !(isReportArchived || isPolicyExpenseChat(report)); }, [report, isSplitBill, skipConfirmation, isReportArchived]); - const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute(shouldSkipConfirmation, report?.reportID); + // Both self-DM signals are ORed on purpose. The navigate half (IOUAmountSubmission) reads participants at submit time, + // but on a quick-action flow they are not populated yet when this pre-mount decision runs, so the participants check + // alone misses and the self-DM gets pre-inserted - then navigateAfterExpenseCreate reveals it instead of going to Search. + // isSelfDM(report) answers the question this site actually cares about ("is the report I am about to pre-insert the + // self-DM?") and is available immediately. + const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute( + shouldSkipConfirmation, + report?.reportID, + isLookingAroundUser, + isSelfDM(report) || isSelfDMSoleDestination(transaction?.participants ?? [], iouType, currentUserPersonalDetails.accountID), + ); usePreMountDestination(skipConfirmationPreMountRoute); useFocusEffect( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f9c675ec70ae..8fa9d8383388 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -273,6 +273,10 @@ function IOURequestStepConfirmation({ const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + // "Something else" (LOOKING_AROUND) users have no workspace, so their global-create expense lands in their self-DM. + // We route them to Spend > Expenses (Search) after creating instead of dropping them into that self-DM report. + const isLookingAroundUser = introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; const privateIsArchivedMap = usePrivateIsArchivedMap(); const receiptFilename = transaction?.receipt?.filename; @@ -638,8 +642,9 @@ function IOURequestStepConfirmation({ iouType, isCreatingTrackExpense, isSelfDMDestination, + isLookingAroundUser, }), - [isTransactionReady, destinationReportID, destinationReport, isFromGlobalCreate, canPreInsertSearch, iouType, isCreatingTrackExpense, isSelfDMDestination], + [isTransactionReady, destinationReportID, destinationReport, isFromGlobalCreate, canPreInsertSearch, iouType, isCreatingTrackExpense, isSelfDMDestination, isLookingAroundUser], ); const {reveal: revealPreMountDestination, cleanupPreMount} = usePreMountDestination(preMountDestinationRoute, { @@ -962,6 +967,7 @@ function IOURequestStepConfirmation({ isFromGlobalCreate={isFromGlobalCreate} iouType={iouType} isSelfDMDestination={isSelfDMDestination} + isLookingAroundUser={isLookingAroundUser} requestType={requestType} canDismissFromSearch={canDismissFromSearch} gpsRequired={!!gpsRequired} diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index eb1f19141b4f..6ff5b44b2833 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -35,13 +35,13 @@ import {removeBackupTransaction} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {isSelfDMSoleDestination, shouldUseTransactionDraft} from '@libs/IOUUtils'; import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; -import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; +import {isPolicyExpenseChat as isPolicyExpenseChatUtil, isSelfDM} from '@libs/ReportUtils'; import {getDistanceInMeters, getRateID, getRequestType, getSelectedRouteKey, hasManualDistanceOverride, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -312,7 +312,19 @@ function IOURequestStepDistance({ return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); - const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute(shouldSkipConfirmation, report?.reportID); + // The LOOKING_AROUND + self-DM flags are computed inline rather than hoisted into named consts on purpose: this + // component is already at React Compiler's memoization-preservation limit, and adding another top-level reactive + // value tips it over so it can no longer preserve the manual memos below. Keep these inline. + const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute( + shouldSkipConfirmation, + report?.reportID, + introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND, + // Same self-DM predicate as the navigate half (handleMoneyRequestStepDistanceNavigation) so the guard suppresses in + // exactly the cases navigation forces Search. Kept inline to stay under this component's React Compiler memo limit. + // Both self-DM signals are ORed: on a quick-action flow participants are not populated yet when this runs, so the + // participants check alone misses and the self-DM gets pre-inserted, which navigateAfterExpenseCreate then reveals. + isSelfDM(report) || isSelfDMSoleDestination(transaction?.participants ?? [], iouType, currentUserPersonalDetails.accountID), + ); usePreMountDestination(skipConfirmationPreMountRoute); let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); @@ -702,7 +714,7 @@ function IOURequestStepDistance({ waypoints, ...(isRouteSelectionOnlyChange ? {} : {distance: distanceAsFloat}), ...(hasRouteChanged ? {routes: transaction?.routes} : {}), - // We need to pass selectedRouteKey to ensure that updating manual distance won't cause alternate route to be overriden with the primary one + // We need to pass selectedRouteKey to ensure that updating manual distance won't cause alternate route to be overridden with the primary one ...(wasOriginallyMapDistance ? {selectedRouteKey} : {}), transactionBackup, policy, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 2240d38d26a4..8dcf697f64be 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -6,7 +6,7 @@ import {setCustomUnitRateID, setMoneyRequestDistance, setMoneyRequestMerchant, s import {createDistanceRequest, resetSplitShares} from '@libs/actions/IOU/Split'; import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, getExistingTransactionID, isSelfDMSoleDestination, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; @@ -213,6 +213,11 @@ function handleMoneyRequestStepDistanceNavigation({ const isOdometerDistance = odometerDistance !== undefined; const isGPSDistance = gpsDistance !== undefined && gpsCoordinates !== undefined; const distanceRequestType = getDistanceRequestType(transaction); + // Derived here (rather than read from Onyx) from the onboarding choice the calling component/hook already passes in. + const isLookingAroundUser = introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; + // Whether this expense's sole destination is the current user's self-DM. Scopes the LOOKING_AROUND + // "route to Spend > Expenses" behaviour to the self-DM case (matches the confirmation step). + const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserAccountID); const selectedRouteDistance = getSelectedRouteDistance(transaction); if (transaction?.splitShares && !isManualDistance && !isOdometerDistance) { @@ -274,6 +279,9 @@ function handleMoneyRequestStepDistanceNavigation({ if (isCreatingTrackExpense && participant) { submitWithDismissFirst({ + isFromGlobalCreate: transactionIsFromGlobalCreate, + isLookingAroundUser, + isSelfDMDestination, // trackExpense is a void action with no navigation params; submitWithDismissFirst owns dismiss/reveal and cleanup runs after. executeWrite: (overrides) => { trackExpense({ @@ -342,6 +350,8 @@ function handleMoneyRequestStepDistanceNavigation({ backToReport, optimisticChatReportID, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination, }); }, destinationReportID: report?.reportID ?? selfDMReport?.reportID, @@ -359,6 +369,9 @@ function handleMoneyRequestStepDistanceNavigation({ const distanceDestinationReportID = report?.reportID; submitWithDismissFirst({ + isFromGlobalCreate: transactionIsFromGlobalCreate, + isLookingAroundUser, + isSelfDMDestination, executeWrite: (overrides) => { const {transactionID: writtenDistanceTransactionID} = createDistanceRequest({ report, @@ -423,6 +436,8 @@ function handleMoneyRequestStepDistanceNavigation({ backToReport, optimisticChatReportID, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination, }); }, destinationReportID: distanceDestinationReportID, diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 383f317c3057..a0b894cc11c4 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -22,7 +22,7 @@ import {createTransaction, getMoneyRequestParticipantOptions} from '@libs/action import {startSplitBill} from '@libs/actions/IOU/Split'; import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; import getCurrentPosition from '@libs/getCurrentPosition'; -import {calculateDefaultReimbursable, getExistingTransactionID} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, getExistingTransactionID, isSelfDMSoleDestination} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; @@ -108,6 +108,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, selector: shouldStartLocationPermissionFlowSelector, }); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); + const isLookingAroundUser = introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; const [transactions] = useOptimisticDraftTransactions(transaction); const {isMultiScanEnabled} = useMultiScanState(); @@ -132,6 +133,10 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, ); const participantsPolicyTags = useParticipantsPolicyTags(participants); + // Whether this expense's sole destination is the current user's self-DM. Forwarded to the cleanup helpers so the + // LOOKING_AROUND "route to Spend > Expenses" behaviour is scoped to the self-DM case (matches the confirmation step). + const isSelfDMDestination = isSelfDMSoleDestination(participants, iouType, currentUserPersonalDetails.accountID); + const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = (transaction?.taxCode ? transaction.taxCode : defaultTaxCode) ?? ''; const transactionTaxAmount = transaction?.taxAmount ?? 0; @@ -140,7 +145,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, useScanFileReadabilityCheck(transactions, draftTransactionIDs ?? [], disableMultiScan); const preInsertReportID = iouType === CONST.IOU.TYPE.TRACK ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID; - const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute(true, preInsertReportID); + const skipConfirmationPreMountRoute = getSkipConfirmationPreMountDestinationRoute(true, preInsertReportID, isLookingAroundUser, isSelfDMDestination); usePreMountDestination(skipConfirmationPreMountRoute); // Pre-fetch location if GPS is required and permission is already granted @@ -253,6 +258,8 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, backToReport, optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination, }); }, destinationReportID: reportID, @@ -316,6 +323,9 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const scanDestinationReportID = iouType === CONST.IOU.TYPE.TRACK ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID; submitWithDismissFirst({ + isFromGlobalCreate, + isLookingAroundUser, + isSelfDMDestination, executeWrite: (overrides) => { // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. const runCleanup = () => @@ -328,6 +338,8 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, backToReport, optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination, }); if (locationPermissionGranted) { diff --git a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx index 0e072402a876..c681ccedf292 100644 --- a/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx +++ b/src/pages/iou/request/step/confirmation/SubmitExpenseOrchestrator.tsx @@ -60,6 +60,12 @@ type SubmitExpenseOrchestratorProps = { */ isSelfDMDestination: boolean; + /** + * Whether the user onboarded as "Something else" (LOOKING_AROUND). Such users have no workspace, so a global-create + * expense is routed to Spend > Expenses (Search) instead of dismissing into their self-DM report. + */ + isLookingAroundUser: boolean; + /** Request sub-type (manual, scan, distance). Used for telemetry scenario derivation. */ requestType: string | undefined; @@ -128,6 +134,7 @@ function SubmitExpenseOrchestrator({ isFromGlobalCreate, iouType, isSelfDMDestination, + isLookingAroundUser, requestType, canDismissFromSearch, gpsRequired, @@ -207,6 +214,8 @@ function SubmitExpenseOrchestrator({ isReportTopmostSplit: isReportTopmostSplitNavigator(), isSearchTopmostFullScreen: isSearchTopmostFullScreenRoute(), isDestinationReportLoaded: !!destinationReportID && !!getReportOrDraftReport(destinationReportID, undefined, undefined, undefined, destinationReport)?.reportID, + isLookingAroundUser, + isSelfDMDestination, }; }; @@ -275,6 +284,12 @@ function SubmitExpenseOrchestrator({ // When Search is not visible (e.g. submitting from Home/Settings), we must navigate there. const isSearchVisible = isSearchTopmostFullScreenRoute(); const shouldNavigateToSearch = !isSameType || !isSearchVisible; + // forceReplace makes linkTo dispatch a REPLACE against TAB_NAVIGATOR, but SEARCH.ROOT is in linkTo's + // ROOT_TAB_SCREENS, so the cross-tab PUSH branch is skipped and the REPLACE resolves to a no-op: the mounted + // tab navigator keeps its state and the user stays on the tab they submitted from. Skipping forceReplace makes + // the navigation actually happen. Scoped to the LOOKING_AROUND self-DM flow this fix is about - other callers + // keep forceReplace so their browser-history behaviour is unchanged, even though they hit the same no-op today. + const shouldSkipForceReplace = isFromGlobalCreateForNavigation && isLookingAroundUser && isSelfDMDestination; setPendingSubmitFollowUpAction(shouldNavigateToSearch ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY); reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); @@ -312,7 +327,7 @@ function SubmitExpenseOrchestrator({ return; } - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: searchType})}), {forceReplace: true}); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: searchType})}), {forceReplace: !shouldSkipForceReplace}); }); }, }); diff --git a/src/pages/iou/request/step/confirmation/getSkipConfirmationPreMountDestinationRoute.ts b/src/pages/iou/request/step/confirmation/getSkipConfirmationPreMountDestinationRoute.ts index d5fae514c132..32fbf2d95ba0 100644 --- a/src/pages/iou/request/step/confirmation/getSkipConfirmationPreMountDestinationRoute.ts +++ b/src/pages/iou/request/step/confirmation/getSkipConfirmationPreMountDestinationRoute.ts @@ -15,9 +15,19 @@ import ROUTES from '@src/ROUTES'; * screens). The impure isSearchTopmostFullScreenRoute() read is safe to run per render: the topmost fullscreen route can't * change beneath an open RHP, so it's invariant for the screen's lifetime, and the string result is value-compared by the * hook's [route] effect - so recomputing it is a no-op. + * + * "Something else" (LOOKING_AROUND) users who skip confirmation for an expense that lands in their self-DM are routed to + * Spend > Expenses afterwards, so we must not pre-insert that self-DM report behind the RHP - doing so would flash Personal + * Space before the forced replace to Search (the exact stutter this pre-insert machinery exists to prevent). Mirrors the + * `!(isLookingAroundUser && isSelfDMDestination)` guard in getSubmitExpensePreMountDestinationRoute. */ -function getSkipConfirmationPreMountDestinationRoute(shouldSkipConfirmation: boolean, reportID: string | undefined): Route | undefined { - if (!shouldSkipConfirmation || isSearchTopmostFullScreenRoute() || !reportID) { +function getSkipConfirmationPreMountDestinationRoute( + shouldSkipConfirmation: boolean, + reportID: string | undefined, + isLookingAroundUser = false, + isSelfDMDestination = false, +): Route | undefined { + if (!shouldSkipConfirmation || isSearchTopmostFullScreenRoute() || !reportID || (isLookingAroundUser && isSelfDMDestination)) { return undefined; } diff --git a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts index 8b421fc9af00..130f46472518 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitExpensePreMountDestinationRoute.ts @@ -23,6 +23,7 @@ type GetSubmitExpensePreMountDestinationRouteParams = { iouType: IOUType; isCreatingTrackExpense: boolean; isSelfDMDestination: boolean; + isLookingAroundUser: boolean; }; /** @@ -38,6 +39,7 @@ function getSubmitExpensePreMountDestinationRoute({ iouType, isCreatingTrackExpense, isSelfDMDestination, + isLookingAroundUser, }: GetSubmitExpensePreMountDestinationRouteParams): Route | undefined { // Unlike getSkipConfirmationPreMountDestinationRoute (which lets usePreMountDestination own the narrow gate), this builder // returns undefined on wide up front - it avoids the nav reads below, and reveal() would never consume a wide result anyway. @@ -63,8 +65,13 @@ function getSubmitExpensePreMountDestinationRoute({ // Spend tab) pre-inserting a report is wrong - the user should stay on Search. Global-create TRACK targets self-DM, PAY/SPLIT // target a specific chat report, and a self-DM CREATE is effectively a TRACK, so all are eligible when Search is NOT topmost. const isReportBoundGlobalCreate = iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SPLIT; + // "Something else" (LOOKING_AROUND) users are routed to Spend > Expenses after a global-create expense that lands in + // their self-DM, so never pre-insert that self-DM report - doing so would strand them on it instead of Search after + // dismiss. The gate is scoped to isSelfDMDestination so it only fires for the self-DM case: a LOOKING_AROUND user who + // later has a workspace and submits to a real report/friend (e.g. PAY/SPLIT) still gets that report pre-inserted. const canUseReportPreInsert = !shouldPreInsertSearch && + !(isFromGlobalCreate && isLookingAroundUser && isSelfDMDestination) && (isReportTopmostSplitNavigator() || (!isSearchTopmostFullScreenRoute() && (isCreatingTrackExpense || isSelfDMDestination || isReportBoundGlobalCreate || !isFromGlobalCreate))); // RHP has its own dismiss handler; pre-inserting under it would break the stack. diff --git a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts index 948b97318901..118391c3a3f9 100644 --- a/src/pages/iou/request/step/confirmation/getSubmitHandler.ts +++ b/src/pages/iou/request/step/confirmation/getSubmitHandler.ts @@ -31,6 +31,10 @@ type SubmitNavigationSnapshot = { isSearchTopmostFullScreen: boolean; /** Whether the destination report is already loaded in Onyx. */ isDestinationReportLoaded: boolean; + /** Whether the user onboarded as "Something else" (LOOKING_AROUND) - they have no workspace. */ + isLookingAroundUser: boolean; + /** Whether the sole destination for this expense is the current user's self-DM (Personal Space). */ + isSelfDMDestination: boolean; }; function canUseDismissModalFastPath(snapshot: SubmitNavigationSnapshot): boolean { @@ -64,6 +68,7 @@ function canUseDismissModalFastPath(snapshot: SubmitNavigationSnapshot): boolean * Decision tree (evaluated top to bottom): * isPreInserted && !isReportPreInserted -> SEARCH_PRE_INSERT * isReportPreInserted -> REPORT_PRE_INSERT + * isFromGlobalCreate && isLookingAroundUser && isSelfDMDestination && canDismissFromSearch -> SEARCH_DISMISS * canUseDismissModalFastPath() -> DISMISS_MODAL * isFromGlobalCreate && canDismissFromSearch && isSearchTopmostFullScreen -> SEARCH_DISMISS * isReportInRHP && destinationReportID -> REPORT_IN_RHP_DISMISS @@ -79,6 +84,14 @@ function getSubmitHandler(snapshot: SubmitNavigationSnapshot): SubmitHandler { if (snapshot.isReportPreInserted) { return SUBMIT_HANDLER.REPORT_PRE_INSERT; } + // "Something else" (LOOKING_AROUND) users have no workspace, so a global-create expense that lands in their self-DM + // would otherwise dismiss into that self-DM report (the Inbox). Route them to Spend > Expenses (Search) instead. This + // must run before canUseDismissModalFastPath, which would pick DISMISS_MODAL when a report split is topmost (e.g. Home). + // Scoped to isSelfDMDestination so a LOOKING_AROUND user who later has a workspace and submits to a real report/friend + // (e.g. their workspace chat, or a PAY/SPLIT recipient) still dismisses to that report rather than being sent to Search. + if (snapshot.isFromGlobalCreate && snapshot.isLookingAroundUser && snapshot.isSelfDMDestination && snapshot.canDismissFromSearch) { + return SUBMIT_HANDLER.SEARCH_DISMISS; + } if (canUseDismissModalFastPath(snapshot)) { return SUBMIT_HANDLER.DISMISS_MODAL; } diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 3e8b3ec8c899..f964197e8026 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -293,6 +293,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); + const isLookingAroundUser = introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; // Onboarding task data const { taskReport: viewTourTaskReport, @@ -373,6 +374,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { backToReport: navigateBackToReport, optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction, + isLookingAroundUser, + isSelfDMDestination, }); } @@ -684,6 +687,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { hasMultipleTransactions: reportTransactions.length > 0, shouldAddPendingNewTransactionIDs: (shouldHandleNavigation && targetReportID === chatReportID) || isOneToTwoTransition, shouldNavigate: shouldHandleNavigation, + isLookingAroundUser, + isSelfDMDestination, }); } } diff --git a/src/selectors/Onboarding.ts b/src/selectors/Onboarding.ts index bc052ee7860c..2b21ebb20875 100644 --- a/src/selectors/Onboarding.ts +++ b/src/selectors/Onboarding.ts @@ -1,5 +1,6 @@ import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -89,4 +90,19 @@ function isTrackIntentUserSelector(introSelected: OnyxValue): boolean { + return introSelected?.choice === CONST.ONBOARDING_CHOICES.LOOKING_AROUND; +} + +export { + hasCompletedGuidedSetupFlowSelector, + tryNewDotOnyxSelector, + hasSeenTourSelector, + wasInvitedToNewDotSelector, + guidedSetupAndTourStatusSelector, + isTrackIntentUserSelector, + isLookingAroundUserSelector, +}; diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index a99fad4fb345..0deb1b286df7 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -11,6 +11,7 @@ import CONST from '@src/CONST'; import {getValidConnectedIntegration, isPreferredExporter} from '@src/libs/PolicyUtils'; import * as ReportActionsUtils from '@src/libs/ReportActionsUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; +import * as TransactionUtils from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; @@ -4707,6 +4708,54 @@ describe('getSecondaryTransactionThreadActions', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(true); }); + describe('SEND_TO_SOMEONE gate', () => { + // These cases install persistent spies (mockReturnValue); the enclosing beforeEach only clears call data, not + // implementations, so restore them here to avoid leaking into later getSecondaryTransactionThreadActions tests. + afterEach(() => { + jest.restoreAllMocks(); + }); + + function getSendToSomeoneResult(isExpenseSplit: boolean, hasWorkspaceToSubmitTo: boolean, isChatReportArchived = false) { + jest.spyOn(ReportUtils, 'isTrackExpenseReportNew').mockReturnValue(true); + // An archived self-DM has no write access; mirror that so the write-action guard on SEND_TO_SOMEONE is exercised. + jest.spyOn(ReportUtils, 'canUserPerformWriteAction').mockReturnValue(!isChatReportArchived); + jest.spyOn(TransactionUtils, 'getOriginalTransactionWithSplitInfo').mockReturnValue({ + originalTransaction: createMock({}), + isBillSplit: false, + isExpenseSplit, + }); + + return getSecondaryTransactionThreadActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + parentReport: createMock({chatType: CONST.REPORT.CHAT_TYPE.SELF_DM}), + reportTransaction: createMock({}), + reportAction: actionR14932, + originalTransaction: createMock({}), + policy: createMock({}), + isChatReportArchived, + isProduction: false, + hasWorkspaceToSubmitTo, + }); + } + + it('includes SEND_TO_SOMEONE for an unreported self-tracked expense that is not a split', () => { + expect(getSendToSomeoneResult(false, false)).toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE); + }); + + it('hides SEND_TO_SOMEONE for a self-DM split expense when the user has no workspace to submit to', () => { + expect(getSendToSomeoneResult(true, false)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE); + }); + + it('includes SEND_TO_SOMEONE for a self-DM split expense when the user has a workspace to submit to', () => { + expect(getSendToSomeoneResult(true, true)).toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE); + }); + + it('hides SEND_TO_SOMEONE on an archived self-DM (no write access)', () => { + expect(getSendToSomeoneResult(false, false, true)).not.toContain(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SEND_TO_SOMEONE); + }); + }); + it('includes the SPLIT option when grandParentReport is a selfDM report (transaction thread inside selfDM)', () => { // parentReport is a transaction thread (not selfDM, not expense report) const transactionThreadReport = createMock({ diff --git a/tests/unit/getSkipConfirmationPreMountDestinationRouteTest.ts b/tests/unit/getSkipConfirmationPreMountDestinationRouteTest.ts index fd698d32867c..ebfb898bbbd6 100644 --- a/tests/unit/getSkipConfirmationPreMountDestinationRouteTest.ts +++ b/tests/unit/getSkipConfirmationPreMountDestinationRouteTest.ts @@ -30,4 +30,16 @@ describe('getSkipConfirmationPreMountDestinationRoute', () => { it('returns undefined when reportID is missing', () => { expect(getSkipConfirmationPreMountDestinationRoute(true, undefined)).toBeUndefined(); }); + + it('returns undefined for a LOOKING_AROUND user whose expense lands in their self-DM (routed to Search, so no pre-insert)', () => { + expect(getSkipConfirmationPreMountDestinationRoute(true, '123', true, true)).toBeUndefined(); + }); + + it('still returns the report route for a LOOKING_AROUND user when the destination is NOT the self-DM', () => { + expect(getSkipConfirmationPreMountDestinationRoute(true, '123', true, false)).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); + }); + + it('still returns the report route for a self-DM destination when the user is NOT a LOOKING_AROUND user', () => { + expect(getSkipConfirmationPreMountDestinationRoute(true, '123', false, true)).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); + }); }); diff --git a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts index 1be8dd90b941..e0334b6dde1f 100644 --- a/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts +++ b/tests/unit/getSubmitExpensePreMountDestinationRouteTest.ts @@ -54,6 +54,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }), ).toBeUndefined(); }); @@ -69,6 +70,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }), ).toBeUndefined(); }); @@ -83,6 +85,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); @@ -100,6 +103,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); @@ -115,6 +119,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.TRACK, isCreatingTrackExpense: true, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); @@ -130,11 +135,30 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.CREATE, isCreatingTrackExpense: false, isSelfDMDestination: true, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); }); + it('does NOT pre-insert the self-DM report for a LOOKING_AROUND user (they are routed to Search after submit)', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + const route = getSubmitExpensePreMountDestinationRoute({ + isTransactionReady: true, + destinationReportID: '123', + destinationReport: {reportID: '123'}, + isFromGlobalCreate: true, + canPreInsertSearch: false, + iouType: CONST.IOU.TYPE.CREATE, + isCreatingTrackExpense: false, + isSelfDMDestination: true, + isLookingAroundUser: true, + }); + + expect(route).toBeUndefined(); + }); + it('returns the report route for a report-bound global create (PAY)', () => { const route = getSubmitExpensePreMountDestinationRoute({ isTransactionReady: true, @@ -145,6 +169,25 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.PAY, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, + }); + + expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); + }); + + it('still pre-inserts the report for a LOOKING_AROUND user when the destination is a real report, not the self-DM (PAY)', () => { + // A LOOKING_AROUND user who later has a workspace and submits to a real report/friend must keep the report + // pre-insert - the LOOKING_AROUND gate is scoped to isSelfDMDestination, so it does not fire here. + const route = getSubmitExpensePreMountDestinationRoute({ + isTransactionReady: true, + destinationReportID: '123', + destinationReport: {reportID: '123'}, + isFromGlobalCreate: true, + canPreInsertSearch: false, + iouType: CONST.IOU.TYPE.PAY, + isCreatingTrackExpense: false, + isSelfDMDestination: false, + isLookingAroundUser: true, }); expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); @@ -163,6 +206,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }), ).toBeUndefined(); }); @@ -181,6 +225,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }), ).toBeUndefined(); }); @@ -202,6 +247,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.REPORT_WITH_ID.getRoute('123')); @@ -221,6 +267,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }), ).toBeUndefined(); }); @@ -240,6 +287,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); @@ -264,6 +312,7 @@ describe('getSubmitExpensePreMountDestinationRoute', () => { iouType: CONST.IOU.TYPE.SUBMIT, isCreatingTrackExpense: false, isSelfDMDestination: false, + isLookingAroundUser: false, }); expect(route).toEqual(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); diff --git a/tests/unit/getSubmitHandlerTest.ts b/tests/unit/getSubmitHandlerTest.ts index 0c54f55f20bd..e51df5412bcc 100644 --- a/tests/unit/getSubmitHandlerTest.ts +++ b/tests/unit/getSubmitHandlerTest.ts @@ -12,6 +12,8 @@ const BASE_SNAPSHOT: SubmitNavigationSnapshot = { isReportTopmostSplit: false, isSearchTopmostFullScreen: false, isDestinationReportLoaded: false, + isLookingAroundUser: false, + isSelfDMDestination: false, }; function snap(overrides: Partial): SubmitNavigationSnapshot { @@ -101,6 +103,57 @@ describe('getSubmitHandler', () => { ).toBe(SUBMIT_HANDLER.SEARCH_DISMISS); }); + it('returns SEARCH_DISMISS for a LOOKING_AROUND user creating from global create on Home when the expense lands in their self-DM, instead of DISMISS_MODAL', () => { + expect( + getSubmitHandler( + snap({ + isFromGlobalCreate: true, + isLookingAroundUser: true, + isSelfDMDestination: true, + canDismissFromSearch: true, + navigatesToDestinationReport: true, + isReportTopmostSplit: true, + destinationReportID: '123', + isDestinationReportLoaded: true, + }), + ), + ).toBe(SUBMIT_HANDLER.SEARCH_DISMISS); + }); + + it('does not divert a LOOKING_AROUND user to SEARCH_DISMISS when the destination is a real report (not the self-DM)', () => { + // A LOOKING_AROUND user who later has a workspace and submits to a real report must dismiss to that report, not Search. + expect( + getSubmitHandler( + snap({ + isFromGlobalCreate: true, + isLookingAroundUser: true, + isSelfDMDestination: false, + canDismissFromSearch: true, + navigatesToDestinationReport: true, + isReportTopmostSplit: true, + destinationReportID: '123', + isDestinationReportLoaded: true, + }), + ), + ).toBe(SUBMIT_HANDLER.DISMISS_MODAL); + }); + + it('does not divert to SEARCH_DISMISS for a LOOKING_AROUND user when the expense is not from global create', () => { + expect( + getSubmitHandler( + snap({ + isFromGlobalCreate: false, + isLookingAroundUser: true, + isSelfDMDestination: true, + canDismissFromSearch: true, + isReportTopmostSplit: true, + destinationReportID: '123', + isDestinationReportLoaded: true, + }), + ), + ).toBe(SUBMIT_HANDLER.DISMISS_MODAL); + }); + it('returns DISMISS_MODAL (not SEARCH_DISMISS) when global create + search on top + report topmost split (dismiss fast path takes priority)', () => { expect( getSubmitHandler( diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 9a29d6403ceb..26ec395ea9af 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -97,6 +97,63 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); + it('should navigate to search for a LOOKING_AROUND user whose expense lands in their self-DM, even on the inbox tab', () => { + // A "Looking around / Something else" user creating an expense from global create while on the Inbox that lands in + // their self-DM should end up in Spend > Expenses, not that self-DM. The gate is scoped to isSelfDMDestination. + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + isLookingAroundUser: true, + isSelfDMDestination: true, + }); + + // forceReplace is deliberately false here: it makes linkTo dispatch a REPLACE against TAB_NAVIGATOR, and because + // SEARCH.ROOT is a tab root that REPLACE is a no-op, which left these users stuck on the tab they submitted from. + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'}), {forceReplace: false}); + expect(Navigation.dismissModalWithReport).not.toHaveBeenCalled(); + }); + + it('should keep forceReplace for a narrow Search navigation that is not the LOOKING_AROUND self-DM flow', () => { + // The forceReplace opt-out is scoped to the flow this fix is about, so every other caller keeps its existing + // browser-history behaviour even though they hit the same linkTo no-op today. + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + isLookingAroundUser: false, + isSelfDMDestination: false, + }); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'}), {forceReplace: true}); + }); + + it('should NOT route a LOOKING_AROUND user to search when the destination is a real report (not the self-DM)', () => { + // A LOOKING_AROUND user who later has a workspace and submits to a real report/friend from the Inbox must open that + // report, not be permanently misrouted to Search. isSelfDMDestination is false, so they are treated as "on inbox". + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + isLookingAroundUser: true, + isSelfDMDestination: false, + }); + + expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'}), {forceReplace: true}); + }); + it('should dismiss to report when transactionID is missing', () => { navigateAfterExpenseCreate({ activeReportID: 'report-123', diff --git a/tests/unit/submitWithDismissFirstTest.ts b/tests/unit/submitWithDismissFirstTest.ts index e826ea351edd..e3fc66ddfd68 100644 --- a/tests/unit/submitWithDismissFirstTest.ts +++ b/tests/unit/submitWithDismissFirstTest.ts @@ -194,6 +194,106 @@ describe('submitWithDismissFirst', () => { }); }); + describe('Looking-Around self-DM branch', () => { + const LOOKING_AROUND_SELF_DM = { + isFromGlobalCreate: true, + isLookingAroundUser: true, + isSelfDMDestination: true, + }; + + it('hands navigation to the write instead of revealing the self-DM report', () => { + // Without this branch the destination-report fast path reveals the self-DM and calls executeWrite with + // shouldHandleNavigation: false, which makes cleanupAfterSkipConfirmSubmit drop the routing flags before + // navigateAfterExpenseCreate can route these users to Spend > Expenses. + mockGetReportOrDraftReport.mockReturnValue({reportID: 'selfDM1'}); + const executeWrite = jest.fn(); + + submitWithDismissFirst({ + executeWrite, + destinationReportID: 'selfDM1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + }); + + expect(executeWrite).toHaveBeenCalledWith({shouldHandleNavigation: true}); + expect(mockRevealRouteBeforeDismissingModal).not.toHaveBeenCalled(); + expect(mockDismissModal).not.toHaveBeenCalled(); + }); + + it('still starts tracking so telemetry is not skipped', () => { + mockGetReportOrDraftReport.mockReturnValue({reportID: 'selfDM1'}); + + submitWithDismissFirst({ + executeWrite: jest.fn(), + destinationReportID: 'selfDM1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + }); + + expect(mockStartTracking).toHaveBeenCalledWith(TELEMETRY_CONTEXT, {skipSubmitExpenseSpan: true}); + expect(mockSetFastPath).toHaveBeenCalledWith(CONST.TELEMETRY.FAST_PATH_HANDLER.DEFAULT); + }); + + it('does not divert when the destination is not the self-DM', () => { + mockGetReportOrDraftReport.mockReturnValue({reportID: 'r1'}); + const executeWrite = jest.fn(); + + submitWithDismissFirst({ + executeWrite, + destinationReportID: 'r1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + isSelfDMDestination: false, + }); + + expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalled(); + expect(executeWrite).not.toHaveBeenCalledWith({shouldHandleNavigation: true}); + }); + + it('does not divert when the expense is not from global create', () => { + mockGetReportOrDraftReport.mockReturnValue({reportID: 'selfDM1'}); + + submitWithDismissFirst({ + executeWrite: jest.fn(), + destinationReportID: 'selfDM1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + isFromGlobalCreate: false, + }); + + expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalled(); + }); + + it('does not divert for a non-Looking-Around user', () => { + mockGetReportOrDraftReport.mockReturnValue({reportID: 'selfDM1'}); + + submitWithDismissFirst({ + executeWrite: jest.fn(), + destinationReportID: 'selfDM1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + isLookingAroundUser: false, + }); + + expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalled(); + }); + + it('yields to the Search-topmost branch, which already leaves the user on Search', () => { + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + const executeWrite = jest.fn(); + + submitWithDismissFirst({ + executeWrite, + destinationReportID: 'selfDM1', + telemetryContext: TELEMETRY_CONTEXT, + ...LOOKING_AROUND_SELF_DM, + }); + + expect(mockDismissModal).toHaveBeenCalledTimes(1); + expect(executeWrite).not.toHaveBeenCalled(); + }); + }); + describe('Priority', () => { it('Search-topmost takes priority over destination report', () => { mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);