Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
866d0b6
Navigate to clicked expense in multi-expense report preview and alway…
wildan-m May 19, 2026
92f1af9
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 1, 2026
9f55fd6
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 3, 2026
d3d086e
Add unit tests for multi-expense preview transaction navigation
wildan-m Jun 3, 2026
cab5457
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 5, 2026
89b62ed
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jun 6, 2026
5eaa24e
Show the primary action before the View button in the report preview
wildan-m Jun 6, 2026
0ead53d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
e46b769
Open the report below the pressed expense; single-expense reports ope…
wildan-m Jun 9, 2026
e408854
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
8dd5a27
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
73d3719
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 9, 2026
f04f96a
Open the pressed expense over the report in the wide RHP, not via the…
wildan-m Jun 9, 2026
f3b1226
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jun 12, 2026
22a0f66
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 15, 2026
cfff64b
Open the pressed expense over the report's wide RHP after a short delay
wildan-m Jun 15, 2026
16793ef
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 23, 2026
e383b44
Open the pressed expense with a single forward push on narrow layouts
wildan-m Jun 23, 2026
30f3b95
Let the View button hug its content so a long primary label is not tr…
wildan-m Jun 23, 2026
fd46c13
Re-trigger CI (flaky TrackExpenseTest from main merge, unrelated to t…
wildan-m Jun 23, 2026
bfce6ee
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 23, 2026
d5d88d0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 24, 2026
e2de5c0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 25, 2026
2e04534
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 25, 2026
de10193
Keep the report in the stack on narrow and open the pressed expense a…
wildan-m Jun 26, 2026
08dc97d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jun 26, 2026
bfa7e91
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 2, 2026
37f1625
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 3, 2026
670a3fe
Fix iOS backward animation, offline dead tap, and blank uncached expense
wildan-m Jul 3, 2026
a71c3d3
Merge remote-tracking branch 'upstream/main' into HEAD
wildan-m Jul 3, 2026
84dfff2
Open narrow expense as a single forward slide with the parent report …
wildan-m Jul 4, 2026
5ab3727
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 4, 2026
f55f88a
Skip the parent-report splice on web to fix the mobile-web double-back
wildan-m Jul 6, 2026
5bdf3b0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 7, 2026
d7a2caf
Push the report then the expense on web so hard back and header back …
wildan-m Jul 7, 2026
e4cb936
Open the expense in an RHP over the report on mobile web
wildan-m Jul 7, 2026
e3f4901
Open the mobile-web expense RHP in the same tick as the report (no ca…
wildan-m Jul 7, 2026
9ad3fff
Defer the mobile-web expense RHP by one frame so back stops on the re…
wildan-m Jul 7, 2026
eef7176
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 9, 2026
9ddff97
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 13, 2026
9ca40b5
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 13, 2026
c62bfc3
Drop the inert wide RHP width hint on the mobile-web expense open
wildan-m Jul 13, 2026
3c5ad11
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 14, 2026
68444dd
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 15, 2026
764bbfe
Update ReportPreviewActionButton test for the View-alongside layout
wildan-m Jul 15, 2026
13e582f
Guard the wide-layout delayed expense navigation against dismissal
wildan-m Jul 15, 2026
c90aef2
Defer the mobile-web expense RHP until the report settles (fix iOS Sa…
wildan-m Jul 15, 2026
ebbf494
Open the mobile-web expense directly over the chat (fix iOS Safari fr…
wildan-m Jul 15, 2026
ed5c45d
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 16, 2026
950904b
mWeb: keep report alive under the expense RHP so back returns to it
wildan-m Jul 16, 2026
4fb9ce0
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 21, 2026
bbe4f34
Only defer an unresolved expense press while the report's actions are…
wildan-m Jul 21, 2026
e46083f
Re-fetch and settle a deferred expense press on fetch completion
wildan-m Jul 21, 2026
1c48493
Merge remote-tracking branch 'upstream/main' into wildan/91042-multi-…
wildan-m Jul 25, 2026
e07bfa3
Address review feedback: drop the no-op FreezeWrapper change, tighten…
wildan-m Jul 25, 2026
2e7ea87
Add 'dispatchable' to the cspell dictionary
wildan-m Jul 25, 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 cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@
"diems",
"dimen",
"directfeeds",
"dispatchable",
"displaystatus",
"domainpadding",
"domelementtype",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@ function ReportPreviewActionButton() {
const {buttonMaxWidth} = useReportPreviewUIState();
const {openReportFromPreview} = useReportPreviewActions();

const renderButton = () => {
const viewButton = (
<Button
onPress={openReportFromPreview}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.VIEW_BUTTON}
>
<Button.Text>{translate('common.view')}</Button.Text>
</Button>
);

const renderPrimaryButton = () => {
if (reportPreviewAction === CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT) {
return <SubmitActionButton />;
}
Expand All @@ -45,17 +54,21 @@ function ReportPreviewActionButton() {
return <AddExpenseActionButton />;
}

return (
<Button
onPress={openReportFromPreview}
sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.VIEW_BUTTON}
>
<Button.Text>{translate('common.view')}</Button.Text>
</Button>
);
return null;
};

return <View style={[buttonMaxWidth, styles.flex1, {height: variables.h40}]}>{renderButton()}</View>;
const primaryButton = renderPrimaryButton();

Comment thread
MonilBhavsar marked this conversation as resolved.
if (!primaryButton) {
return <View style={[buttonMaxWidth, styles.flex1, {height: variables.h40}]}>{viewButton}</View>;
}

return (
<View style={[buttonMaxWidth, styles.flex1, styles.flexRow, styles.gap2, {height: variables.h40}]}>
<View style={[styles.flex1]}>{primaryButton}</View>
{viewButton}
</View>
);
}

export default ReportPreviewActionButton;
236 changes: 215 additions & 21 deletions src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import TransactionPreview from '@components/ReportActionItem/TransactionPreview';
import {useWideRHPActions} from '@components/WideRHPContextProvider';

import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import useNewTransactions from '@hooks/useNewTransactions';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -11,8 +13,18 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionViolations from '@hooks/useTransactionViolations';

import {createTransactionThreadReport, openReport, setOptimisticTransactionThread} from '@libs/actions/Report';
import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getIOUActionForReportID, isSplitBillAction as isSplitBillActionReportActionsUtils, isTrackExpenseAction as isTrackExpenseActionReportActionsUtils} from '@libs/ReportActionsUtils';
import getPlatform from '@libs/getPlatform';
import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue';
import {
getIOUActionForReportID,
getOriginalMessage,
isMoneyRequestAction,
isSplitBillAction as isSplitBillActionReportActionsUtils,
isTrackExpenseAction as isTrackExpenseActionReportActionsUtils,
} from '@libs/ReportActionsUtils';
import {isIOUReport} from '@libs/ReportUtils';
import {startSpan} from '@libs/telemetry/activeSpans';

Expand All @@ -30,12 +42,16 @@ import type {ListRenderItem} from '@shopify/flash-list';
import type {LayoutChangeEvent} from 'react-native';

import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';

import type {MoneyRequestReportPreviewProps} from './types';

import MoneyRequestReportPreviewContent from './MoneyRequestReportPreviewContent';

// Delay (ms) before the pressed expense opens on top of the report's wide RHP. Letting the report settle
// into the wide RHP first makes the two panels open as a cascade rather than appearing at once.
const PRESSED_EXPENSE_CASCADE_DELAY = 180;

function MoneyRequestReportPreview({
iouReportID,
iouReport,
Expand All @@ -53,7 +69,11 @@ function MoneyRequestReportPreview({
const StyleUtils = useStyleUtils();
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {markReportRHPWidth} = useWideRHPActions();
const personalDetailsList = usePersonalDetails();
const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined;
const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(invoiceReceiverPolicyID)}`);
const invoiceReceiverPersonalDetail = chatReport?.invoiceReceiver && 'accountID' in chatReport.invoiceReceiver ? personalDetailsList?.[chatReport.invoiceReceiver.accountID] : null;
Expand All @@ -63,6 +83,19 @@ function MoneyRequestReportPreview({
// reimbursable derivations so they include optimistically-deleted rows, exactly as before the decomposition.
const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction);
const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
// Tracks how many actions the IOU report has loaded so a deferred expense press can be retried once
// the actions arrive (they may be missing right after a cache clear).
const [iouReportActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, {
selector: (reportActions) => Object.keys(reportActions ?? {}).length,
});
// Whether the deferred press's openReport fetch is still in flight. The true -> false flip re-runs the drain
// effect below, so a deferred press settles even when the fetch returns the actions we already had.
const [isLoadingInitialIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(iouReportID)}`, {
selector: (loadingState) => !!loadingState?.isLoadingInitialReportActions,
});
// Holds a pressed transaction whose thread report could not be resolved yet, so the expense can be
// opened once the IOU report's actions have loaded instead of falling back to the parent report.
const pendingExpenseTransactionRef = useRef<Transaction | null>(null);
const policy = usePolicy(policyID);
const lastTransaction = transactions?.at(0);
const lastTransactionViolations = useTransactionViolations(lastTransaction?.transactionID);
Expand Down Expand Up @@ -144,27 +177,188 @@ function MoneyRequestReportPreview({

const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle];

const renderItem: ListRenderItem<Transaction> = ({item}) => (
<TransactionPreview
chatReport={chatReport}
action={getIOUActionForReportID(item.reportID, item.transactionID)}
contextAction={action}
reportID={item.reportID}
isBillSplit={isSplitBillAction}
isTrackExpense={isTrackExpenseAction}
isWhisper={isWhisper}
isHovered={isHovered}
iouReportID={iouReportID}
containerStyles={transactionPreviewContainerStyles}
transactionPreviewWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width}
transactionID={item.transactionID}
reportPreviewAction={action}
onPreviewPressed={openReportFromPreview}
shouldShowPayerAndReceiver={shouldShowPayerAndReceiver}
shouldHighlight={!!newTransactionIDs?.has(item.transactionID)}
/>
// Resolve the target transaction thread report. Prefer the IOU action's childReportID, then the
// transaction's own thread id, and finally create the thread inline so the press never lands on a dead route.
const resolveChildReportID = useCallback(
(transaction: Transaction) => {
const transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID);
let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID;
if (childReportID) {
// The thread already exists, but it may not be present in OnyxDB. Seed its
// optimistic report shell + parent linkage so navigating to it renders the expense instead of a
// blank/not-found screen.
setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID);
} else if (transactionIOUAction?.reportActionID) {
const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined;
if (transactionID) {
childReportID = createTransactionThreadReport({
introSelected,
currentUserLogin: currentUserEmail ?? '',
currentUserAccountID,
betas,
iouReport,
iouReportAction: transactionIOUAction,
})?.reportID;
}
}
return childReportID;
},
[betas, currentUserAccountID, currentUserEmail, introSelected, iouReport, policyID],
);

const navigateToExpense = useCallback(
(childReportID: string) => {
startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, {
name: 'MoneyRequestReportPreview.Transaction',
op: CONST.TELEMETRY.SPAN_OPEN_REPORT,
});

if (isSmallScreenWidth) {
const backTo = Navigation.getActiveRoute();
if (!iouReportID) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID, undefined, undefined, backTo));
return;
}

if (getPlatform() === CONST.PLATFORM.WEB) {
// On mobile web, push the parent report first so it gets its own browser-history entry (back stops
// on the report, then the chat), then open the pressed expense in the RHP over it one microtask
// later so the two navigations don't batch into a single history push. Mirrors native's
// back -> report -> chat.
const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo);
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID));
Navigation.navigate(reportRoute);
setNavigationActionToMicrotaskQueue(() => {
// The user may have left the report during the microtask defer; don't open the expense over
// whatever screen is now active.
if (!Navigation.isActiveRoute(reportRoute)) {
return;
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
});
return;
}

