Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 21 additions & 97 deletions src/pages/inbox/report/ReportActionItemMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,26 @@
import type {ReactElement} from 'react';
import React from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Button from '@components/Button';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import Navigation from '@libs/Navigation/Navigation';
import {
getLinkedTransactionID,
getMemberChangeMessageFragment,
getOriginalMessage,
getReportActionMessage,
getReportActionMessageFragments,
getUpdateRoomDescriptionFragment,
isAddCommentAction,
isApprovedOrSubmittedReportAction as isApprovedOrSubmittedReportActionUtils,
isMemberChangeAction,
isMoneyRequestAction,
isReimbursementDirectionInformationRequiredAction,
isThreadParentMessage,
} from '@libs/ReportActionsUtils';
import {getIOUReportActionDisplayMessage, getReportName, hasMissingInvoiceBankAccount, isSettled} from '@libs/ReportUtils';
import {getReportName} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {ReportAction} from '@src/types/onyx';
import IouReportActionMessage from './actionContents/IouReportActionMessage';
import ReportActionMessageContent from './actionContents/ReportActionMessageContent';
import TextCommentFragment from './comment/TextCommentFragment';
import ReportActionItemFragment from './ReportActionItemFragment';

type ReportActionItemMessageProps = {
/** The report action */
Expand All @@ -51,12 +42,18 @@ type ReportActionItemMessageProps = {
function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHidden = false}: ReportActionItemMessageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(getLinkedTransactionID(action))}`);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);

const fragments = getReportActionMessageFragments(translate, action);
const isIOUReport = isMoneyRequestAction(action);
if (isMoneyRequestAction(action)) {
return (
<IouReportActionMessage
action={action}
displayAsGroup={displayAsGroup}
reportID={reportID}
style={style}
isHidden={isHidden}
/>
);
}

if (isMemberChangeAction(action)) {
// This will be fixed: https://github.com/Expensify/App/issues/76852
Expand Down Expand Up @@ -98,7 +95,6 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid

Navigation.navigate(ROUTES.BANK_ACCOUNT_ENTER_SIGNER_INFO.getRoute(policyID, bankAccountID, isCompleted));
};

if (isReimbursementDirectionInformationRequiredAction(action)) {
const {bankAccountLastFour, currency, policyID, bankAccountID, completed} =
getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DIRECTOR_INFORMATION_REQUIRED>(action) ?? {};
Expand All @@ -118,86 +114,14 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid
);
}

let iouMessage: string | undefined;
if (isIOUReport) {
const originalMessage = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? getOriginalMessage(action) : null;
const iouReportID = originalMessage?.IOUReportID;
if (iouReportID) {
iouMessage = getIOUReportActionDisplayMessage(translate, action, transaction, report, bankAccountList);
}
}

const isApprovedOrSubmittedReportAction = isApprovedOrSubmittedReportActionUtils(action);

const isHoldReportAction = [CONST.REPORT.ACTIONS.TYPE.HOLD, CONST.REPORT.ACTIONS.TYPE.UNHOLD].some((type) => type === action.actionName);

/**
* Get the ReportActionItemFragments
* @param shouldWrapInText determines whether the fragments are wrapped in a Text component
* @returns report action item fragments
*/
const renderReportActionItemFragments = (shouldWrapInText: boolean): ReactElement | ReactElement[] => {
const reportActionItemFragments = fragments.map((fragment, index) => (
<ReportActionItemFragment
/* eslint-disable-next-line react/no-array-index-key */
key={`actionFragment-${action.reportActionID}-${index}`}
reportActionID={action.reportActionID}
fragment={fragment}
iouMessage={iouMessage}
isThreadParentMessage={isThreadParentMessage(action, reportID)}
pendingAction={action.pendingAction}
actionName={action.actionName}
source={isAddCommentAction(action) ? getOriginalMessage(action)?.source : ''}
accountID={action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID}
style={style}
displayAsGroup={displayAsGroup}
isApprovedOrSubmittedReportAction={isApprovedOrSubmittedReportAction}
isHoldReportAction={isHoldReportAction}
// Since system messages from Old Dot begin with the person who performed the action,
// the first fragment will contain the person's display name and their email. We'll use this
// to decide if the fragment should be from left to right for RTL display names e.g. Arabic for proper
// formatting.
isFragmentContainingDisplayName={index === 0}
moderationDecision={getReportActionMessage(action)?.moderationDecision?.decision}
/>
));

// Approving or submitting reports in oldDot results in system messages made up of multiple fragments of `TEXT` type
// which we need to wrap in `<Text>` to prevent them rendering on separate lines.
return shouldWrapInText ? <Text style={styles.ltr}>{reportActionItemFragments}</Text> : reportActionItemFragments;
};

const openWorkspaceInvoicesPage = () => {
const policyID = report?.policyID;

if (!policyID) {
return;
}

Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID));
};

const shouldShowAddBankAccountButton = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && hasMissingInvoiceBankAccount(reportID) && !isSettled(reportID);

return (
<View style={[styles.chatItemMessage, style]}>
{!isHidden ? (
<>
{renderReportActionItemFragments(isApprovedOrSubmittedReportAction)}
{shouldShowAddBankAccountButton && (
<Button
style={[styles.mt2, styles.alignSelfStart]}
success
text={translate('bankAccount.addBankAccount')}
onPress={openWorkspaceInvoicesPage}
sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_ADD_BANK_ACCOUNT}
/>
)}
</>
) : (
<Text style={[styles.textLabelSupporting, styles.lh20]}>{translate('moderation.flaggedContent')}</Text>
)}
</View>
<ReportActionMessageContent
action={action}
displayAsGroup={displayAsGroup}
reportID={reportID}
style={style}
isHidden={isHidden}
/>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getLinkedTransactionID, getOriginalMessage, isActionOfType} from '@libs/ReportActionsUtils';
import {getIOUReportActionDisplayMessage} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportAction} from '@src/types/onyx';
import ReportActionMessageContent from './ReportActionMessageContent';

type IouReportActionMessageProps = {
/** The report action */
action: ReportAction;

/** Should the comment have the appearance of being grouped with the previous comment? */
displayAsGroup: boolean;

/** Additional styles to add after local styles. */
style?: StyleProp<ViewStyle & TextStyle>;

/** Whether or not the message is hidden by moderation */
isHidden?: boolean;

/** The ID of the report */
reportID: string | undefined;
};

function IouReportActionMessage({action, displayAsGroup, reportID, style, isHidden = false}: IouReportActionMessageProps) {
const {translate} = useLocalize();
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(getLinkedTransactionID(action))}`);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);

