diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 60a350410ce31..da76fb0bb6405 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8353,6 +8353,8 @@ const CONST = {
},
RECEIPT: {
IMAGE: 'Receipt-Image',
+ ENLARGE_BUTTON: 'Receipt-EnlargeButton',
+ ADD_ATTACHMENT_BUTTON: 'Receipt-AddAttachmentButton',
},
RECEIPT_MODAL: {
REPLACE_RECEIPT: 'ReceiptModal-ReplaceReceipt',
diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx
index 79b541baf632f..299da8b18bafd 100644
--- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx
@@ -4,24 +4,32 @@ import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import AttachmentPicker from '@components/AttachmentPicker';
+import Icon from '@components/Icon';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit';
import ReceiptEmptyState from '@components/ReceiptEmptyState';
import useActiveRoute from '@hooks/useActiveRoute';
+import useAncestors from '@hooks/useAncestors';
import useCardFeedErrors from '@hooks/useCardFeedErrors';
import useConfirmModal from '@hooks/useConfirmModal';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useEnvironment from '@hooks/useEnvironment';
import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction';
+import useHover from '@hooks/useHover';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useOriginalReportID from '@hooks/useOriginalReportID';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionViolations from '@hooks/useTransactionViolations';
import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from '@libs/ErrorUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
@@ -46,9 +54,10 @@ import {
} from '@libs/TransactionUtils';
import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils';
import Navigation from '@navigation/Navigation';
+import variables from '@styles/variables';
import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors';
import {cleanUpMoneyRequest, replaceReceipt} from '@userActions/IOU';
-import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report';
+import {addAttachmentWithComment, navigateToConciergeChatAndDeleteReport} from '@userActions/Report';
import {clearError, getLastModifiedExpense, revert} from '@userActions/Transaction';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -131,7 +140,12 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction);
const isInvoice = isInvoiceReport(moneyRequestReport);
const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID);
- const {login: currentUserLogin, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
+ const {login: currentUserLogin, accountID: currentUserAccountID, timezone: currentUserTimezone} = useCurrentUserPersonalDetails();
+ const theme = useTheme();
+ const ancestors = useAncestors(report);
+ const {hovered, bind: hoverBind} = useHover();
+ const isTouchScreen = canUseTouchScreen();
+ const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']);
// Flags for allowing or disallowing editing an expense
// Used for non-restricted fields such as: description, category, tag, billable, etc...
@@ -322,6 +336,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
const isMapDistanceRequest = !!transaction && isDistanceRequest && !isManualDistanceRequest(transaction);
+ const canShowReceiptActions = hasReceipt && !isLoading && isEditable && !isMapDistanceRequest && !mergeTransactionID;
+
const receiptAuditMessagesRow = (
@@ -423,6 +439,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
showBorderlessLoading && styles.flex1,
fillSpace && !shouldShowReceiptEmptyState && isMapDistanceRequest && styles.flex1,
]}
+ onMouseEnter={() => !isLoading && hoverBind.onMouseEnter()}
+ onMouseLeave={hoverBind.onMouseLeave}
>
setIsLoading(false)}
onLoadFailure={() => setIsLoading(false)}
/>
+ {canShowReceiptActions && (
+
+
+ {({openPicker}) => (
+ {
+ openPicker({
+ onPicked: (files) => {
+ if (!report?.reportID) {
+ return;
+ }
+ addAttachmentWithComment({
+ report,
+ notifyReportID: moneyRequestReport?.reportID ?? report.reportID,
+ ancestors,
+ attachments: files,
+ currentUserAccountID,
+ timezone: currentUserTimezone,
+ });
+ },
+ });
+ }}
+ style={styles.receiptActionButton}
+ accessibilityLabel={translate('reportActionCompose.addAttachment')}
+ role={CONST.ROLE.BUTTON}
+ sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ADD_ATTACHMENT_BUTTON}
+ >
+
+
+
+
+ )}
+
+
+ Navigation.navigate(
+ ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID, (updatedTransaction ?? transaction)?.transactionID, readonly || !canEditReceipt),
+ )
+ }
+ style={styles.receiptActionButton}
+ accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ role={CONST.ROLE.BUTTON}
+ sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ENLARGE_BUTTON}
+ >
+
+
+
+
+
+ )}
)}
{/* For WideRHP (fillSpace is true), we need to wait for the image to load to get the correct size, then display the violation message to avoid the jumping issue.
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 8db662549d447..f839bd0fa2511 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3622,6 +3622,21 @@ const staticStyles = (theme: ThemeColors) =>
right: 20,
},
+ receiptActionButtonsContainer: {
+ position: 'absolute',
+ top: 16,
+ right: 16,
+ flexDirection: 'row',
+ gap: 8,
+ },
+
+ receiptActionButton: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
bgGreenSuccess: {
backgroundColor: colors.green400,
},
diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx
index e4e81de60e4e3..6a14647d407e4 100644
--- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx
+++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx
@@ -35,6 +35,51 @@ jest.mock(
},
);
+jest.mock('@components/ReportActionItem/ReportActionItemImage', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
+ const {useEffect} = require('react');
+ function MockReportActionItemImage({onLoad}: {onLoad?: () => void}) {
+ (useEffect as typeof React.useEffect)(() => {
+ onLoad?.();
+ }, [onLoad]);
+ return null;
+ }
+ return MockReportActionItemImage;
+});
+
+jest.mock('@src/languages/IntlStore', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ const en: Record = require('@src/languages/en').default;
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ const flatten: (obj: Record) => Record = require('@src/languages/flattenObject').default;
+ const cache = new Map>();
+ cache.set('en', flatten(en));
+ return {
+ getCurrentLocale: jest.fn(() => 'en'),
+ load: jest.fn(() => Promise.resolve()),
+ get: jest.fn((key: string, locale?: string) => {
+ const translations = cache.get(locale ?? 'en');
+ return translations?.[key] ?? null;
+ }),
+ };
+});
+
+jest.mock('@assets/emojis', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const actual = jest.requireActual('@assets/emojis');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ ...actual,
+ // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
+ default: actual.default,
+ importEmojiLocale: jest.fn(() => Promise.resolve()),
+ };
+});
+
+jest.mock('@libs/EmojiTrie', () => ({
+ buildEmojisTrie: jest.fn(),
+}));
+
// Override IDs so we control Onyx keys and can use evictableKeys for REPORT_ACTIONS
const TEST_PARENT_REPORT_ID = 'testParentReportID';
const TEST_REPORT_ID = 'testReportID';
@@ -122,6 +167,22 @@ const transactionWithoutReceipt: Transaction = {
originalCurrency: '',
};
+const transactionWithReceipt: Transaction = {
+ ...transactionWithoutReceipt,
+ receipt: {
+ state: CONST.IOU.RECEIPT_STATE.OPEN,
+ source: 'https://example.com/receipt.jpg',
+ },
+};
+
+const transactionWithScanningReceipt: Transaction = {
+ ...transactionWithoutReceipt,
+ receipt: {
+ state: CONST.IOU.RECEIPT_STATE.SCANNING,
+ source: 'https://example.com/receipt.jpg',
+ },
+};
+
function Wrapper({children}: {children: React.ReactNode}) {
return {children};
}
@@ -175,4 +236,72 @@ describe('MoneyRequestReceiptView', () => {
expect(onPicked).toBeDefined();
});
});
+
+ describe('receipt action buttons visibility', () => {
+ it('does not show action buttons when transaction has no receipt', async () => {
+ render(
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull();
+ expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull();
+ });
+
+ it('shows action buttons when transaction has a receipt', async () => {
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt);
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ render(
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy();
+ expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy();
+ });
+
+ it('shows action buttons when receipt is scanning', async () => {
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithScanningReceipt);
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ render(
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy();
+ expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy();
+ });
+
+ it('does not show action buttons in readonly mode', async () => {
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt);
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ render(
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull();
+ expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull();
+ });
+ });
});