// On native the wide RHP is unavailable and there is a swipe-back gesture, so open the expense with the
// parent report as a real stack entry underneath as a single forward slide.
Navigation.openExpenseOverParentReport(iouReportID, childReportID, backTo);
return;
}

// On wide layouts open the expense report itself in the wide RHP (super wide for multi-expense
// reports) and show the pressed expense on top of it — mirroring how an expense opens from the
// report view — rather than navigating to the report in the Inbox. Back returns to the report,
// and back again to the chat.
if (iouReportID) {
const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: Navigation.getActiveRoute()});
markReportRHPWidth(iouReportID, 'super-wide');
Navigation.navigate(reportRoute);
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID)).then(() => {
markReportRHPWidth(childReportID, 'wide');
// Let the report's wide RHP settle before opening the pressed expense on top, so the two
// panels open as a cascade rather than at once.
setTimeout(() => {
// The user may have closed the report's wide RHP or navigated away during the cascade delay;
// don't reopen the expense over whatever screen is now active.
if (!Navigation.isActiveRoute(reportRoute)) {
return;
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
Comment thread
MonilBhavsar marked this conversation as resolved.
}, PRESSED_EXPENSE_CASCADE_DELAY);
});
return;
}

// Fallback when the parent report is unknown: open the pressed expense alone in the wide RHP.
setActiveTransactionIDs(transactions.map((transaction) => transaction.transactionID)).then(() => {
markReportRHPWidth(childReportID, 'wide');
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: Navigation.getActiveRoute()}));
});
},
[isSmallScreenWidth, iouReportID, markReportRHPWidth, transactions],
);

