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
29 changes: 24 additions & 5 deletions src/components/HeaderWithBackButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,6 +34,7 @@ function HeaderWithBackButton({
onBackButtonPress = () => Navigation.goBack(),
onCloseButtonPress = () => Navigation.dismissModal(),
onDownloadButtonPress = () => {},
onRotateButtonPress = () => {},
onThreeDotsButtonPress = () => {},
report,
policyAvatar,
Expand All @@ -46,6 +45,8 @@ function HeaderWithBackButton({
shouldShowCloseButton = false,
shouldShowDownloadButton = false,
isDownloading = false,
shouldShowRotateButton = false,
isRotating = false,
shouldShowPinButton = false,
shouldSetModalVisibility = true,
shouldShowThreeDotsButton = false,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -223,7 +224,7 @@ function HeaderWithBackButton({
id={CONST.BACK_BUTTON_NATIVE_ID}
>
<Icon
src={Expensicons.BackArrow}
src={icons.BackArrow}
fill={iconFill ?? theme.icon}
/>
</PressableWithoutFeedback>
Expand Down Expand Up @@ -280,6 +281,24 @@ function HeaderWithBackButton({
) : (
<ActivityIndicator style={[styles.touchableButtonImage]} />
))}
{shouldShowRotateButton &&
(!isRotating ? (
<Tooltip text={translate('common.rotate')}>
<PressableWithoutFeedback
onPress={onRotateButtonPress}
style={[styles.touchableButtonImage]}
role="button"
accessibilityLabel={translate('common.rotate')}
>
<Icon
src={icons.Rotate}
fill={iconFill ?? theme.icon}
/>
</PressableWithoutFeedback>
</Tooltip>
) : (
<ActivityIndicator style={[styles.touchableButtonImage]} />
))}
{shouldShowPinButton && !!report && <PinButton report={report} />}
</View>
{ThreeDotMenuButton}
Expand All @@ -292,7 +311,7 @@ function HeaderWithBackButton({
accessibilityLabel={translate('common.close')}
>
<Icon
src={Expensicons.Close}
src={icons.Close}
fill={iconFill ?? theme.icon}
/>
</PressableWithoutFeedback>
Expand Down
9 changes: 9 additions & 0 deletions src/components/HeaderWithBackButton/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type HeaderWithBackButtonProps = Partial<ChildrenProps> & {
/** 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;

Expand All @@ -72,6 +75,12 @@ type HeaderWithBackButtonProps = Partial<ChildrenProps> & {
/** 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;

Expand Down
23 changes: 23 additions & 0 deletions src/libs/fetchImage/index.native.ts
Original file line number Diff line number Diff line change
@@ -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()}`;
});
}
13 changes: 13 additions & 0 deletions src/libs/fetchImage/index.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ function AttachmentModalBaseContent({
shouldShowCarousel = true,
shouldDisableSendButton = false,
shouldDisplayHelpButton = false,
shouldShowRotateButton = false,
onRotateButtonPress,
isRotating = false,
submitRef,
onDownloadAttachment,
onClose,
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';
Expand All @@ -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<typeof SCREENS.TRANSACTION_RECEIPT>) {
Expand All @@ -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
Expand Down Expand Up @@ -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<ReceiptSource>('');

const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
const canEditReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT);
Expand All @@ -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) {
Expand All @@ -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(() => {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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,
}),
Expand All @@ -254,6 +350,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
report,
reportMetadata?.isLoadingInitialReportActions,
shouldShowNotFoundPage,
shouldShowRotateReceiptButton,
rotateReceipt,
isRotating,
source,
threeDotsMenuItems,
transaction,
Expand Down
Loading