From 638754b168ef41bb709e61874def2f158bfae577 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 19 Nov 2025 04:23:53 +0700 Subject: [PATCH 01/12] Add Rotate button to receipt modal --- src/components/HeaderWithBackButton/index.tsx | 17 ++++ src/components/HeaderWithBackButton/types.ts | 6 ++ .../AttachmentModalBaseContent/index.tsx | 4 + .../AttachmentModalBaseContent/types.ts | 6 ++ .../routes/TransactionReceiptModalContent.tsx | 81 ++++++++++++++++++- 5 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 8df34b97933ae..b49cd3d204cba 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -34,6 +34,7 @@ function HeaderWithBackButton({ onBackButtonPress = () => Navigation.goBack(), onCloseButtonPress = () => Navigation.dismissModal(), onDownloadButtonPress = () => {}, + onRotateButtonPress = () => {}, onThreeDotsButtonPress = () => {}, report, policyAvatar, @@ -44,6 +45,7 @@ function HeaderWithBackButton({ shouldShowCloseButton = false, shouldShowDownloadButton = false, isDownloading = false, + shouldShowRotateButton = false, shouldShowPinButton = false, shouldSetModalVisibility = true, shouldShowThreeDotsButton = false, @@ -277,6 +279,21 @@ function HeaderWithBackButton({ ) : ( ))} + {shouldShowRotateButton && ( + + + + + + )} {shouldShowPinButton && !!report && } {ThreeDotMenuButton} diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 7bc7df03b639e..cb687f8a24a5b 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,9 @@ 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 pin button */ shouldShowPinButton?: boolean; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 5e6d9dc901f69..2195cd6fbc8cb 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -53,6 +53,8 @@ function AttachmentModalBaseContent({ shouldShowCarousel = true, shouldDisableSendButton = false, shouldDisplayHelpButton = false, + shouldShowRotateButton = false, + onRotateButtonPress, submitRef, onDownloadAttachment, onClose, @@ -288,6 +290,8 @@ function AttachmentModalBaseContent({ title={headerTitle ?? translate('common.attachment')} shouldShowBorderBottom shouldShowDownloadButton={shouldShowDownloadButton} + shouldShowRotateButton={shouldShowRotateButton} + onRotateButtonPress={onRotateButtonPress} 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 7ec62800de35b..b84879519f496 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -90,6 +90,12 @@ 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 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 ed52569e23ee5..c2f0b373b7c39 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 {InteractionManager} from 'react-native'; import ConfirmModal from '@components/ConfirmModal'; @@ -5,8 +6,9 @@ import * as Expensicons from '@components/Icon/Expensicons'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead} from '@libs/actions/IOU'; +import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead, setMoneyRequestReceipt} from '@libs/actions/IOU'; import {openReport} from '@libs/actions/Report'; +import cropOrRotateImage from '@libs/cropOrRotateImage'; import getReceiptFilenameFromTransaction from '@libs/getReceiptFilenameFromTransaction'; import {getReceiptFileName} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -151,6 +153,79 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const allowDownload = !isEReceipt; + /** + * Rotate the receipt image 90 degrees and save it automatically. + */ + const rotateReceipt = useCallback(() => { + if (!transaction?.transactionID || !source || typeof source !== 'string') { + return; + } + + const receiptFilename = getReceiptFilenameFromTransaction(transaction); + if (!receiptFilename || !Str.isImage(receiptFilename)) { + return; + } + + const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG; + const imageUri = isDraftTransaction && typeof source === 'string' ? source : receiptURIs.image ?? ''; + + if (!imageUri || typeof imageUri !== 'string') { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + try { + const rotatedImage = await cropOrRotateImage( + imageUri, + [{rotate: 90}], + { + compress: 1, + name: receiptFilename, + type: receiptType, + }, + ); + + if (!rotatedImage) { + return; + } + + // Both web and native return objects with uri property + const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined; + if (!imageUriResult) { + return; + } + + const file = rotatedImage as File; + const rotatedFilename = file.name ?? receiptFilename; + + // Update the transaction immediately so the modal displays the rotated image right away + setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, receiptType); + + // // Then save it to the backend + // replaceReceipt({ + // transactionID: transaction.transactionID, + // file, + // source: imageUriResult, + // transactionPolicyCategories: policyCategories, + // }); + } catch (error) { + // Silently fail if rotation fails + } + })(); + }, [transaction, source, isDraftTransaction, receiptURIs.image]); + + const shouldShowRotateReceiptButton = + shouldShowReplaceReceiptButton && + transaction && + hasReceiptSource(transaction) && + !isEReceipt && + !transaction?.receipt?.isTestDriveReceipt && + (() => { + const receiptFilename = getReceiptFilenameFromTransaction(transaction); + return receiptFilename ? Str.isImage(receiptFilename) : false; + })(); + const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback( ({file, source: innerSource, isLocalSource}) => { const menuItems = []; @@ -238,6 +313,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre isLoading: !transaction && reportMetadata?.isLoadingInitialReportActions, shouldShowNotFoundPage, shouldShowCarousel: false, + shouldShowRotateButton: shouldShowRotateReceiptButton, + onRotateButtonPress: rotateReceipt, onDownloadAttachment: allowDownload ? undefined : onDownloadAttachment, transaction, }), @@ -251,6 +328,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre report, reportMetadata?.isLoadingInitialReportActions, shouldShowNotFoundPage, + shouldShowRotateReceiptButton, + rotateReceipt, source, threeDotsMenuItems, transaction, From 7680cd6c1900d7c7fd615d15776ec8dab527f657 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 25 Nov 2025 23:15:28 +0700 Subject: [PATCH 02/12] refactor code --- .../routes/TransactionReceiptModalContent.tsx | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 3348d5faa1156..2a89e8b6848ea 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -176,19 +176,16 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return; } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - (async () => { - try { - const rotatedImage = await cropOrRotateImage( - imageUri, - [{rotate: 90}], - { - compress: 1, - name: receiptFilename, - type: receiptType, - }, - ); - + cropOrRotateImage( + imageUri, + [{rotate: 90}], + { + compress: 1, + name: receiptFilename, + type: receiptType, + }, + ) + .then((rotatedImage) => { if (!rotatedImage) { return; } @@ -202,32 +199,33 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const file = rotatedImage as File; const rotatedFilename = file.name ?? receiptFilename; - // Update the transaction immediately so the modal displays the rotated image right away - setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, receiptType); + 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, + // }); + } // // Then save it to the backend - // replaceReceipt({ - // transactionID: transaction.transactionID, - // file, - // source: imageUriResult, - // transactionPolicyCategories: policyCategories, - // }); - } catch (error) { + }) + .catch(() => { // Silently fail if rotation fails - } - })(); + }); }, [transaction, source, isDraftTransaction, receiptURIs.image]); + const receiptFilenameForRotation = transaction ? getReceiptFilenameFromTransaction(transaction) : undefined; const shouldShowRotateReceiptButton = shouldShowReplaceReceiptButton && transaction && hasReceiptSource(transaction) && !isEReceipt && !transaction?.receipt?.isTestDriveReceipt && - (() => { - const receiptFilename = getReceiptFilenameFromTransaction(transaction); - return receiptFilename ? Str.isImage(receiptFilename) : false; - })(); + (receiptFilenameForRotation ? Str.isImage(receiptFilenameForRotation) : false); const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback( ({file, source: innerSource, isLocalSource}) => { From af03edd1271775931da009148d49c925583617b6 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 25 Nov 2025 23:21:50 +0700 Subject: [PATCH 03/12] run prettier --- .../routes/TransactionReceiptModalContent.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 2a89e8b6848ea..0300bef70a9c9 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -170,21 +170,17 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre } const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG; - const imageUri = isDraftTransaction && typeof source === 'string' ? source : receiptURIs.image ?? ''; + const imageUri = isDraftTransaction && typeof source === 'string' ? source : (receiptURIs.image ?? ''); if (!imageUri || typeof imageUri !== 'string') { return; } - cropOrRotateImage( - imageUri, - [{rotate: 90}], - { - compress: 1, - name: receiptFilename, - type: receiptType, - }, - ) + cropOrRotateImage(imageUri, [{rotate: 90}], { + compress: 1, + name: receiptFilename, + type: receiptType, + }) .then((rotatedImage) => { if (!rotatedImage) { return; From f91ac077785e205ad0e6bc1fdfd1237aa541d7e3 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 2 Dec 2025 23:02:08 +0700 Subject: [PATCH 04/12] implement rotate for edit receipt --- src/libs/fetchImage/index.native.ts | 22 ++++ src/libs/fetchImage/index.ts | 13 +++ .../routes/TransactionReceiptModalContent.tsx | 104 ++++++++++-------- 3 files changed, 93 insertions(+), 46 deletions(-) create mode 100644 src/libs/fetchImage/index.native.ts create mode 100644 src/libs/fetchImage/index.ts diff --git a/src/libs/fetchImage/index.native.ts b/src/libs/fetchImage/index.native.ts new file mode 100644 index 0000000000000..a2b61e2509dbd --- /dev/null +++ b/src/libs/fetchImage/index.native.ts @@ -0,0 +1,22 @@ +import RNFetchBlob from 'react-native-blob-util'; +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 = source.split('.').pop()?.split('?')[0] || '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 0000000000000..d9119c57aa142 --- /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/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 0300bef70a9c9..fe6e3da5a8451 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -8,9 +8,10 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead, setMoneyRequestReceipt} 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 getReceiptFilenameFromTransaction from '@libs/getReceiptFilenameFromTransaction'; import {getReceiptFileName} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -26,6 +27,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) { @@ -40,6 +42,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}); // If we have a merge transaction, we need to use the receipt from the merge transaction const [mergeTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, {canBeMissing: true}); @@ -71,6 +74,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); @@ -83,6 +87,9 @@ 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 = getReceiptFilenameFromTransaction(transaction); + const isImage = !!receiptFilename && Str.isImage(receiptFilename); + const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); useEffect(() => { @@ -94,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 (isLocalFile || typeof source !== 'string') { + setSourceUri(source); + return; + } + + if (!session?.encryptedAuthToken) { + return; + } + + fetchImage(source, session?.encryptedAuthToken) + .then((uri) => { + setSourceUri(uri); + }) + .catch(() => setSourceUri('')); + }, [source, isLocalFile, session?.encryptedAuthToken, isDraftTransaction]); + const receiptPath = transaction?.receipt?.source; useEffect(() => { @@ -160,59 +188,43 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre * Rotate the receipt image 90 degrees and save it automatically. */ const rotateReceipt = useCallback(() => { - if (!transaction?.transactionID || !source || typeof source !== 'string') { - return; - } - - const receiptFilename = getReceiptFilenameFromTransaction(transaction); - if (!receiptFilename || !Str.isImage(receiptFilename)) { + if (!transaction?.transactionID || !source || typeof source !== 'string' || !sourceUri || !isImage) { return; } const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG; - const imageUri = isDraftTransaction && typeof source === 'string' ? source : (receiptURIs.image ?? ''); - if (!imageUri || typeof imageUri !== 'string') { - return; - } - - cropOrRotateImage(imageUri, [{rotate: 90}], { + cropOrRotateImage(sourceUri as string, [{rotate: 90}], { compress: 1, name: receiptFilename, type: receiptType, - }) - .then((rotatedImage) => { - if (!rotatedImage) { - return; - } - - // Both web and native return objects with uri property - const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined; - if (!imageUriResult) { - 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, - // }); - } - - // // Then save it to the backend - }) - .catch(() => { - // Silently fail if rotation fails - }); - }, [transaction, source, isDraftTransaction, receiptURIs.image]); + }).then((rotatedImage) => { + if (!rotatedImage) { + return; + } + + // Both web and native return objects with uri property + const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined; + if (!imageUriResult) { + 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, + }); + } + }); + }, [transaction, source, isDraftTransaction, receiptURIs.image, sourceUri, isImage, receiptFilename, policyCategories]); const receiptFilenameForRotation = transaction ? getReceiptFilenameFromTransaction(transaction) : undefined; const shouldShowRotateReceiptButton = From ccef49120ece36be02044d9bc190566334bde2e4 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 2 Dec 2025 23:09:27 +0700 Subject: [PATCH 05/12] fix eslint --- src/libs/fetchImage/index.native.ts | 2 +- .../routes/TransactionReceiptModalContent.tsx | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/libs/fetchImage/index.native.ts b/src/libs/fetchImage/index.native.ts index a2b61e2509dbd..c25f4199e92e3 100644 --- a/src/libs/fetchImage/index.native.ts +++ b/src/libs/fetchImage/index.native.ts @@ -4,7 +4,7 @@ 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 = source.split('.').pop()?.split('?')[0] || 'jpg'; + const extension = source.split('.').pop()?.split('?')?.at(0) ?? 'jpg'; const filename = `temp_image_${timestamp}.${extension}`; const path = `${RNFetchBlob.fs.dirs.CacheDir}/${filename}`; diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index fe6e3da5a8451..a7ce8723973cb 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -120,7 +120,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre setSourceUri(uri); }) .catch(() => setSourceUri('')); - }, [source, isLocalFile, session?.encryptedAuthToken, isDraftTransaction]); + }, [source, isLocalFile, session?.encryptedAuthToken, isDraftTransaction, isImage]); const receiptPath = transaction?.receipt?.source; @@ -130,7 +130,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre } const requestType = getRequestType(transaction); - const receiptFilename = getReceiptFilenameFromTransaction(transaction); const receiptType = transaction?.receipt?.type; navigateToStartStepIfScanFileCannotBeRead( receiptFilename, @@ -224,16 +223,15 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); } }); - }, [transaction, source, isDraftTransaction, receiptURIs.image, sourceUri, isImage, receiptFilename, policyCategories]); + }, [transaction?.transactionID, source, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories]); - const receiptFilenameForRotation = transaction ? getReceiptFilenameFromTransaction(transaction) : undefined; const shouldShowRotateReceiptButton = shouldShowReplaceReceiptButton && transaction && hasReceiptSource(transaction) && !isEReceipt && !transaction?.receipt?.isTestDriveReceipt && - (receiptFilenameForRotation ? Str.isImage(receiptFilenameForRotation) : false); + (receiptFilename ? Str.isImage(receiptFilename) : false); const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback( ({file, source: innerSource, isLocalSource}) => { From 13b321270314b4e3680b9514c146482426558e8b Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 2 Dec 2025 23:25:34 +0700 Subject: [PATCH 06/12] fix perf comment --- .../routes/TransactionReceiptModalContent.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index a7ce8723973cb..60a9763669619 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -225,13 +225,16 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); }, [transaction?.transactionID, source, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories]); - const shouldShowRotateReceiptButton = - shouldShowReplaceReceiptButton && - transaction && - hasReceiptSource(transaction) && - !isEReceipt && - !transaction?.receipt?.isTestDriveReceipt && - (receiptFilename ? Str.isImage(receiptFilename) : false); + 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}) => { From c5490dac57a0533e39fbd53d22abecf653c756d8 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 4 Dec 2025 16:11:03 +0700 Subject: [PATCH 07/12] use splitExtensionFromFileName --- src/libs/fetchImage/index.native.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/fetchImage/index.native.ts b/src/libs/fetchImage/index.native.ts index c25f4199e92e3..78bb44ee0fd27 100644 --- a/src/libs/fetchImage/index.native.ts +++ b/src/libs/fetchImage/index.native.ts @@ -1,10 +1,11 @@ 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 = source.split('.').pop()?.split('?')?.at(0) ?? 'jpg'; + const extension = splitExtensionFromFileName(source).fileExtension || CONST.IMAGE_FILE_FORMAT.JPG; const filename = `temp_image_${timestamp}.${extension}`; const path = `${RNFetchBlob.fs.dirs.CacheDir}/${filename}`; From f898ba188dc63556623c81eeed4926e86f863e9e Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Sat, 6 Dec 2025 01:12:41 +0700 Subject: [PATCH 08/12] fix lint --- .../routes/TransactionReceiptModalContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index ea2f56444666c..af859e8ed2e27 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -221,7 +221,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); } }); - }, [transaction?.transactionID, source, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories]); + }, [transaction?.transactionID, source, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type]); const shouldShowRotateReceiptButton = useMemo( () => From febaf75ab5540fe3ccff25c02083d0477ba8ca4a Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 9 Dec 2025 22:43:03 +0700 Subject: [PATCH 09/12] fix issue on capture image --- .../routes/TransactionReceiptModalContent.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index f43acabbaae6d..2d8c4015da910 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -104,7 +104,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return; } - if (isLocalFile || typeof source !== 'string') { + if (!isAuthTokenRequired || typeof source !== 'string') { setSourceUri(source); return; } @@ -118,7 +118,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre setSourceUri(uri); }) .catch(() => setSourceUri('')); - }, [source, isLocalFile, session?.encryptedAuthToken, isDraftTransaction, isImage]); + }, [source, isAuthTokenRequired, session?.encryptedAuthToken, isDraftTransaction, isImage]); const receiptPath = transaction?.receipt?.source; @@ -185,13 +185,13 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre * Rotate the receipt image 90 degrees and save it automatically. */ const rotateReceipt = useCallback(() => { - if (!transaction?.transactionID || !source || typeof source !== 'string' || !sourceUri || !isImage) { + if (!transaction?.transactionID || !sourceUri || !isImage) { return; } const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG; - cropOrRotateImage(sourceUri as string, [{rotate: 90}], { + cropOrRotateImage(sourceUri as string, [{rotate: -90}], { compress: 1, name: receiptFilename, type: receiptType, @@ -221,7 +221,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); } }); - }, [transaction?.transactionID, source, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type]); + }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type]); const shouldShowRotateReceiptButton = useMemo( () => From 9f628e1c37ef8e4c93d77cd5cfb7bbe0de63ae97 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 9 Dec 2025 23:00:39 +0700 Subject: [PATCH 10/12] fix dependency --- .../routes/TransactionReceiptModalContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 93ce7cc7b3696..f021110373d4d 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -224,7 +224,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre }); } }); - }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type, transactionPolicy]); + }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type, policy]); const shouldShowRotateReceiptButton = useMemo( () => From 9442956bf568f911d1e216be265e5a23f8acefe8 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 16 Dec 2025 11:58:51 +0700 Subject: [PATCH 11/12] display a loading while rotating --- src/components/HeaderWithBackButton/index.tsx | 34 +++++----- src/components/HeaderWithBackButton/types.ts | 3 + .../AttachmentModalBaseContent/index.tsx | 2 + .../AttachmentModalBaseContent/types.ts | 3 + .../routes/TransactionReceiptModalContent.tsx | 66 +++++++++++-------- 5 files changed, 66 insertions(+), 42 deletions(-) diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 9ad6bf8da4cf5..a189505689545 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -47,6 +47,7 @@ function HeaderWithBackButton({ shouldShowDownloadButton = false, isDownloading = false, shouldShowRotateButton = false, + isRotating = false, shouldShowPinButton = false, shouldSetModalVisibility = true, shouldShowThreeDotsButton = false, @@ -281,21 +282,24 @@ function HeaderWithBackButton({ ) : ( ))} - {shouldShowRotateButton && ( - - - - - - )} + {shouldShowRotateButton && + (!isRotating ? ( + + + + + + ) : ( + + ))} {shouldShowPinButton && !!report && } {ThreeDotMenuButton} diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 0fe17a816c418..03632ba9c0e62 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -78,6 +78,9 @@ type HeaderWithBackButtonProps = Partial & { /** 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/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index df08770bd0fe1..04dffe28418a0 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -56,6 +56,7 @@ function AttachmentModalBaseContent({ shouldDisplayHelpButton = false, shouldShowRotateButton = false, onRotateButtonPress, + isRotating = false, submitRef, onDownloadAttachment, onClose, @@ -294,6 +295,7 @@ function AttachmentModalBaseContent({ 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 b84879519f496..b6c1d3792e4a5 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -96,6 +96,9 @@ type AttachmentModalBaseContentProps = { /** 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 e77fa26a5689d..66c72cbcc3171 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -90,6 +90,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const isImage = !!receiptFilename && Str.isImage(receiptFilename); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); + const [isRotating, setIsRotating] = useState(false); useEffect(() => { if ((!!report && !!transaction) || isDraftTransaction) { @@ -156,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 @@ -192,37 +194,45 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre 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) { - return; - } - - // Both web and native return objects with uri property - const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined; - if (!imageUriResult) { - 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, - }); - } - }); + }) + .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( @@ -326,6 +336,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre shouldShowCarousel: false, shouldShowRotateButton: shouldShowRotateReceiptButton, onRotateButtonPress: rotateReceipt, + isRotating, onDownloadAttachment: allowDownload ? undefined : onDownloadAttachment, transaction, }), @@ -341,6 +352,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre shouldShowNotFoundPage, shouldShowRotateReceiptButton, rotateReceipt, + isRotating, source, threeDotsMenuItems, transaction, From e20452568f88401d48081cce208c214abab6e139 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 16 Dec 2025 21:28:29 +0700 Subject: [PATCH 12/12] remove disable lint --- src/components/HeaderWithBackButton/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index a22e0abd4264f..108ad3da0d82a 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -6,7 +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 PinButton from '@components/PinButton'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import SearchButton from '@components/Search/SearchRouter/SearchButton';