const openTransactionFromPreview = useCallback(
(transaction: Transaction) => {
if (contextMenuRef.current?.isContextMenuOpening) {
return;
}

// A report with a single expense opens the report itself, not the lone expense — opening the
// expense directly would skip the report the user expects to land on.
if (transactions.length <= 1) {
openReportFromPreview();
return;
}

const childReportID = resolveChildReportID(transaction);
if (childReportID) {
navigateToExpense(childReportID);
Comment thread
wildan-m marked this conversation as resolved.
return;
}

// The thread could not be resolved because this expense's IOU action isn't present in OnyxDB. Fetch the report's actions and
// open the expense once the fetch settles, instead of falling back to the parent
// report and losing the pressed expense. Skip this while offline: openReport can't fetch, so the
// deferred press would never fire (dead tap) — fall through to opening the cached parent report
// instead, matching the "View" button.
const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID);
if (!isIOUActionLoaded && iouReportID && !isOffline) {
pendingExpenseTransactionRef.current = transaction;
openReport({reportID: iouReportID, introSelected, betas});
return;
}

openReportFromPreview();
},
[betas, introSelected, iouReportID, isOffline, navigateToExpense, openReportFromPreview, resolveChildReportID, transactions.length],
);

// Completes a deferred expense press once the IOU report's actions have loaded.
useEffect(() => {
const pendingTransaction = pendingExpenseTransactionRef.current;
// Hold the press while the fetch is in flight — the loading flip back to false re-runs this effect, so the
// press settles even when the fetched actions match the cache and the action count never changes.
if (!pendingTransaction || isLoadingInitialIOUReportActions) {
return;
}
const childReportID = resolveChildReportID(pendingTransaction);
if (childReportID) {
pendingExpenseTransactionRef.current = null;
navigateToExpense(childReportID);
return;
}
// The actions finished loading but the expense still has no resolvable thread — open the parent report.
if (iouReportActionCount) {
pendingExpenseTransactionRef.current = null;
openReportFromPreview();
}
}, [iouReportActionCount, isLoadingInitialIOUReportActions, navigateToExpense, openReportFromPreview, resolveChildReportID]);

const renderItem: ListRenderItem<Transaction> = ({item}) => {
const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID);
return (
<TransactionPreview
chatReport={chatReport}
action={transactionIOUAction}
contextAction={action}
reportID={item.reportID}
isBillSplit={isSplitBillAction}
isTrackExpense={isTrackExpenseAction}
isWhisper={isWhisper}
isHovered={isHovered}
iouReportID={iouReportID}
containerStyles={transactionPreviewContainerStyles}
transactionPreviewWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width}
transactionID={item.transactionID}
reportPreviewAction={action}
onPreviewPressed={() => openTransactionFromPreview(item)}
shouldShowPayerAndReceiver={shouldShowPayerAndReceiver}
shouldHighlight={!!newTransactionIDs?.has(item.transactionID)}
/>
);
};

return (
<MoneyRequestReportPreviewContent
newTransactionIDs={newTransactionIDs}
Expand Down
Loading
Loading