Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
21df24c
Land Looking-Around users in Spend > Expenses and add Send to someone…
MelvinBot Aug 5, 2026
340f52e
Keep Looking-Around users on Home after onboarding; route to Spend on…
MelvinBot Aug 5, 2026
1900f27
Move Send to someone to top of transaction More menu with divider
MelvinBot Aug 5, 2026
8f2e326
Explicitly land Something-else (LOOKING_AROUND) users on Home after o…
MelvinBot Aug 6, 2026
696c76c
Fix spellcheck: reword comment to avoid unknown word 'signups'
MelvinBot Aug 6, 2026
e0bb893
Route Looking-Around users to Spend > Expenses at the orchestrator layer
MelvinBot Aug 7, 2026
a0c216e
Merge remote-tracking branch 'origin/main' into claude-expenseCreateL…
MelvinBot Aug 8, 2026
c086d8c
Pass LOOKING_AROUND onboarding choice into navigateAfterExpenseCreate…
MelvinBot Aug 11, 2026
8a88da5
Merge remote-tracking branch 'origin/main' into claude-expenseCreateL…
MelvinBot Aug 11, 2026
c521c15
Address tgolen & ikevin127 review feedback
MelvinBot Aug 12, 2026
acd8555
Merge remote-tracking branch 'origin/main' into claude-expenseCreateL…
MelvinBot Aug 14, 2026
f2cf27d
Address ikevin127 Option A: complete LOOKING_AROUND routing on skip f…
MelvinBot Aug 14, 2026
668fe15
Fix React Compiler + spellcheck CI failures
MelvinBot Aug 14, 2026
0ee2434
Address tgolen review: inline trivial helper, trim comments, drop str…
MelvinBot Aug 14, 2026
e26deb5
Address ikevin127 follow-up: unify self-DM predicate + test-spy hygiene
MelvinBot Aug 14, 2026
33df2b1
Apply ikevin127 patch: route LOOKING_AROUND self-DM skip flows to Sea…
MelvinBot Aug 15, 2026
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/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,7 @@ const CONST = {
MERGE: 'merge',
DUPLICATE: 'duplicate',
MOVE_EXPENSE: 'moveExpense',
SEND_TO_SOMEONE: 'sendToSomeone',
},
SELECTED_TRANSACTIONS_BULK_ACTION_TYPES: {
HOLD: 'hold',
Expand Down
51 changes: 49 additions & 2 deletions src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useActivePolicy from '@hooks/useActivePolicy';
import useConfirmModal from '@hooks/useConfirmModal';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -127,6 +131,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
'DocumentMerge',
'ExpenseCopy',
'Info',
'Send',
'Stopwatch',
'ThumbsDown',
'Trashcan',
Expand Down Expand Up @@ -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)}`);
Expand All @@ -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();
Expand Down Expand Up @@ -332,6 +346,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
isChatReportArchived: isChatIOUReportArchived,
grandParentReport,
isProduction,
hasWorkspaceToSubmitTo,
});
})();

Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,7 @@ const translations: TranslationDeepObject<typeof en> = {
`ξεκίνησε τη διαδικασία τακτοποίησης. Η πληρωμή έχει τεθεί σε αναμονή μέχρι ο/η ${submitterDisplayName} να ενεργοποιήσει το πορτοφόλι του/της.`,
enableWallet: 'Ενεργοποίηση πορτοφολιού',
hold: 'Σε αναμονή',
sendToSomeone: 'Αποστολή σε κάποιον',
unhold: 'Αφαίρεση κράτησης',
holdExpense: () => ({
one: 'Αναστολή δαπάνης',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,7 @@ const translations: TranslationDeepObject<typeof en> = {
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.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,7 @@ const translations: TranslationDeepObject<typeof en> = {
`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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,7 @@ const translations: TranslationDeepObject<typeof en> = {
waitingOnEnabledWallet: (submitterDisplayName: string) => `清算を開始しました。${submitterDisplayName} がウォレットを有効にするまで、支払いは保留されます。`,
enableWallet: 'ウォレットを有効にする',
hold: '保留',
sendToSomeone: '誰かに送る',
unhold: '保留を解除',
holdExpense: () => ({
one: '経費を保留',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,7 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,7 @@ const translations: TranslationDeepObject<typeof en> = {
waitingOnEnabledWallet: (submitterDisplayName: string) => `已开始结算。在 ${submitterDisplayName} 启用其钱包之前,付款将被暂挂。`,
enableWallet: '启用钱包',
hold: '暂挂',
sendToSomeone: '发送给某人',
unhold: '解除保留',
holdExpense: () => ({
one: '暂挂报销',
Expand Down
9 changes: 9 additions & 0 deletions src/libs/IOUAmountSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getExistingTransactionID,
isMovingTransactionFromTrackExpense,
isParticipantP2P,
isSelfDMSoleDestination,
navigateToConfirmationPage,
navigateToParticipantPage,
resolveOptimisticChatReportID,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ type CleanupAndNavigateAfterExpenseCreateParams = {
isInvoice?: boolean;
linkedTrackedExpenseReportAction?: OnyxEntry<ReportAction>;
action: DeepValueOf<typeof CONST.IOU.ACTION>;

/**
* 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
Expand All @@ -45,6 +57,8 @@ function cleanupAndNavigateAfterExpenseCreate({
isInvoice,
linkedTrackedExpenseReportAction,
action,
isLookingAroundUser,
isSelfDMDestination,
shouldNavigate = true,
navigationReportID,
}: CleanupAndNavigateAfterExpenseCreateParams) {
Expand All @@ -70,6 +84,8 @@ function cleanupAndNavigateAfterExpenseCreate({
isInvoice,
hasMultipleTransactions,
shouldAddPendingNewTransactionIDs,
isLookingAroundUser,
isSelfDMDestination,
shouldNavigate,
});
}
Expand Down
30 changes: 28 additions & 2 deletions src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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');
}
Expand Down
Loading
Loading