let iouMessage: string | undefined;
const originalMessage = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) ? getOriginalMessage(action) : null;
const iouReportID = originalMessage?.IOUReportID;
if (iouReportID) {
iouMessage = getIOUReportActionDisplayMessage(translate, action, transaction, report, bankAccountList);
}

return (
<ReportActionMessageContent
action={action}
displayAsGroup={displayAsGroup}
reportID={reportID}
style={style}
isHidden={isHidden}
iouMessage={iouMessage}
/>
);
}

export default IouReportActionMessage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type {ReactElement} from 'react';
import React from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {
getOriginalMessage,
getReportActionMessage,
getReportActionMessageFragments,
isAddCommentAction,
isApprovedOrSubmittedReportAction as isApprovedOrSubmittedReportActionUtils,
isThreadParentMessage,
} from '@libs/ReportActionsUtils';
import ReportActionItemFragment from '@pages/inbox/report/ReportActionItemFragment';
import CONST from '@src/CONST';
import type {ReportAction} from '@src/types/onyx';

type ReportActionMessageContentProps = {
/** The report action */
action: ReportAction;

/** Should the comment have the appearance of being grouped with the previous comment? */
displayAsGroup: boolean;

/** Additional styles to add after local styles. */
style?: StyleProp<ViewStyle & TextStyle>;

/** Whether or not the message is hidden by moderation */
isHidden?: boolean;

/** The ID of the report */
reportID: string | undefined;

/** Optional IOU display message passed into each fragment */
iouMessage?: string;
};

function ReportActionMessageContent({action, displayAsGroup, reportID, style, isHidden = false, iouMessage}: ReportActionMessageContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const isApprovedOrSubmittedReportAction = isApprovedOrSubmittedReportActionUtils(action);
const isHoldReportAction = [CONST.REPORT.ACTIONS.TYPE.HOLD, CONST.REPORT.ACTIONS.TYPE.UNHOLD].some((type) => type === action.actionName);
const fragments = getReportActionMessageFragments(translate, action);

const renderReportActionItemFragments = (shouldWrapInText: boolean): ReactElement | ReactElement[] => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CLEAN-REACT-PATTERNS-4 (docs)

renderReportActionItemFragments is an internal render helper defined inside the component body and called in the return statement (renderReportActionItemFragments(isApprovedOrSubmittedReportAction)). This closure captures the entire component scope, preventing React Compiler from independently memoizing it.

Extract this into a separate component with explicit props:

function ReportActionItemFragments({fragments, action, reportID, iouMessage, style, displayAsGroup, isApprovedOrSubmittedReportAction, isHoldReportAction, shouldWrapInText}: Props) {
    const styles = useThemeStyles();
    const reportActionItemFragments = fragments.map((fragment, index) => (
        <ReportActionItemFragment
            key={`actionFragment-${action.reportActionID}-${index}`}
            // ... props
        />
    ));
    return shouldWrapInText ? <Text style={styles.ltr}>{reportActionItemFragments}</Text> : <>{reportActionItemFragments}</>;
}

Then use it in the return:

{!isHidden ? (
    <ReportActionItemFragments ... shouldWrapInText={isApprovedOrSubmittedReportAction} />
) : (
    <Text ...>{translate('moderation.flaggedContent')}</Text>
)}

Reviewed at: 2894a40 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown
Contributor Author

@LukasMod LukasMod Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Compiler compliance passes on the original, the closure here is not opaque to RC. This component renders once per visible report action, the extraction would add a component frame and an extra useThemeStyles() call per row for no realized benefit. I will keep it as it was originally (it was moved to new file)

const reportActionItemFragments = fragments.map((fragment, index) => (
<ReportActionItemFragment
// Message fragments don't have stable unique IDs, so index is the best available key
/* eslint-disable-next-line react/no-array-index-key */
Comment thread
LukasMod marked this conversation as resolved.
key={`actionFragment-${action.reportActionID}-${index}`}
reportActionID={action.reportActionID}
fragment={fragment}
iouMessage={iouMessage}
isThreadParentMessage={isThreadParentMessage(action, reportID)}
pendingAction={action.pendingAction}
actionName={action.actionName}
source={isAddCommentAction(action) ? getOriginalMessage(action)?.source : ''}
accountID={action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID}
style={style}
displayAsGroup={displayAsGroup}
isApprovedOrSubmittedReportAction={isApprovedOrSubmittedReportAction}
isHoldReportAction={isHoldReportAction}
// Since system messages from Old Dot begin with the person who performed the action,
// the first fragment will contain the person's display name and their email. We'll use this
// to decide if the fragment should be from left to right for RTL display names e.g. Arabic for proper
// formatting.
isFragmentContainingDisplayName={index === 0}
moderationDecision={getReportActionMessage(action)?.moderationDecision?.decision}
/>
));

// Approving or submitting reports in oldDot results in system messages made up of multiple fragments of `TEXT` type
// which we need to wrap in `<Text>` to prevent them rendering on separate lines.
return shouldWrapInText ? <Text style={styles.ltr}>{reportActionItemFragments}</Text> : reportActionItemFragments;
};

