diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index a89efa7dcb44..108ad3da0d82 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -6,8 +6,6 @@ import Avatar from '@components/Avatar';
import AvatarWithDisplayName from '@components/AvatarWithDisplayName';
import Header from '@components/Header';
import Icon from '@components/Icon';
-// eslint-disable-next-line no-restricted-imports
-import * as Expensicons from '@components/Icon/Expensicons';
import PinButton from '@components/PinButton';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import SearchButton from '@components/Search/SearchRouter/SearchButton';
@@ -36,6 +34,7 @@ function HeaderWithBackButton({
onBackButtonPress = () => Navigation.goBack(),
onCloseButtonPress = () => Navigation.dismissModal(),
onDownloadButtonPress = () => {},
+ onRotateButtonPress = () => {},
onThreeDotsButtonPress = () => {},
report,
policyAvatar,
@@ -46,6 +45,8 @@ function HeaderWithBackButton({
shouldShowCloseButton = false,
shouldShowDownloadButton = false,
isDownloading = false,
+ shouldShowRotateButton = false,
+ isRotating = false,
shouldShowPinButton = false,
shouldSetModalVisibility = true,
shouldShowThreeDotsButton = false,
@@ -75,7 +76,7 @@ function HeaderWithBackButton({
shouldMinimizeMenuButton = false,
openParentReportInCurrentTab = false,
}: HeaderWithBackButtonProps) {
- const icons = useMemoizedLazyExpensifyIcons(['Download']);
+ const icons = useMemoizedLazyExpensifyIcons(['Download', 'Rotate', 'BackArrow', 'Close']);
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -223,7 +224,7 @@ function HeaderWithBackButton({
id={CONST.BACK_BUTTON_NATIVE_ID}
>
@@ -280,6 +281,24 @@ function HeaderWithBackButton({
) : (
))}
+ {shouldShowRotateButton &&
+ (!isRotating ? (
+
+
+
+
+
+ ) : (
+
+ ))}
{shouldShowPinButton && !!report && }
{ThreeDotMenuButton}
@@ -292,7 +311,7 @@ function HeaderWithBackButton({
accessibilityLabel={translate('common.close')}
>
diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts
index 65f9bb45b5d3..03632ba9c0e6 100644
--- a/src/components/HeaderWithBackButton/types.ts
+++ b/src/components/HeaderWithBackButton/types.ts
@@ -51,6 +51,9 @@ type HeaderWithBackButtonProps = Partial & {
/** Method to trigger when pressing download button of the header */
onDownloadButtonPress?: () => void;
+ /** Method to trigger when pressing rotate button of the header */
+ onRotateButtonPress?: () => void;
+
/** Method to trigger when pressing close button of the header */
onCloseButtonPress?: () => void;
@@ -72,6 +75,12 @@ type HeaderWithBackButtonProps = Partial & {
/** Whether we should show a loading indicator replacing the download button */
isDownloading?: boolean;
+ /** Whether we should show a rotate button */
+ shouldShowRotateButton?: boolean;
+
+ /** Whether we should show a loading indicator replacing the rotate button */
+ isRotating?: boolean;
+
/** Whether we should show a pin button */
shouldShowPinButton?: boolean;
diff --git a/src/libs/fetchImage/index.native.ts b/src/libs/fetchImage/index.native.ts
new file mode 100644
index 000000000000..78bb44ee0fd2
--- /dev/null
+++ b/src/libs/fetchImage/index.native.ts
@@ -0,0 +1,23 @@
+import RNFetchBlob from 'react-native-blob-util';
+import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils';
+import CONST from '@src/CONST';
+
+export default function fetchImage(source: string, authToken: string) {
+ // Create a unique filename based on timestamp
+ const timestamp = Date.now();
+ const extension = splitExtensionFromFileName(source).fileExtension || CONST.IMAGE_FILE_FORMAT.JPG;
+ const filename = `temp_image_${timestamp}.${extension}`;
+ const path = `${RNFetchBlob.fs.dirs.CacheDir}/${filename}`;
+
+ return RNFetchBlob.config({
+ fileCache: true,
+ path,
+ })
+ .fetch('GET', source, {
+ [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
+ })
+ .then((res) => {
+ // Return the file URI with file:// prefix for expo-image-manipulator
+ return `file://${res.path()}`;
+ });
+}
diff --git a/src/libs/fetchImage/index.ts b/src/libs/fetchImage/index.ts
new file mode 100644
index 000000000000..d9119c57aa14
--- /dev/null
+++ b/src/libs/fetchImage/index.ts
@@ -0,0 +1,13 @@
+import CONST from '@src/CONST';
+
+export default function fetchImage(source: string, authToken: string) {
+ return fetch(source, {
+ headers: {
+ [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
+ },
+ })
+ .then((res) => res.blob())
+ .then((blob) => {
+ return URL.createObjectURL(blob);
+ });
+}
diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
index 226b691e514b..04dffe28418a 100644
--- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
+++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
@@ -54,6 +54,9 @@ function AttachmentModalBaseContent({
shouldShowCarousel = true,
shouldDisableSendButton = false,
shouldDisplayHelpButton = false,
+ shouldShowRotateButton = false,
+ onRotateButtonPress,
+ isRotating = false,
submitRef,
onDownloadAttachment,
onClose,
@@ -290,6 +293,9 @@ function AttachmentModalBaseContent({
title={headerTitle ?? translate('common.attachment')}
shouldShowBorderBottom
shouldShowDownloadButton={shouldShowDownloadButton}
+ shouldShowRotateButton={shouldShowRotateButton}
+ onRotateButtonPress={onRotateButtonPress}
+ isRotating={isRotating}
shouldDisplayHelpButton={shouldDisplayHelpButton}
onDownloadButtonPress={() => onDownloadAttachment?.({file: fileToDisplay, source})}
shouldShowCloseButton={!shouldUseNarrowLayout}
diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
index 7ec62800de35..b6c1d3792e4a 100644
--- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
+++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
@@ -90,6 +90,15 @@ type AttachmentModalBaseContentProps = {
/** Whether to show download button */
shouldShowDownloadButton?: boolean;
+ /** Whether to show rotate button */
+ shouldShowRotateButton?: boolean;
+
+ /** Callback triggered when the rotate button is pressed */
+ onRotateButtonPress?: () => void;
+
+ /** Whether we should show a loading indicator replacing the rotate button */
+ isRotating?: boolean;
+
/** Whether to disable send button */
shouldDisableSendButton?: boolean;
diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
index a4279b2eebcf..f28d4c724db9 100644
--- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
+++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
@@ -1,3 +1,4 @@
+import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import ConfirmModal from '@components/ConfirmModal';
// eslint-disable-next-line no-restricted-imports
@@ -7,8 +8,10 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
-import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead} from '@libs/actions/IOU';
+import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt} from '@libs/actions/IOU';
import {openReport} from '@libs/actions/Report';
+import cropOrRotateImage from '@libs/cropOrRotateImage';
+import fetchImage from '@libs/fetchImage';
import Navigation from '@libs/Navigation/Navigation';
import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
import {getReportAction, isTrackExpenseAction} from '@libs/ReportActionsUtils';
@@ -22,6 +25,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type {ReceiptSource} from '@src/types/onyx/Transaction';
import useDownloadAttachment from './hooks/useDownloadAttachment';
function TransactionReceiptModalContent({navigation, route}: AttachmentModalScreenProps) {
@@ -37,6 +41,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true});
const [reportMetadata = CONST.DEFAULT_REPORT_METADATA] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true});
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`, {canBeMissing: true});
+ const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true});
const policy = usePolicy(report?.policyID);
// If we have a merge transaction, we need to use the receipt from the merge transaction
@@ -68,6 +73,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const readonly = readonlyParam === 'true';
const isFromReviewDuplicates = isFromReviewDuplicatesParam === 'true';
const source = isDraftTransaction ? transactionDraft?.receipt?.source : tryResolveUrlFromApiRoot(receiptURIs.image ?? '');
+ const [sourceUri, setSourceUri] = useState('');
const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
const canEditReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT);
@@ -80,7 +86,11 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const isTrackExpenseActionValue = isTrackExpenseAction(parentReportAction);
const iouType = useMemo(() => iouTypeParam ?? (isTrackExpenseActionValue ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseActionValue, iouTypeParam]);
+ const receiptFilename = transaction?.receipt?.filename;
+ const isImage = !!receiptFilename && Str.isImage(receiptFilename);
+
const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false);
+ const [isRotating, setIsRotating] = useState(false);
useEffect(() => {
if ((!!report && !!transaction) || isDraftTransaction) {
@@ -91,6 +101,27 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
+ useEffect(() => {
+ if (!source || !isImage) {
+ return;
+ }
+
+ if (!isAuthTokenRequired || typeof source !== 'string') {
+ setSourceUri(source);
+ return;
+ }
+
+ if (!session?.encryptedAuthToken) {
+ return;
+ }
+
+ fetchImage(source, session?.encryptedAuthToken)
+ .then((uri) => {
+ setSourceUri(uri);
+ })
+ .catch(() => setSourceUri(''));
+ }, [source, isAuthTokenRequired, session?.encryptedAuthToken, isDraftTransaction, isImage]);
+
const receiptPath = transaction?.receipt?.source;
useEffect(() => {
@@ -99,7 +130,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
}
const requestType = getRequestType(transaction);
- const receiptFilename = transaction?.receipt?.filename;
const receiptType = transaction?.receipt?.type;
navigateToStartStepIfScanFileCannotBeRead(
receiptFilename,
@@ -127,6 +157,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
}, [receiptPath]);
const moneyRequestReportID = isMoneyRequestReport(report) ? report?.reportID : report?.parentReportID;
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
const isTrackExpenseReportValue = isTrackExpenseReport(report);
// eslint-disable-next-line rulesdir/no-negated-variables
@@ -153,6 +184,68 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const allowDownload = !isEReceipt;
+ /**
+ * Rotate the receipt image 90 degrees and save it automatically.
+ */
+ const rotateReceipt = useCallback(() => {
+ if (!transaction?.transactionID || !sourceUri || !isImage) {
+ return;
+ }
+
+ const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG;
+
+ setIsRotating(true);
+ cropOrRotateImage(sourceUri as string, [{rotate: -90}], {
+ compress: 1,
+ name: receiptFilename,
+ type: receiptType,
+ })
+ .then((rotatedImage) => {
+ if (!rotatedImage) {
+ setIsRotating(false);
+ return;
+ }
+
+ // Both web and native return objects with uri property
+ const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined;
+ if (!imageUriResult) {
+ setIsRotating(false);
+ return;
+ }
+
+ const file = rotatedImage as File;
+ const rotatedFilename = file.name ?? receiptFilename;
+
+ if (isDraftTransaction) {
+ // Update the transaction immediately so the modal displays the rotated image right away
+ setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, receiptType);
+ } else {
+ replaceReceipt({
+ transactionID: transaction.transactionID,
+ file,
+ source: imageUriResult,
+ transactionPolicyCategories: policyCategories,
+ transactionPolicy: policy,
+ });
+ }
+ setIsRotating(false);
+ })
+ .catch(() => {
+ setIsRotating(false);
+ });
+ }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type, policy]);
+
+ const shouldShowRotateReceiptButton = useMemo(
+ () =>
+ shouldShowReplaceReceiptButton &&
+ transaction &&
+ hasReceiptSource(transaction) &&
+ !isEReceipt &&
+ !transaction?.receipt?.isTestDriveReceipt &&
+ (receiptFilename ? Str.isImage(receiptFilename) : false),
+ [shouldShowReplaceReceiptButton, transaction, isEReceipt, receiptFilename],
+ );
+
const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback(
({file, source: innerSource, isLocalSource}) => {
const menuItems = [];
@@ -241,6 +334,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
isLoading: !transaction && reportMetadata?.isLoadingInitialReportActions,
shouldShowNotFoundPage,
shouldShowCarousel: false,
+ shouldShowRotateButton: shouldShowRotateReceiptButton,
+ onRotateButtonPress: rotateReceipt,
+ isRotating,
onDownloadAttachment: allowDownload ? undefined : onDownloadAttachment,
transaction,
}),
@@ -254,6 +350,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
report,
reportMetadata?.isLoadingInitialReportActions,
shouldShowNotFoundPage,
+ shouldShowRotateReceiptButton,
+ rotateReceipt,
+ isRotating,
source,
threeDotsMenuItems,
transaction,