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,