return (
<View style={[styles.chatItemMessage, style]}>
{!isHidden ? (
renderReportActionItemFragments(isApprovedOrSubmittedReportAction)
) : (
<Text style={[styles.textLabelSupporting, styles.lh20]}>{translate('moderation.flaggedContent')}</Text>
)}
</View>
);
}

export default ReportActionMessageContent;
74 changes: 74 additions & 0 deletions tests/ui/PureReportActionItemTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {setHasRadio} from '@libs/NetworkState';
import Parser from '@libs/Parser';
import {getIOUActionForReportID} from '@libs/ReportActionsUtils';
import PureReportActionItem from '@pages/inbox/report/PureReportActionItem';
import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage';
import colors from '@styles/theme/colors';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
Expand Down Expand Up @@ -2634,4 +2635,77 @@ describe('PureReportActionItem', () => {
expect(screen.getByText(translateLocal('actionableMentionTrackExpense.nothing' as TranslationPaths))).toBeOnTheScreen();
});
});

// Path: No FE flow creates IOU + reject/cancel/delete/approve actions; this defensive path is only
// reachable from BE-emitted actions
describe('IouReportActionMessage edge subtypes', () => {
const TEST_REPORT_ID = 'iouEdgeReport123';
const TEST_TRANSACTION_ID = 'iouEdgeTx456';

it.each([CONST.IOU.REPORT_ACTION_TYPE.REJECT, CONST.IOU.REPORT_ACTION_TYPE.CANCEL, CONST.IOU.REPORT_ACTION_TYPE.DELETE, CONST.IOU.REPORT_ACTION_TYPE.APPROVE])(
'renders IOU display text for IOU + %s action when transaction exists in Onyx',
async (subtype) => {
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, {
transactionID: TEST_TRANSACTION_ID,
amount: 4200,
currency: 'USD',
reportID: TEST_REPORT_ID,
merchant: 'Test Merchant',
});
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`, {
reportID: TEST_REPORT_ID,
type: CONST.REPORT.TYPE.EXPENSE,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
});
});

const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.IOU, {
type: subtype,
IOUReportID: TEST_REPORT_ID,
IOUTransactionID: TEST_TRANSACTION_ID,
amount: 4200,
currency: 'USD',
});
renderItemWithAction(action);
await waitForBatchedUpdatesWithAct();

// getIOUReportActionDisplayMessage only special-cases `type === 'pay'`. For all other action
// types, the displayed text is picked from the REPORT's state (isSettled, isReportApproved)
// and the action's structural shape (split bill, track expense).
expect(screen.getByText(translateLocal('iou.expenseAmount', '-$42.00', 'Test Merchant'))).toBeOnTheScreen();
},
);

it('renders flagged content text instead of IOU display when isHidden is true', async () => {
const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.IOU, {
type: CONST.IOU.REPORT_ACTION_TYPE.REJECT,
IOUReportID: TEST_REPORT_ID,
IOUTransactionID: TEST_TRANSACTION_ID,
amount: 4200,
currency: 'USD',
});

render(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, HTMLEngineProvider]}>
<OptionsListContextProvider>
<ScreenWrapper testID="test">
<PortalProvider>
<ReportActionItemMessage
action={action}
displayAsGroup={false}
reportID={TEST_REPORT_ID}
isHidden
/>
</PortalProvider>
</ScreenWrapper>
</OptionsListContextProvider>
</ComposeProviders>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.getByText(translateLocal('moderation.flaggedContent'))).toBeOnTheScreen();
});
});
});
Loading