From 8b72a83fca27efbd317df7eda85f65b8e293a69f Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 2 Jun 2025 13:35:08 +0200 Subject: [PATCH 01/79] feat: allow multiple files --- src/CONST.ts | 2 ++ .../AttachmentPickerWithMenuItems.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 00bcd438856a3..f42a9a0615007 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -451,6 +451,8 @@ const CONST = { // Allowed extensions for receipts ALLOWED_RECEIPT_EXTENSIONS: ['jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], + + MAX_FILE_LIMIT: 30, }, // Allowed extensions for spreadsheets import diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 77de23706b3ef..ed1cb430b2f87 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -132,7 +132,7 @@ function AttachmentPickerWithMenuItems({ const {isDelegateAccessRestricted} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {canBeMissing: true}); - const {canUseTableReportView} = usePermissions(); + const {canUseTableReportView, canUseMultiFilesDragAndDrop} = usePermissions(); const selectOption = useCallback( (onSelected: () => void, shouldRestrictAction: boolean) => { @@ -276,7 +276,10 @@ function AttachmentPickerWithMenuItems({ const createButtonContainerStyles = [styles.flexGrow0, styles.flexShrink0]; return ( - + {({openPicker}) => { const triggerAttachmentPicker = () => { onTriggerAttachmentPicker(); @@ -405,7 +408,10 @@ function AttachmentPickerWithMenuItems({ } }} anchorPosition={styles.createMenuPositionReportActionCompose(shouldUseNarrowLayout, windowHeight, windowWidth)} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} menuItems={menuItems} withoutOverlay anchorRef={actionButtonRef} From 19fa99849157747f2df72119b28893f3e796b3d8 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 15:42:52 +0200 Subject: [PATCH 02/79] feat: create a new function for multiple files --- src/CONST.ts | 3 ++- src/components/AttachmentModal.tsx | 13 +++++++++++++ src/components/AttachmentPicker/index.native.tsx | 2 +- src/components/AttachmentPicker/index.tsx | 1 + .../ReportActionCompose/ReportActionCompose.tsx | 12 +++++++++++- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index c55ecc6da3fb7..ec49e952000d5 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -439,7 +439,8 @@ const CONST = { // Allowed extensions for receipts ALLOWED_RECEIPT_EXTENSIONS: ['heif', 'heic', 'jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], - MAX_FILE_LIMIT: 30, + + MAX_FILE_LIMIT: 30, }, // Allowed extensions for spreadsheets import diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 8ba0dc0fd645e..496290e711ed5 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -64,6 +64,7 @@ type FileObject = Partial; type ChildrenProps = { displayFileInModal: (data: FileObject) => void; + displayMultipleFilesInModal: (data: FileObject[]) => void; show: () => void; }; @@ -360,6 +361,17 @@ function AttachmentModal({ return true; }, []); + const validateAndDisplayMultipleFilesToUpload = useCallback((data: FileObject[]) => { + if (!data?.length) { + return; + } + if (data.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + setIsAttachmentInvalid(true); + setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); + setAttachmentInvalidReason('attachmentPicker.tooManyFiles'); + } + }, []); + const validateAndDisplayFileToUpload = useCallback( (data: FileObject) => { if (!data || !isDirectoryCheck(data)) { @@ -697,6 +709,7 @@ function AttachmentModal({ {children?.({ displayFileInModal: validateAndDisplayFileToUpload, + displayMultipleFilesInModal: validateAndDisplayMultipleFilesToUpload, show: openModal, })} diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 12b7df0521522..9e47b38fb17a7 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -152,7 +152,7 @@ function AttachmentPicker({ return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); } - + // TODO: refactor this to use multiple files const targetAsset = response.assets?.[0]; const targetAssetUri = targetAsset?.uri; diff --git a/src/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx index 5b4dd6c4d7f2a..f4697c9191040 100644 --- a/src/components/AttachmentPicker/index.tsx +++ b/src/components/AttachmentPicker/index.tsx @@ -63,6 +63,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a const file = e.target.files[0]; if (file) { + // TODO: refactor to use multiple files file.uri = URL.createObjectURL(file); onPicked.current([file]); } diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 65b5f14823e07..e290af43c28e4 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -440,7 +440,7 @@ function ReportActionCompose({ shouldDisableSendButton={!!exceededMaxLength} reportID={reportID} > - {({displayFileInModal}) => ( + {({displayFileInModal, displayMultipleFilesInModal}) => ( <> 1) { + const files = Array.from(event.dataTransfer?.files).map((file) => { + // eslint-disable-next-line no-param-reassign + file.uri = URL.createObjectURL(file); + return file; + }); + displayMultipleFilesInModal(files); + return; + } + const data = event.dataTransfer?.files[0]; if (data) { data.uri = URL.createObjectURL(data); From bcd9dd144891c038454a2f7961d3c11fee3f095c Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 16:03:12 +0200 Subject: [PATCH 03/79] feat: create validation utils --- src/CONST.ts | 13 ++++++++++ src/libs/fileDownload/FileUtils.ts | 41 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/CONST.ts b/src/CONST.ts index ec49e952000d5..bbd4c6ac1f0f7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -442,6 +442,11 @@ const CONST = { MAX_FILE_LIMIT: 30, }, + ATTACHMENT_ERRORS: { + COUNT: 'count', + SIZE: 'size', + FILE_TYPE: 'fileType', + }, // Allowed extensions for spreadsheets import ALLOWED_SPREADSHEET_EXTENSIONS: ['xls', 'xlsx', 'csv', 'txt'], @@ -2051,6 +2056,14 @@ const CONST = { // Video MimeTypes allowed by iOS photos app. VIDEO: /\.(mov|mp4)$/, }, + + FILE_VALIDATION_ERRORS: { + WRONG_FILE_TYPE: 'wrongFileType', + FILE_TOO_LARGE: 'fileTooLarge', + FILE_TOO_SMALL: 'fileTooSmall', + FILE_CORRUPTED: 'fileCorrupted', + }, + IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 81fcb9847a2ee..b6831824282b7 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import {Alert, Linking, Platform} from 'react-native'; import ImageSize from 'react-native-image-size'; -import type {TupleToUnion} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; @@ -378,6 +378,45 @@ const validateReceipt = (file: FileObject, setUploadReceiptError: (isInvalid: bo }); }; +const validateReceiptFile = (file: FileObject) => { + const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); + if ( + !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase() as TupleToUnion) + ) { + return false; + } + + if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { + return false; + } + + if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return false; + } + return true; +}; + +const getFileValidationErrorText = (validationError: ValueOf) => { + switch (validationError) { + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: + return {title: 'attachmentPicker.wrongFileType', reason: 'attachmentPicker.notAllowedExtension'}; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: + return {title: 'attachmentPicker.attachmentTooLarge', reason: 'attachmentPicker.sizeExceededWithLimit'}; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: + return {title: 'attachmentPicker.attachmentTooSmall', reason: 'attachmentPicker.sizeNotMet'}; + case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: + return { + title: 'attachmentPicker.attachmentError', + reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', + }; + default: + return { + title: 'attachmentPicker.attachmentError', + reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', + }; + } +}; + export { showGeneralErrorAlert, showSuccessAlert, From d0190be44a42f51cf9bacfdc6d1eba4ea3b8b2c2 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 15 May 2025 11:56:40 +0200 Subject: [PATCH 04/79] feat: exctract isValidReceiptExtension --- src/libs/fileDownload/FileUtils.ts | 21 ++++++++++++------- .../step/IOURequestStepScan/index.native.tsx | 9 ++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index b6831824282b7..650d8398f14aa 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -378,22 +378,26 @@ const validateReceipt = (file: FileObject, setUploadReceiptError: (isInvalid: bo }); }; -const validateReceiptFile = (file: FileObject) => { +const isValidReceiptExtension = (file: FileObject) => { const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); - if ( - !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase() as TupleToUnion) - ) { - return false; + return CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( + fileExtension.toLowerCase() as TupleToUnion, + ); +}; + +const validateReceiptFile = (file: FileObject) => { + if (!isValidReceiptExtension(file)) { + return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; } if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - return false; + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; } if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - return false; + return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; } - return true; + return ''; }; const getFileValidationErrorText = (validationError: ValueOf) => { @@ -439,4 +443,5 @@ export { resizeImageIfNeeded, createFile, validateReceipt, + isValidReceiptExtension }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 094536c441a18..60c1d9c48ac6f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -35,7 +35,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import setTestReceipt from '@libs/actions/setTestReceipt'; import {dismissProductTraining} from '@libs/actions/Welcome'; -import {readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import {isValidReceiptExtension, readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import getCurrentPosition from '@libs/getCurrentPosition'; @@ -230,12 +230,7 @@ function IOURequestStepScan({ ); const validateReceipt = (file: FileObject) => { - const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); - if ( - !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( - fileExtension.toLowerCase() as TupleToUnion, - ) - ) { + if (!isValidReceiptExtension(file)) { Alert.alert(translate('attachmentPicker.wrongFileType'), translate('attachmentPicker.notAllowedExtension')); return false; } From 18fdcae500ac92d9dd18ea53f5d4e4ed34b49614 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 16:08:03 +0200 Subject: [PATCH 05/79] fix: minor fix --- src/pages/iou/request/step/IOURequestStepScan/index.native.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 60c1d9c48ac6f..6a78b5e0eaef9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -11,7 +11,6 @@ import {RESULTS} from 'react-native-permissions'; import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated'; import type {Camera, PhotoFile, Point} from 'react-native-vision-camera'; import {useCameraDevice} from 'react-native-vision-camera'; -import type {TupleToUnion} from 'type-fest'; import TestReceipt from '@assets/images/fake-receipt.png'; import Hand from '@assets/images/hand.svg'; import Shutter from '@assets/images/shutter.svg'; @@ -35,7 +34,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import setTestReceipt from '@libs/actions/setTestReceipt'; import {dismissProductTraining} from '@libs/actions/Welcome'; -import {isValidReceiptExtension, readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import {isValidReceiptExtension, readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import getCurrentPosition from '@libs/getCurrentPosition'; From 2d963f5a5467ff68ec5967b72a672c7aca99bec8 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 15 May 2025 19:04:10 +0200 Subject: [PATCH 06/79] feat: refactor error handling for the attachment modal --- src/CONST.ts | 1 + src/components/AttachmentModal.tsx | 49 +++++++++++------------------- src/libs/fileDownload/FileUtils.ts | 13 ++++++-- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index bbd4c6ac1f0f7..ec9078dae6a8e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2062,6 +2062,7 @@ const CONST = { FILE_TOO_LARGE: 'fileTooLarge', FILE_TOO_SMALL: 'fileTooSmall', FILE_CORRUPTED: 'fileCorrupted', + FOLDER_NOT_ALLOWED: 'folderNotAllowed' }, IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 496290e711ed5..194118b0af00c 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -14,7 +14,12 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import attachmentModalHandler from '@libs/AttachmentModalHandler'; import fileDownload from '@libs/fileDownload'; -import {cleanFileName, getFileName, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import { + cleanFileName, + getFileName, getFileValidationErrorText, + validateImageForCorruption, + validateReceiptFile, +} from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; @@ -23,7 +28,6 @@ import variables from '@styles/variables'; import {detachReceipt} from '@userActions/IOU'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -194,11 +198,9 @@ function AttachmentModal({ const styles = useThemeStyles(); const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [attachmentError, setAttachmentError] = useState | undefined>(undefined); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(null); - const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null); const [sourceState, setSourceState] = useState(() => source); const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE); const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); @@ -208,7 +210,7 @@ function AttachmentModal({ const {windowWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const nope = useSharedValue(false); - const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && isAttachmentInvalid); + const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && attachmentError); const iouType = useMemo(() => iouTypeProp ?? (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction, iouTypeProp]); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -309,7 +311,7 @@ function AttachmentModal({ * Close the confirm modals. */ const closeConfirmModal = useCallback(() => { - setIsAttachmentInvalid(false); + setAttachmentError(undefined); setIsDeleteReceiptConfirmModalVisible(false); }, []); @@ -326,26 +328,15 @@ function AttachmentModal({ (fileObject: FileObject) => validateImageForCorruption(fileObject) .then(() => { - if (fileObject.size && fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooLarge'); - setAttachmentInvalidReason('attachmentPicker.sizeExceeded'); + const fileError = validateReceiptFile(fileObject); + if (fileError) { + setAttachmentError(fileError); return false; } - - if (fileObject.size && fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentTooSmall'); - setAttachmentInvalidReason('attachmentPicker.sizeNotMet'); - return false; - } - return true; }) .catch(() => { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.errorWhileSelectingCorruptedAttachment'); + setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); return false; }), [], @@ -353,9 +344,7 @@ function AttachmentModal({ const isDirectoryCheck = useCallback((data: FileObject) => { if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.folderNotAllowedMessage'); + setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); return false; } return true; @@ -541,9 +530,7 @@ function AttachmentModal({ setShouldLoadAttachment(false); clearAttachmentErrors(); if (isPDFLoadError.current) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.errorWhileSelectingCorruptedAttachment'); + setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED) return; } @@ -690,11 +677,11 @@ function AttachmentModal({ {!isReceiptAttachment && ( { diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 650d8398f14aa..c893db506c553 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -400,7 +400,12 @@ const validateReceiptFile = (file: FileObject) => { return ''; }; -const getFileValidationErrorText = (validationError: ValueOf) => { +const getFileValidationErrorText = ( + validationError: ValueOf, +): { + title: TranslationPaths; + reason: TranslationPaths; +} => { switch (validationError) { case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return {title: 'attachmentPicker.wrongFileType', reason: 'attachmentPicker.notAllowedExtension'}; @@ -408,6 +413,8 @@ const getFileValidationErrorText = (validationError: ValueOf Date: Fri, 16 May 2025 11:33:53 +0200 Subject: [PATCH 07/79] fix: resolve naming conflict --- src/components/AttachmentModal.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 194118b0af00c..715c729989edb 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -198,7 +198,7 @@ function AttachmentModal({ const styles = useThemeStyles(); const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); - const [attachmentError, setAttachmentError] = useState | undefined>(undefined); + const [fileError, setFileError] = useState | undefined>(undefined); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); const [sourceState, setSourceState] = useState(() => source); @@ -210,7 +210,7 @@ function AttachmentModal({ const {windowWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const nope = useSharedValue(false); - const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && attachmentError); + const isOverlayModalVisible = (isReceiptAttachment && isDeleteReceiptConfirmModalVisible) || (!isReceiptAttachment && fileError); const iouType = useMemo(() => iouTypeProp ?? (isTrackExpenseAction ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseAction, iouTypeProp]); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -311,7 +311,7 @@ function AttachmentModal({ * Close the confirm modals. */ const closeConfirmModal = useCallback(() => { - setAttachmentError(undefined); + setFileError(undefined); setIsDeleteReceiptConfirmModalVisible(false); }, []); @@ -328,15 +328,15 @@ function AttachmentModal({ (fileObject: FileObject) => validateImageForCorruption(fileObject) .then(() => { - const fileError = validateReceiptFile(fileObject); - if (fileError) { - setAttachmentError(fileError); + const error = validateReceiptFile(fileObject); + if (error) { + setFileError(error); return false; } return true; }) .catch(() => { - setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); return false; }), [], @@ -344,7 +344,7 @@ function AttachmentModal({ const isDirectoryCheck = useCallback((data: FileObject) => { if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) { - setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); + setFileError(CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED); return false; } return true; @@ -530,7 +530,7 @@ function AttachmentModal({ setShouldLoadAttachment(false); clearAttachmentErrors(); if (isPDFLoadError.current) { - setAttachmentError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED) + setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED) return; } @@ -677,11 +677,11 @@ function AttachmentModal({ {!isReceiptAttachment && ( { From 186a45f74fbf5ad9e78f422d3c20622d26d82dc7 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 18:11:00 +0200 Subject: [PATCH 08/79] feat: add translations --- src/languages/en.ts | 4 ++++ src/languages/es.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 6c4a386fee9be..f528c9382e2ae 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -614,6 +614,10 @@ const translations = { attachmentImageTooLarge: 'This image is too large to preview before uploading.', tooManyFiles: ({fileLimit}: FileLimitParams) => `You can only upload up to ${fileLimit} files at a time.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Files exceeds ${maxUploadSizeInMB} MB. Please try again.`, + someFilesCantBeUploaded: "Some files can't be uploaded", + sizeLimitExceeded: "Files must be under 10 MB. Any larger files won't be uploaded.", + maxFileLimitExceeded: "You can upload up to 30 receipts at a time. Any extras won't be uploaded.", + unsupportedFileType: "files aren't supported. Only supported file types will be uploaded. Learn more about supported formats." }, dropzone: { addAttachments: 'Add attachments', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1e0858f222a56..a8655507983c9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -609,6 +609,10 @@ const translations = { attachmentImageTooLarge: 'Esta imagen es demasiado grande para obtener una vista previa antes de subirla.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Solamente puedes suber ${fileLimit} archivos a la vez.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo supera los ${maxUploadSizeInMB} MB. Por favor, vuelve a intentarlo.`, + someFilesCantBeUploaded: "Algunos archivos no se pueden subir", + sizeLimitExceeded: "Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.", + maxFileLimitExceeded: "Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.", + unsupportedFileType: "archivos no son compatibles. Solo se subirán los tipos de archivo compatibles. Obtén más información sobre los formatos compatibles." }, dropzone: { addAttachments: 'Añadir archivos adjuntos', From 3e9b09a0dbcc437632ecb7140931cb37d3ef2642 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 18:23:45 +0200 Subject: [PATCH 09/79] feat: show error modal when max file limit exceeded --- src/CONST.ts | 3 ++- src/components/AttachmentModal.tsx | 37 ++++++++++++++++-------------- src/libs/fileDownload/FileUtils.ts | 2 ++ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index ec9078dae6a8e..284aa473d97b2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2062,7 +2062,8 @@ const CONST = { FILE_TOO_LARGE: 'fileTooLarge', FILE_TOO_SMALL: 'fileTooSmall', FILE_CORRUPTED: 'fileCorrupted', - FOLDER_NOT_ALLOWED: 'folderNotAllowed' + FOLDER_NOT_ALLOWED: 'folderNotAllowed', + MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded', }, IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 715c729989edb..050c2bb964d23 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -14,12 +14,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import attachmentModalHandler from '@libs/AttachmentModalHandler'; import fileDownload from '@libs/fileDownload'; -import { - cleanFileName, - getFileName, getFileValidationErrorText, - validateImageForCorruption, - validateReceiptFile, -} from '@libs/fileDownload/FileUtils'; +import {cleanFileName, getFileName, getFileValidationErrorText, validateImageForCorruption, validateReceiptFile} from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; @@ -350,16 +345,24 @@ function AttachmentModal({ return true; }, []); - const validateAndDisplayMultipleFilesToUpload = useCallback((data: FileObject[]) => { - if (!data?.length) { - return; - } - if (data.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - setIsAttachmentInvalid(true); - setAttachmentInvalidReasonTitle('attachmentPicker.attachmentError'); - setAttachmentInvalidReason('attachmentPicker.tooManyFiles'); - } - }, []); + const validateAndDisplayMultipleFilesToUpload = useCallback( + (data: FileObject[]) => { + if (!data?.length || data.some((fileObject) => !isDirectoryCheck(fileObject))) { + return; + } + if (data.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + setFileError(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); + return; + } + // eslint-disable-next-line @typescript-eslint/no-misused-promises + data.forEach((fileToUpload) => + isValidFile(fileToUpload).then((isValid) => { + console.log(fileToUpload, isValid); + }), + ); + }, + [isDirectoryCheck, isValidFile], + ); const validateAndDisplayFileToUpload = useCallback( (data: FileObject) => { @@ -530,7 +533,7 @@ function AttachmentModal({ setShouldLoadAttachment(false); clearAttachmentErrors(); if (isPDFLoadError.current) { - setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED) + setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); return; } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index c893db506c553..1bdea41d26585 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -415,6 +415,8 @@ const getFileValidationErrorText = ( return {title: 'attachmentPicker.attachmentTooSmall', reason: 'attachmentPicker.sizeNotMet'}; case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: return {title: 'attachmentPicker.attachmentError', reason: 'attachmentPicker.folderNotAllowedMessage'}; + case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: + return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.maxFileLimitExceeded'}; case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: return { title: 'attachmentPicker.attachmentError', From 18e7db1f248e50b9c6049aa6559189b9788cbe9b Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Tue, 3 Jun 2025 18:39:34 +0200 Subject: [PATCH 10/79] fix: minor translation improvement --- src/languages/es.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index a8655507983c9..cdc7d0368d40c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -612,7 +612,7 @@ const translations = { someFilesCantBeUploaded: "Algunos archivos no se pueden subir", sizeLimitExceeded: "Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.", maxFileLimitExceeded: "Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.", - unsupportedFileType: "archivos no son compatibles. Solo se subirán los tipos de archivo compatibles. Obtén más información sobre los formatos compatibles." + unsupportedFileType: "archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles." }, dropzone: { addAttachments: 'Añadir archivos adjuntos', From 9b55b874203544844d6931829c81e816f1a2c062 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Wed, 4 Jun 2025 15:05:29 +0200 Subject: [PATCH 11/79] feat: validate multiple files --- src/components/AttachmentModal.tsx | 64 +++++++++++++------ .../ReportActionCompose.tsx | 17 +++-- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 050c2bb964d23..0dbf9dfbdb079 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -75,7 +75,7 @@ type AttachmentModalProps = { attachmentID?: string; /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm?: ((file: FileObject) => void) | null; + onConfirm?: ((file: FileObject | FileObject[]) => void) | null; /** Whether the modal should be open by default */ defaultOpen?: boolean; @@ -212,6 +212,7 @@ function AttachmentModal({ const transactionID = (isMoneyRequestAction(parentReportAction) && getOriginalMessage(parentReportAction)?.IOUTransactionID) || CONST.DEFAULT_NUMBER_ID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: true}); const [currentAttachmentLink, setCurrentAttachmentLink] = useState(attachmentLink); + const [validFilesToUpload, setValidFilesToUpload] = useState([]); const {setAttachmentError, isErrorInAttachment, clearAttachmentErrors} = useAttachmentErrors(); const [file, setFile] = useState( @@ -295,7 +296,11 @@ function AttachmentModal({ } if (onConfirm) { - onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); + if (validFilesToUpload) { + onConfirm(validFilesToUpload) + } else { + onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); + } } setIsModalOpen(false); @@ -345,23 +350,50 @@ function AttachmentModal({ return true; }, []); + const handleOpenModal = useCallback( + (inputSource: string, fileObject: FileObject) => { + const inputModalType = getModalType(inputSource, fileObject); + setIsModalOpen(true); + setSourceState(inputSource); + setFile(fileObject); + setModalType(inputModalType); + }, + [getModalType, setSourceState, setFile, setModalType], + ); + const validateAndDisplayMultipleFilesToUpload = useCallback( (data: FileObject[]) => { if (!data?.length || data.some((fileObject) => !isDirectoryCheck(fileObject))) { return; } + let validFiles: FileObject[] = []; if (data.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + validFiles = data.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + setValidFilesToUpload(validFiles); setFileError(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); return; } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - data.forEach((fileToUpload) => - isValidFile(fileToUpload).then((isValid) => { - console.log(fileToUpload, isValid); - }), - ); + + // Validate all files in parallel and collect valid ones + Promise.all( + data.map((fileToUpload) => + isValidFile(fileToUpload) + .then((isValid) => isValid ? fileToUpload : null) + ) + ).then((results) => { + // Filter out null values (invalid files) + validFiles = results.filter((validFile): validFile is FileObject => validFile !== null); + setValidFilesToUpload(validFiles); + + // Only open modal if we have valid files + if (validFiles.length > 0) { + // eslint-disable-next-line + const fileToDisplay = validFiles.at(0) as FileObject; + handleOpenModal(fileToDisplay.uri ?? '', fileToDisplay); + } + }); }, - [isDirectoryCheck, isValidFile], + [handleOpenModal, isDirectoryCheck, isValidFile], ); const validateAndDisplayFileToUpload = useCallback( @@ -393,21 +425,13 @@ function AttachmentModal({ } const inputSource = URL.createObjectURL(updatedFile); updatedFile.uri = inputSource; - const inputModalType = getModalType(inputSource, updatedFile); - setIsModalOpen(true); - setSourceState(inputSource); - setFile(updatedFile); - setModalType(inputModalType); + handleOpenModal(inputSource, updatedFile); } else if (fileObject.uri) { - const inputModalType = getModalType(fileObject.uri, fileObject); - setIsModalOpen(true); - setSourceState(fileObject.uri); - setFile(fileObject); - setModalType(inputModalType); + handleOpenModal(fileObject.uri, fileObject); } }); }, - [isValidFile, getModalType, isDirectoryCheck], + [isDirectoryCheck, isValidFile, handleOpenModal], ); /** diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index fc18a1c58436b..4a436443cf0b3 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -242,8 +242,9 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - const attachmentFileRef = useRef(null); - const addAttachment = useCallback((file: FileObject) => { + const attachmentFileRef = useRef(null); + + const addAttachment = useCallback((file: FileObject | FileObject[]) => { attachmentFileRef.current = file; const clear = composerRef.current?.clear; if (!clear) { @@ -269,7 +270,15 @@ function ReportActionCompose({ const newCommentTrimmed = newComment.trim(); if (attachmentFileRef.current) { - addAttachmentReportActions(reportID, attachmentFileRef.current, newCommentTrimmed, true); + if (Array.isArray(attachmentFileRef.current)) { + // Handle multiple files + attachmentFileRef.current.forEach((file) => { + addAttachmentReportActions(reportID, file, newCommentTrimmed, true); + }); + } else { + // Handle single file + addAttachmentReportActions(reportID, attachmentFileRef.current, newCommentTrimmed, true); + } attachmentFileRef.current = null; } else { Performance.markStart(CONST.TIMING.SEND_MESSAGE, {message: newCommentTrimmed}); @@ -507,7 +516,7 @@ function ReportActionCompose({ if (isAttachmentPreviewActive) { return; } - if (canUseMultiFilesDragAndDrop && event.dataTransfer?.files.length && event.dataTransfer?.files.length > 1) { + if (isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP) && event.dataTransfer?.files.length && event.dataTransfer?.files.length > 1) { const files = Array.from(event.dataTransfer?.files).map((file) => { // eslint-disable-next-line no-param-reassign file.uri = URL.createObjectURL(file); From 18fc6605a735406818591f392f19832cbfac3e9c Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Wed, 4 Jun 2025 19:36:51 +0200 Subject: [PATCH 12/79] feat: show error modal --- src/CONST.ts | 2 +- src/components/AttachmentModal.tsx | 80 +++++++++++++++++++----------- src/libs/fileDownload/FileUtils.ts | 8 +-- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index cebe6261a2314..2f32597972693 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -440,7 +440,7 @@ const CONST = { // Allowed extensions for receipts ALLOWED_RECEIPT_EXTENSIONS: ['heif', 'heic', 'jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], - MAX_FILE_LIMIT: 30, + MAX_FILE_LIMIT: 2, }, ATTACHMENT_ERRORS: { COUNT: 'count', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 0dbf9dfbdb079..eab336cac44bc 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -14,7 +14,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import attachmentModalHandler from '@libs/AttachmentModalHandler'; import fileDownload from '@libs/fileDownload'; -import {cleanFileName, getFileName, getFileValidationErrorText, validateImageForCorruption, validateReceiptFile} from '@libs/fileDownload/FileUtils'; +import {cleanFileName, getFileName, getFileValidationErrorText, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; @@ -194,6 +194,7 @@ function AttachmentModal({ const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [fileError, setFileError] = useState | undefined>(undefined); + const [isFileErrorModalVisible, setIsFileErrorModalVisible] = useState(false); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); const [sourceState, setSourceState] = useState(() => source); @@ -231,6 +232,13 @@ function AttachmentModal({ setFile(originalFileName ? {name: originalFileName} : undefined); }, [originalFileName]); + useEffect(() => { + if (!fileError) { + return; + } + setIsFileErrorModalVisible(true); + }, [fileError]); + /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. */ @@ -297,7 +305,8 @@ function AttachmentModal({ if (onConfirm) { if (validFilesToUpload) { - onConfirm(validFilesToUpload) + onConfirm(validFilesToUpload); + setValidFilesToUpload([]); } else { onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); } @@ -311,8 +320,11 @@ function AttachmentModal({ * Close the confirm modals. */ const closeConfirmModal = useCallback(() => { - setFileError(undefined); + setIsFileErrorModalVisible(false); setIsDeleteReceiptConfirmModalVisible(false); + InteractionManager.runAfterInteractions(() => { + setFileError(undefined); + }) }, []); /** @@ -328,7 +340,7 @@ function AttachmentModal({ (fileObject: FileObject) => validateImageForCorruption(fileObject) .then(() => { - const error = validateReceiptFile(fileObject); + const error = validateAttachment(fileObject); if (error) { setFileError(error); return false; @@ -361,39 +373,46 @@ function AttachmentModal({ [getModalType, setSourceState, setFile, setModalType], ); + const validateFiles = (data: FileObject[]) => { + let validFiles: FileObject[] = []; + // Validate all files in parallel and collect valid ones + Promise.all(data.map((fileToUpload) => isValidFile(fileToUpload).then((isValid) => (isValid ? fileToUpload : null)))).then((results) => { + // Filter out null values (invalid files) + validFiles = results.filter((validFile): validFile is FileObject => validFile !== null); + setValidFilesToUpload(validFiles); + + // Only open modal if we have valid files + if (validFiles.length > 0) { + // eslint-disable-next-line + const fileToDisplay = validFiles.at(0) as FileObject; + handleOpenModal(fileToDisplay.uri ?? '', fileToDisplay); + } + }); + }; + + const confirmAndContinue = () => { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + validateFiles(validFilesToUpload); + } else { + setValidFilesToUpload([]); + } + closeConfirmModal(); + }; + const validateAndDisplayMultipleFilesToUpload = useCallback( (data: FileObject[]) => { if (!data?.length || data.some((fileObject) => !isDirectoryCheck(fileObject))) { return; } - let validFiles: FileObject[] = []; if (data.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { - validFiles = data.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + const validFiles = data.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); setValidFilesToUpload(validFiles); setFileError(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); return; } - - // Validate all files in parallel and collect valid ones - Promise.all( - data.map((fileToUpload) => - isValidFile(fileToUpload) - .then((isValid) => isValid ? fileToUpload : null) - ) - ).then((results) => { - // Filter out null values (invalid files) - validFiles = results.filter((validFile): validFile is FileObject => validFile !== null); - setValidFilesToUpload(validFiles); - - // Only open modal if we have valid files - if (validFiles.length > 0) { - // eslint-disable-next-line - const fileToDisplay = validFiles.at(0) as FileObject; - handleOpenModal(fileToDisplay.uri ?? '', fileToDisplay); - } - }); + validateFiles(data); }, - [handleOpenModal, isDirectoryCheck, isValidFile], + [isDirectoryCheck, validateFiles], ); const validateAndDisplayFileToUpload = useCallback( @@ -705,12 +724,13 @@ function AttachmentModal({ {!isReceiptAttachment && ( { if (!isPDFLoadError.current) { return; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 1bdea41d26585..723b576994b16 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -385,11 +385,7 @@ const isValidReceiptExtension = (file: FileObject) => { ); }; -const validateReceiptFile = (file: FileObject) => { - if (!isValidReceiptExtension(file)) { - return CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; - } - +const validateAttachment = (file: FileObject) => { if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; } @@ -452,7 +448,7 @@ export { resizeImageIfNeeded, createFile, validateReceipt, - validateReceiptFile, + validateAttachment, isValidReceiptExtension, getFileValidationErrorText, }; From 5e76042cd8b1a1766712ecddacc63d68d5fea044 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 5 Jun 2025 18:25:01 +0200 Subject: [PATCH 13/79] feat: add other error cases --- src/CONST.ts | 7 ++--- src/components/AttachmentModal.tsx | 43 +++++++++++++++--------------- src/libs/fileDownload/FileUtils.ts | 14 +++++++--- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 2f32597972693..9e04f2719b182 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -442,11 +442,6 @@ const CONST = { MAX_FILE_LIMIT: 2, }, - ATTACHMENT_ERRORS: { - COUNT: 'count', - SIZE: 'size', - FILE_TYPE: 'fileType', - }, // Allowed extensions for spreadsheets import ALLOWED_SPREADSHEET_EXTENSIONS: ['xls', 'xlsx', 'csv', 'txt'], @@ -2068,7 +2063,9 @@ const CONST = { FILE_VALIDATION_ERRORS: { WRONG_FILE_TYPE: 'wrongFileType', + WRONG_FILE_TYPE_MULTIPLE: 'wrongFileTypeMultiple', FILE_TOO_LARGE: 'fileTooLarge', + FILE_TOO_LARGE_MULTIPLE: 'fileTooLargeMultiple', FILE_TOO_SMALL: 'fileTooSmall', FILE_CORRUPTED: 'fileCorrupted', FOLDER_NOT_ALLOWED: 'folderNotAllowed', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index eab336cac44bc..beaa9a372c712 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -1,11 +1,11 @@ -import {Str} from 'expensify-common'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import {useOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, LayoutAnimationConfig, useSharedValue} from 'react-native-reanimated'; -import type {ValueOf} from 'type-fest'; +import { Str } from 'expensify-common'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { InteractionManager, Keyboard, View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { useOnyx } from 'react-native-onyx'; +import type { OnyxEntry } from 'react-native-onyx'; +import Animated, { FadeIn, LayoutAnimationConfig, useSharedValue } from 'react-native-reanimated'; +import type { ValueOf } from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -14,26 +14,26 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import attachmentModalHandler from '@libs/AttachmentModalHandler'; import fileDownload from '@libs/fileDownload'; -import {cleanFileName, getFileName, getFileValidationErrorText, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import { cleanFileName, getFileName, getFileValidationErrorText, validateAttachment, validateImageForCorruption } from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; -import type {AvatarSource} from '@libs/UserUtils'; +import { getOriginalMessage, getReportAction, isMoneyRequestAction } from '@libs/ReportActionsUtils'; +import { hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned } from '@libs/TransactionUtils'; +import type { AvatarSource } from '@libs/UserUtils'; import variables from '@styles/variables'; -import {detachReceipt} from '@userActions/IOU'; -import type {IOUAction, IOUType} from '@src/CONST'; +import { detachReceipt } from '@userActions/IOU'; +import type { IOUAction, IOUType } from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import { isEmptyObject } from '@src/types/utils/EmptyObject'; import type ModalType from '@src/types/utils/ModalType'; import viewRef from '@src/types/utils/viewRef'; import AttachmentCarousel from './Attachments/AttachmentCarousel'; import AttachmentCarouselPagerContext from './Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import AttachmentView from './Attachments/AttachmentView'; import useAttachmentErrors from './Attachments/AttachmentView/useAttachmentErrors'; -import type {Attachment} from './Attachments/types'; +import type { Attachment } from './Attachments/types'; import BlockingView from './BlockingViews/BlockingView'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; @@ -45,6 +45,7 @@ import * as Illustrations from './Icon/Illustrations'; import Modal from './Modal'; import SafeAreaConsumer from './SafeAreaConsumer'; + /** * Modal render prop component that exposes modal launching triggers that can be used * to display a full size image or PDF modally with optional confirmation button. @@ -324,7 +325,7 @@ function AttachmentModal({ setIsDeleteReceiptConfirmModalVisible(false); InteractionManager.runAfterInteractions(() => { setFileError(undefined); - }) + }); }, []); /** @@ -337,10 +338,10 @@ function AttachmentModal({ }, [transaction]); const isValidFile = useCallback( - (fileObject: FileObject) => + (fileObject: FileObject, isCheckingMultipleFiles?: boolean) => validateImageForCorruption(fileObject) .then(() => { - const error = validateAttachment(fileObject); + const error = validateAttachment(fileObject, isCheckingMultipleFiles); if (error) { setFileError(error); return false; @@ -376,7 +377,7 @@ function AttachmentModal({ const validateFiles = (data: FileObject[]) => { let validFiles: FileObject[] = []; // Validate all files in parallel and collect valid ones - Promise.all(data.map((fileToUpload) => isValidFile(fileToUpload).then((isValid) => (isValid ? fileToUpload : null)))).then((results) => { + Promise.all(data.map((fileToUpload) => isValidFile(fileToUpload, true).then((isValid) => (isValid ? fileToUpload : null)))).then((results) => { // Filter out null values (invalid files) validFiles = results.filter((validFile): validFile is FileObject => validFile !== null); setValidFilesToUpload(validFiles); @@ -396,7 +397,7 @@ function AttachmentModal({ } else { setValidFilesToUpload([]); } - closeConfirmModal(); + setIsFileErrorModalVisible(false); }; const validateAndDisplayMultipleFilesToUpload = useCallback( diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 723b576994b16..769fe347658d2 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -385,14 +385,18 @@ const isValidReceiptExtension = (file: FileObject) => { ); }; -const validateAttachment = (file: FileObject) => { +const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, isValidatingReceipt?: boolean) => { if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; + return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; } if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; } + + if (isValidatingReceipt && !isValidReceiptExtension(file)) { + return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + } return ''; }; @@ -405,8 +409,12 @@ const getFileValidationErrorText = ( switch (validationError) { case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: return {title: 'attachmentPicker.wrongFileType', reason: 'attachmentPicker.notAllowedExtension'}; + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: + return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.unsupportedFileType'}; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: - return {title: 'attachmentPicker.attachmentTooLarge', reason: 'attachmentPicker.sizeExceededWithLimit'}; + return {title: 'attachmentPicker.attachmentTooLarge', reason: 'attachmentPicker.sizeExceeded'}; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE: + return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.sizeLimitExceeded'}; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: return {title: 'attachmentPicker.attachmentTooSmall', reason: 'attachmentPicker.sizeNotMet'}; case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: From fcb66ec4b8c9b1c5f494382b61a60fa90a11b6f0 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Fri, 6 Jun 2025 09:39:07 +0200 Subject: [PATCH 14/79] fix: minor fix --- src/pages/Search/SearchPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4acf66993081f..944f71deebfa9 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -437,6 +437,7 @@ function SearchPage({route}: SearchPageProps) { const source = URL.createObjectURL(resizedFile as Blob); const newReportID = generateReportID(); initMoneyRequest({ + isFromGlobalCreate: true, reportID: newReportID, newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, }); From 92a083774cae414bf8666cec241687c4b38c7dc4 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 9 Jun 2025 10:31:04 +0200 Subject: [PATCH 15/79] feat: add protected file validation error --- src/CONST.ts | 1 + src/hooks/useFileValidation.ts | 66 ++++++++++--------- src/libs/fileDownload/FileUtils.ts | 16 ++--- src/pages/Search/SearchPage.tsx | 17 ++--- .../ReportActionCompose.tsx | 17 ++--- 5 files changed, 53 insertions(+), 64 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index e3b93757d620a..a3b1a8c9cd770 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2073,6 +2073,7 @@ const CONST = { FILE_CORRUPTED: 'fileCorrupted', FOLDER_NOT_ALLOWED: 'folderNotAllowed', MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded', + PROTECTED_FILE: 'protectedFile', }, IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied', diff --git a/src/hooks/useFileValidation.ts b/src/hooks/useFileValidation.ts index 480c4cc77d533..05ff234e155ca 100644 --- a/src/hooks/useFileValidation.ts +++ b/src/hooks/useFileValidation.ts @@ -1,61 +1,65 @@ import {Str} from 'expensify-common'; import {useState} from 'react'; +import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; -import {resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; +import {resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; function useFileValidation() { const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(); - const [attachmentInvalidReason, setAttachmentValidReason] = useState(); const [pdfFile, setPdfFile] = useState(null); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); + const [fileError, setFileError] = useState | null>(null); /** * Sets the upload receipt error modal content when an invalid receipt is uploaded */ - const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => { - setIsAttachmentInvalid(isInvalid); - setAttachmentInvalidReasonTitle(title); - setAttachmentValidReason(reason); + const setUploadReceiptError = (error: ValueOf) => { + setIsAttachmentInvalid(true); + setFileError(error); setPdfFile(null); }; - const validateAndResizeFile = (originalFile: FileObject, setReceiptAndNavigate: (file: FileObject) => void, isPdfValidated?: boolean) => { - validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => { - if (!isFileValid) { - return; - } + const validateAndResizeFile = (originalFile: FileObject, setReceiptAndNavigate: (file: FileObject) => void, isPdfValidated?: boolean, isCheckingMultipleFiles?: boolean) => { + validateImageForCorruption(originalFile) + .then(() => { + const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); + if (error) { + setIsAttachmentInvalid(true); + setFileError(error); + return false; + } + // If we have a pdf file and if it is not validated then set the pdf file for validation and return + if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { + setPdfFile(originalFile); + return; + } - // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { - setPdfFile(originalFile); - return; - } - - // With the image size > 24MB, we use manipulateAsync to resize the image. - // It takes a long time so we should display a loading indicator while the resize image progresses. - if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsLoadingReceipt(true); - } - resizeImageIfNeeded(originalFile).then((resizedFile) => { - setIsLoadingReceipt(false); - setReceiptAndNavigate(resizedFile); + // With the image size > 24MB, we use manipulateAsync to resize the image. + // It takes a long time so we should display a loading indicator while the resize image progresses. + if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setIsLoadingReceipt(true); + } + resizeImageIfNeeded(originalFile).then((resizedFile) => { + setIsLoadingReceipt(false); + setReceiptAndNavigate(resizedFile); + }); + }) + .catch(() => { + setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + return false; }); - }); }; return { validateAndResizeFile, isAttachmentInvalid, setIsAttachmentInvalid, - attachmentInvalidReason, - attachmentInvalidReasonTitle, - setUploadReceiptError, pdfFile, setPdfFile, + setUploadReceiptError, isLoadingReceipt, + fileError, }; } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 389038352983e..af74ee9e92268 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -378,16 +378,6 @@ const validateReceipt = (file: FileObject, setUploadReceiptError: (isInvalid: bo }); }; -const getConfirmModalPrompt = (attachmentInvalidReason: TranslationPaths | undefined) => { - if (!attachmentInvalidReason) { - return ''; - } - if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { - return translateLocal(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); - } - return translateLocal(attachmentInvalidReason); -}; - const isValidReceiptExtension = (file: FileObject) => { const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); return CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( @@ -436,6 +426,11 @@ const getFileValidationErrorText = ( title: 'attachmentPicker.attachmentError', reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', }; + case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE: + return { + title: 'attachmentPicker.attachmentError', + reason: 'attachmentPicker.protectedPDFNotSupported', + }; default: return { title: 'attachmentPicker.attachmentError', @@ -466,7 +461,6 @@ export { resizeImageIfNeeded, createFile, validateReceipt, - getConfirmModalPrompt, validateAttachment, isValidReceiptExtension, getFileValidationErrorText, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f03d814a792e7..28d3bf2df75de 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -39,7 +39,7 @@ import { search, unholdMoneyRequestOnSearch, } from '@libs/actions/Search'; -import {getConfirmModalPrompt} from '@libs/fileDownload/FileUtils'; +import {getFileValidationErrorText} from '@libs/fileDownload/FileUtils'; import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -78,12 +78,11 @@ function SearchPage({route}: SearchPageProps) { validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, - attachmentInvalidReason, - attachmentInvalidReasonTitle, setUploadReceiptError, pdfFile, setPdfFile, isLoadingReceipt, + fileError, } = useFileValidation(); const {q} = route.params; @@ -451,12 +450,8 @@ function SearchPage({route}: SearchPageProps) { setPdfFile(null); setReceiptAndNavigate(pdfFile, true); }} - onPassword={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} + onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} + onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} /> ) : null; @@ -584,11 +579,11 @@ function SearchPage({route}: SearchPageProps) { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 9b8617766dc21..df6185aadb0dc 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -37,7 +37,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {getDraftComment} from '@libs/DraftCommentUtils'; -import {getConfirmModalPrompt} from '@libs/fileDownload/FileUtils'; +import {getFileValidationErrorText} from '@libs/fileDownload/FileUtils'; import getModalState from '@libs/getModalState'; import Performance from '@libs/Performance'; import { @@ -149,8 +149,7 @@ function ReportActionCompose({ // TODO: remove beta check after the feature is enabled const {isBetaEnabled} = usePermissions(); - const {validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, attachmentInvalidReason, attachmentInvalidReasonTitle, setUploadReceiptError, pdfFile, setPdfFile} = - useFileValidation(); + const {validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, setUploadReceiptError, pdfFile, setPdfFile, fileError} = useFileValidation(); /** * Updates the Highlight state of the composer @@ -500,12 +499,8 @@ function ReportActionCompose({ setPdfFile(null); setReceiptAndNavigate(pdfFile, true); }} - onPassword={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} + onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} + onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} /> ) : null; @@ -693,11 +688,11 @@ function ReportActionCompose({ /> From b64b63ba63f75bbd5d4f97f08d2dd2547bb616b3 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 9 Jun 2025 13:03:27 +0200 Subject: [PATCH 16/79] feat: handle PDF validation inside a hook --- src/hooks/useFilesValidation.tsx | 94 ++++++++++++++++++++++++++++++++ src/pages/Search/SearchPage.tsx | 51 +++++++---------- 2 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 src/hooks/useFilesValidation.tsx diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx new file mode 100644 index 0000000000000..551d365a7d951 --- /dev/null +++ b/src/hooks/useFilesValidation.tsx @@ -0,0 +1,94 @@ +import {Str} from 'expensify-common'; +import React, {useState} from 'react'; +import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; +import PDFThumbnail from '@components/PDFThumbnail'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import CONST from '@src/CONST'; + +// TODO: merge with useFilesValidation later to prevent code duplication + +function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [pdfFile, setPdfFile] = useState(null); + const [pdfFiles, setPdfFiles] = useState([]); + const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); + const [fileError, setFileError] = useState | null>(null); + + const styles = useThemeStyles(); + + /** + * Sets the upload receipt error modal content when an invalid receipt is uploaded + */ + const setUploadReceiptError = (error: ValueOf) => { + setIsAttachmentInvalid(true); + setFileError(error); + setPdfFile(null); + }; + + // TODO: make it accept several files + const validateAndResizeFile = (originalFile: FileObject, isPdfValidated?: boolean, isCheckingMultipleFiles?: boolean) => { + validateImageForCorruption(originalFile) + .then(() => { + const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); + if (error) { + setIsAttachmentInvalid(true); + setFileError(error); + return false; + } + // If we have a pdf file and if it is not validated then set the pdf file for validation and return + if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { + setPdfFile(originalFile); + return; + } + + // With the image size > 24MB, we use manipulateAsync to resize the image. + // It takes a long time so we should display a loading indicator while the resize image progresses. + if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setIsLoadingReceipt(true); + } + resizeImageIfNeeded(originalFile).then((resizedFile) => { + setIsLoadingReceipt(false); + proceedWithFileAction(resizedFile); + }); + }) + .catch(() => { + setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + return false; + }); + }; + + const pdfFilesToRender = pdfFiles.length ? pdfFiles : ([pdfFile].filter(Boolean) as FileObject[]); + + const PDFValidationComponent = pdfFilesToRender.length + ? pdfFilesToRender.map((file) => ( + { + setPdfFile(null); + validateAndResizeFile(file, true); + }} + onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} + onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} + /> + )) + : undefined; + + return { + validateAndResizeFile, + isAttachmentInvalid, + setIsAttachmentInvalid, + pdfFile, + setPdfFile, + setUploadReceiptError, + isLoadingReceipt, + fileError, + PDFValidationComponent, + setPdfFiles, + }; +} + +export default useFilesValidation; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 28d3bf2df75de..36125c39958d9 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -10,7 +10,6 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import * as Expensicons from '@components/Icon/Expensicons'; -import PDFThumbnail from '@components/PDFThumbnail'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -19,7 +18,7 @@ import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/ import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {PaymentData, SearchParams} from '@components/Search/types'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; -import useFileValidation from '@hooks/useFileValidation'; +import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -74,16 +73,6 @@ function SearchPage({route}: SearchPageProps) { const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); const [isDownloadExportModalVisible, setIsDownloadExportModalVisible] = useState(false); - const { - validateAndResizeFile, - setIsAttachmentInvalid, - isAttachmentInvalid, - setUploadReceiptError, - pdfFile, - setPdfFile, - isLoadingReceipt, - fileError, - } = useFileValidation(); const {q} = route.params; @@ -390,10 +379,6 @@ function SearchPage({route}: SearchPageProps) { }); }; - // TODO: to be refactored in step 3 - const hideReceiptModal = () => { - setIsAttachmentInvalid(false); - }; const saveFileAndInitMoneyRequest = (file: FileObject) => { const source = URL.createObjectURL(file as Blob); @@ -408,12 +393,28 @@ function SearchPage({route}: SearchPageProps) { navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }; + const {validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, isLoadingReceipt, fileError, PDFValidationComponent, setPdfFiles} = + useFilesValidation(saveFileAndInitMoneyRequest); + + const hideReceiptModal = () => { + setIsAttachmentInvalid(false); + }; + const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateAndResizeFile(originalFile, saveFileAndInitMoneyRequest, isPdfValidated); + validateAndResizeFile(originalFile, isPdfValidated); }; const initScanRequest = (e: DragEvent) => { const file = e?.dataTransfer?.files[0]; + if (e?.dataTransfer?.files && e?.dataTransfer?.files.length > 1) { + const files = Array.from(e?.dataTransfer?.files).map((f) => { + // eslint-disable-next-line no-param-reassign + f.uri = URL.createObjectURL(f); + return f; + }); + setPdfFiles(files); + return; + } if (file) { file.uri = URL.createObjectURL(file); setReceiptAndNavigate(file); @@ -441,20 +442,6 @@ function SearchPage({route}: SearchPageProps) { const {resetVideoPlayerData} = usePlaybackContext(); const shouldShowOfflineIndicator = currentSearchResults?.data ?? lastNonEmptySearchResults; - // TODO: to be refactored in step 3 - const PDFThumbnailView = pdfFile ? ( - { - setPdfFile(null); - setReceiptAndNavigate(pdfFile, true); - }} - onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} - onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} - /> - ) : null; - // Handles video player cleanup: // 1. On mount: Resets player if navigating from report screen // 2. On unmount: Stops video when leaving this screen @@ -550,7 +537,7 @@ function SearchPage({route}: SearchPageProps) { > {isLoadingReceipt && } - {PDFThumbnailView} + {PDFValidationComponent} Date: Wed, 11 Jun 2025 21:32:27 +0200 Subject: [PATCH 17/79] feat: file validation for mixed files --- src/hooks/useFilesValidation.tsx | 129 +++++++++++++----- src/pages/Search/SearchPage.tsx | 27 ++-- .../AttachmentPickerWithMenuItems.tsx | 2 + 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 551d365a7d951..3f67dc4846c66 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,35 +1,56 @@ import {Str} from 'expensify-common'; -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import PDFThumbnail from '@components/PDFThumbnail'; -import useThemeStyles from '@hooks/useThemeStyles'; import {resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; +import useThemeStyles from './useThemeStyles'; // TODO: merge with useFilesValidation later to prevent code duplication function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { + const styles = useThemeStyles(); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [pdfFile, setPdfFile] = useState(null); const [pdfFiles, setPdfFiles] = useState([]); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [fileError, setFileError] = useState | null>(null); + const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); + + const validatedPDFs = useRef([]); + const validFiles = useRef([]); - const styles = useThemeStyles(); + const resetValidationState = () => { + setIsAttachmentInvalid(false); + setPdfFiles([]); + setIsLoadingReceipt(false); + setFileError(null); + setValidFilesToUpload([]); + validatedPDFs.current = []; + validFiles.current = []; + } + + useEffect(() => { + if (fileError) { + return; + } + if (validFilesToUpload.length && !fileError) { + // @ts-expect-error it won't be undefined + proceedWithFileAction(validFilesToUpload.at(0)); + resetValidationState(); + } + }, [fileError, proceedWithFileAction, validFilesToUpload]); /** * Sets the upload receipt error modal content when an invalid receipt is uploaded */ const setUploadReceiptError = (error: ValueOf) => { setIsAttachmentInvalid(true); setFileError(error); - setPdfFile(null); }; - // TODO: make it accept several files - const validateAndResizeFile = (originalFile: FileObject, isPdfValidated?: boolean, isCheckingMultipleFiles?: boolean) => { - validateImageForCorruption(originalFile) + const isValidFile = (originalFile: FileObject, isCheckingMultipleFiles?: boolean) => { + return validateImageForCorruption(originalFile) .then(() => { const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); if (error) { @@ -37,21 +58,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setFileError(error); return false; } - // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { - setPdfFile(originalFile); - return; - } - - // With the image size > 24MB, we use manipulateAsync to resize the image. - // It takes a long time so we should display a loading indicator while the resize image progresses. - if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsLoadingReceipt(true); - } - resizeImageIfNeeded(originalFile).then((resizedFile) => { - setIsLoadingReceipt(false); - proceedWithFileAction(resizedFile); - }); + return true; }) .catch(() => { setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); @@ -59,35 +66,87 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }); }; - const pdfFilesToRender = pdfFiles.length ? pdfFiles : ([pdfFile].filter(Boolean) as FileObject[]); + const checkIfAllValidatedAndProceed = (error: boolean) => { + if (!validatedPDFs.current || !validFiles.current) { + return; + } + + if (validatedPDFs.current.length !== pdfFiles.length) { + return; + } + + if (!error) { + setValidFilesToUpload(validFiles.current); + } + }; + + const validateFiles = (files: FileObject[]) => { + if (!files.length) { + return; + } + + let validImages = [] as FileObject[]; + let pdfsToLoad = [] as FileObject[]; + Promise.all(files.map((file) => isValidFile(file, true).then((isValid) => (isValid ? file : null)))).then((results) => { + const filteredResults = results.filter((result): result is FileObject => result !== null); + validImages = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); + pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); + + if (validImages.length && validImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + setIsLoadingReceipt(true); + Promise.all(validImages.map((file) => resizeImageIfNeeded(file))).then((resizedFiles) => { + validImages = resizedFiles; + setIsLoadingReceipt(false); + }); + } - const PDFValidationComponent = pdfFilesToRender.length - ? pdfFilesToRender.map((file) => ( + if (validImages.length === 1 && !pdfsToLoad.length) { + // eslint-disable-next-line + proceedWithFileAction(validImages[0]); + resetValidationState(); + } + + if (pdfsToLoad.length) { + validFiles.current = validImages; + setPdfFiles(pdfsToLoad); + } else { + setValidFilesToUpload(validImages); + } + }); + }; + + const PDFValidationComponent = pdfFiles.length + ? pdfFiles.map((file) => ( { - setPdfFile(null); - validateAndResizeFile(file, true); + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + validFiles.current = [...(validFiles.current ?? []), file]; + checkIfAllValidatedAndProceed(false); + }} + onPassword={() => { + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE); + checkIfAllValidatedAndProceed(true); + }} + onLoadError={() => { + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + checkIfAllValidatedAndProceed(true); }} - onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} - onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} /> )) : undefined; return { - validateAndResizeFile, isAttachmentInvalid, setIsAttachmentInvalid, - pdfFile, - setPdfFile, - setUploadReceiptError, isLoadingReceipt, fileError, PDFValidationComponent, - setPdfFiles, + validateFiles, }; } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 36125c39958d9..2ed62b46c8871 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -379,7 +379,6 @@ function SearchPage({route}: SearchPageProps) { }); }; - const saveFileAndInitMoneyRequest = (file: FileObject) => { const source = URL.createObjectURL(file as Blob); const newReportID = generateReportID(); @@ -393,32 +392,24 @@ function SearchPage({route}: SearchPageProps) { navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }; - const {validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, isLoadingReceipt, fileError, PDFValidationComponent, setPdfFiles} = - useFilesValidation(saveFileAndInitMoneyRequest); + const {validateFiles, setIsAttachmentInvalid, isAttachmentInvalid, isLoadingReceipt, fileError, PDFValidationComponent} = useFilesValidation(saveFileAndInitMoneyRequest); const hideReceiptModal = () => { setIsAttachmentInvalid(false); }; - const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateAndResizeFile(originalFile, isPdfValidated); - }; - const initScanRequest = (e: DragEvent) => { - const file = e?.dataTransfer?.files[0]; - if (e?.dataTransfer?.files && e?.dataTransfer?.files.length > 1) { - const files = Array.from(e?.dataTransfer?.files).map((f) => { - // eslint-disable-next-line no-param-reassign - f.uri = URL.createObjectURL(f); - return f; - }); - setPdfFiles(files); + const files = Array.from(e?.dataTransfer?.files ?? []); + + if (files.length === 0) { return; } - if (file) { + files.forEach((file) => { + // eslint-disable-next-line no-param-reassign file.uri = URL.createObjectURL(file); - setReceiptAndNavigate(file); - } + }); + + validateFiles(files); }; const createExportAll = useCallback(() => { diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index e6ce21bb6e29c..77ae9f80431bd 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -12,6 +12,7 @@ import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -129,6 +130,7 @@ function AttachmentPickerWithMenuItems({ const {windowHeight, windowWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isDelegateAccessRestricted} = useDelegateUserDetails(); + const {isBetaEnabled} = usePermissions(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {canBeMissing: true}); From 817d8c9b40881bb41f9bad3671d29bf9c76e2a91 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 12 Jun 2025 11:31:09 +0200 Subject: [PATCH 18/79] feat: move confirmation error modal to the hook --- src/hooks/useFilesValidation.tsx | 98 ++++++++++++++++++-------------- src/pages/Search/SearchPage.tsx | 16 +----- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 3f67dc4846c66..4d85caffc1d0a 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,9 +1,12 @@ import {Str} from 'expensify-common'; import React, {useEffect, useRef, useState} from 'react'; +import {InteractionManager} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; +import ConfirmModal from '@components/ConfirmModal'; import PDFThumbnail from '@components/PDFThumbnail'; -import {resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import useLocalize from '@hooks/useLocalize'; +import {getFileValidationErrorText, resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; import useThemeStyles from './useThemeStyles'; @@ -11,19 +14,19 @@ import useThemeStyles from './useThemeStyles'; function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [pdfFiles, setPdfFiles] = useState([]); - const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [fileError, setFileError] = useState | null>(null); + const [pdfFilesToRender, setPdfFilesToRender] = useState([]); + const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); const validatedPDFs = useRef([]); const validFiles = useRef([]); - const resetValidationState = () => { setIsAttachmentInvalid(false); - setPdfFiles([]); + setPdfFilesToRender([]); setIsLoadingReceipt(false); setFileError(null); setValidFilesToUpload([]); @@ -66,57 +69,54 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }); }; - const checkIfAllValidatedAndProceed = (error: boolean) => { + const checkIfAllValidatedAndProceed = (shouldProceed: boolean) => { if (!validatedPDFs.current || !validFiles.current) { return; } - if (validatedPDFs.current.length !== pdfFiles.length) { + if (validatedPDFs.current.length !== pdfFilesToRender.length) { return; } - if (!error) { + if (!shouldProceed) { setValidFilesToUpload(validFiles.current); } }; const validateFiles = (files: FileObject[]) => { - if (!files.length) { - return; - } + Promise.all(files.map((file) => isValidFile(file, true).then((isValid) => (isValid ? file : null)))) + .then((validationResults) => { + const filteredResults = validationResults.filter((result): result is FileObject => result !== null); + const validImages = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); + const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); + + // Check if we need to resize images + if (validImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + // Only set loading when we actually need to resize + setIsLoadingReceipt(true); + + // Resize images + return Promise.all(validImages.map((file) => resizeImageIfNeeded(file))).then((processedImages) => { + setIsLoadingReceipt(false); + return {processedImages, pdfsToLoad}; + }); + } - let validImages = [] as FileObject[]; - let pdfsToLoad = [] as FileObject[]; - Promise.all(files.map((file) => isValidFile(file, true).then((isValid) => (isValid ? file : null)))).then((results) => { - const filteredResults = results.filter((result): result is FileObject => result !== null); - validImages = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); - pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); - - if (validImages.length && validImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { - setIsLoadingReceipt(true); - Promise.all(validImages.map((file) => resizeImageIfNeeded(file))).then((resizedFiles) => { - validImages = resizedFiles; - setIsLoadingReceipt(false); - }); - } - - if (validImages.length === 1 && !pdfsToLoad.length) { - // eslint-disable-next-line - proceedWithFileAction(validImages[0]); - resetValidationState(); - } - - if (pdfsToLoad.length) { - validFiles.current = validImages; - setPdfFiles(pdfsToLoad); - } else { - setValidFilesToUpload(validImages); - } - }); + // No resizing needed, just return the valid images + return Promise.resolve({processedImages: validImages, pdfsToLoad}); + }) + .then(({processedImages, pdfsToLoad}) => { + if (pdfsToLoad.length) { + validFiles.current = processedImages; + setPdfFilesToRender(pdfsToLoad); + } else { + setValidFilesToUpload(processedImages); + } + }); }; - const PDFValidationComponent = pdfFiles.length - ? pdfFiles.map((file) => ( + const PDFValidationComponent = pdfFilesToRender.length + ? pdfFilesToRender.map((file) => ( void) { )) : undefined; + const ErrorModal = ( + + ); + return { isAttachmentInvalid, - setIsAttachmentInvalid, isLoadingReceipt, - fileError, PDFValidationComponent, validateFiles, + ErrorModal, }; } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 2ed62b46c8871..051dd0e9c7466 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -392,11 +392,7 @@ function SearchPage({route}: SearchPageProps) { navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }; - const {validateFiles, setIsAttachmentInvalid, isAttachmentInvalid, isLoadingReceipt, fileError, PDFValidationComponent} = useFilesValidation(saveFileAndInitMoneyRequest); - - const hideReceiptModal = () => { - setIsAttachmentInvalid(false); - }; + const {validateFiles, isLoadingReceipt, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); const initScanRequest = (e: DragEvent) => { const files = Array.from(e?.dataTransfer?.files ?? []); @@ -556,15 +552,7 @@ function SearchPage({route}: SearchPageProps) { - + {ErrorModal} )} Date: Thu, 12 Jun 2025 17:30:18 +0200 Subject: [PATCH 19/79] refactor: change getFileValidationErrorText to return translated paths --- src/components/AttachmentModal.tsx | 8 +-- src/hooks/useFilesValidation.tsx | 41 ++++++++++--- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- src/libs/fileDownload/FileUtils.ts | 59 ++++++++++++++----- .../ReportActionCompose.tsx | 4 +- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index bd55b6f73e632..24ede6e4b04fe 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -197,7 +197,7 @@ function AttachmentModal({ const styles = useThemeStyles(); const [isModalOpen, setIsModalOpen] = useState(defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); - const [fileError, setFileError] = useState | undefined>(undefined); + const [fileError, setFileError] = useState | null>(null); const [isFileErrorModalVisible, setIsFileErrorModalVisible] = useState(false); const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isAuthTokenRequiredState, setIsAuthTokenRequiredState] = useState(isAuthTokenRequired); @@ -327,7 +327,7 @@ function AttachmentModal({ setIsFileErrorModalVisible(false); setIsDeleteReceiptConfirmModalVisible(false); InteractionManager.runAfterInteractions(() => { - setFileError(undefined); + setFileError(null); }); }, []); @@ -728,11 +728,11 @@ function AttachmentModal({ {!isReceiptAttachment && ( void) { const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); + const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const validatedPDFs = useRef([]); const validFiles = useRef([]); @@ -28,11 +29,32 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setIsAttachmentInvalid(false); setPdfFilesToRender([]); setIsLoadingReceipt(false); + setIsValidatingMultipleFiles(false); setFileError(null); setValidFilesToUpload([]); validatedPDFs.current = []; validFiles.current = []; - } + }; + + const hideModalAndReset = () => { + setIsAttachmentInvalid(false); + InteractionManager.runAfterInteractions(() => { + setPdfFilesToRender([]); + setIsLoadingReceipt(false); + setIsValidatingMultipleFiles(false); + setFileError(null); + setValidFilesToUpload([]); + validatedPDFs.current = []; + validFiles.current = []; + }); + }; + + const onConfirm = () => { + if (validFilesToUpload.length) { + proceedWithFileAction(validFilesToUpload[0]); + } + hideModalAndReset(); + }; useEffect(() => { if (fileError) { @@ -84,7 +106,10 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }; const validateFiles = (files: FileObject[]) => { - Promise.all(files.map((file) => isValidFile(file, true).then((isValid) => (isValid ? file : null)))) + if (files.length > 1) { + setIsValidatingMultipleFiles(true); + } + Promise.all(files.map((file) => isValidFile(file, files.length > 1).then((isValid) => (isValid ? file : null)))) .then((validationResults) => { const filteredResults = validationResults.filter((result): result is FileObject => result !== null); const validImages = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); @@ -142,14 +167,14 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const ErrorModal = ( ); diff --git a/src/languages/en.ts b/src/languages/en.ts index c8d60b2aeec84..70525e897f4c6 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -650,7 +650,8 @@ const translations = { someFilesCantBeUploaded: "Some files can't be uploaded", sizeLimitExceeded: "Files must be under 10 MB. Any larger files won't be uploaded.", maxFileLimitExceeded: "You can upload up to 30 receipts at a time. Any extras won't be uploaded.", - unsupportedFileType: "files aren't supported. Only supported file types will be uploaded. Learn more about supported formats." + unsupportedFileType: "files aren't supported. Only supported file types will be uploaded. Learn more about supported formats.", + passwordProtected: "Password-protected PDFs aren't supported. Only supported files will be uploaded." }, dropzone: { addAttachments: 'Add attachments', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3367d6b5ed1db..efb7989ab36b0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -645,7 +645,8 @@ const translations = { someFilesCantBeUploaded: "Algunos archivos no se pueden subir", sizeLimitExceeded: "Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.", maxFileLimitExceeded: "Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.", - unsupportedFileType: "archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles." + unsupportedFileType: "archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles.", + passwordProtected: "Los PDFs con contraseña no son compatibles. Solo se subirán los archivos compatibles" }, dropzone: { addAttachments: 'Añadir archivos adjuntos', diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index af74ee9e92268..cc87137e1b0c5 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -401,40 +401,67 @@ const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, }; const getFileValidationErrorText = ( - validationError: ValueOf, + validationError: ValueOf | null, ): { - title: TranslationPaths; - reason: TranslationPaths; + title: string; + reason: string; } => { + if (!validationError) { + return { + title: '', + reason: '', + }; + } switch (validationError) { case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: - return {title: 'attachmentPicker.wrongFileType', reason: 'attachmentPicker.notAllowedExtension'}; + return { + title: translateLocal('attachmentPicker.wrongFileType'), + reason: translateLocal('attachmentPicker.notAllowedExtension'), + }; case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: - return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.unsupportedFileType'}; + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.unsupportedFileType'), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: - return {title: 'attachmentPicker.attachmentTooLarge', reason: 'attachmentPicker.sizeExceeded'}; + return { + title: translateLocal('attachmentPicker.attachmentTooLarge'), + reason: translateLocal('attachmentPicker.sizeExceeded'), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE: - return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.sizeLimitExceeded'}; + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.sizeLimitExceeded'), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: - return {title: 'attachmentPicker.attachmentTooSmall', reason: 'attachmentPicker.sizeNotMet'}; + return { + title: translateLocal('attachmentPicker.attachmentTooSmall'), + reason: translateLocal('attachmentPicker.sizeNotMet'), + }; case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: - return {title: 'attachmentPicker.attachmentError', reason: 'attachmentPicker.folderNotAllowedMessage'}; + return { + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.folderNotAllowedMessage'), + }; case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: - return {title: 'attachmentPicker.someFilesCantBeUploaded', reason: 'attachmentPicker.maxFileLimitExceeded'}; + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.maxFileLimitExceeded'), + }; case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: return { - title: 'attachmentPicker.attachmentError', - reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.errorWhileSelectingCorruptedAttachment'), }; case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE: return { - title: 'attachmentPicker.attachmentError', - reason: 'attachmentPicker.protectedPDFNotSupported', + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.protectedPDFNotSupported'), }; default: return { - title: 'attachmentPicker.attachmentError', - reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.errorWhileSelectingCorruptedAttachment'), }; } }; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index ff8bb3f99f272..658047fd5fe68 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -706,11 +706,11 @@ function ReportActionCompose({ /> From 69716b90c608b92007e6a59d9ede1992eb5cefcc Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 12 Jun 2025 19:00:38 +0200 Subject: [PATCH 20/79] feat: display invalid file type --- src/hooks/useFilesValidation.tsx | 14 ++++++++++---- src/languages/en.ts | 11 ++++++++--- src/languages/es.ts | 18 ++++++++++++------ src/languages/params.ts | 7 ++++++- src/libs/fileDownload/FileUtils.ts | 9 ++++++++- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index df7995be4d3df..4e4286ed34bb1 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -5,9 +5,9 @@ import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import ConfirmModal from '@components/ConfirmModal'; import PDFThumbnail from '@components/PDFThumbnail'; -import useLocalize from '@hooks/useLocalize'; -import {getFileValidationErrorText, resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import {getFileValidationErrorText, resizeImageIfNeeded, splitExtensionFromFileName, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; +import useLocalize from './useLocalize'; import useThemeStyles from './useThemeStyles'; // TODO: merge with useFilesValidation later to prevent code duplication @@ -21,6 +21,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); + const [invalidFileExtension, setInvalidFileExtension] = useState(''); const validatedPDFs = useRef([]); const validFiles = useRef([]); @@ -32,6 +33,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setIsValidatingMultipleFiles(false); setFileError(null); setValidFilesToUpload([]); + setInvalidFileExtension(''); validatedPDFs.current = []; validFiles.current = []; }; @@ -44,6 +46,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setIsValidatingMultipleFiles(false); setFileError(null); setValidFilesToUpload([]); + setInvalidFileExtension(''); validatedPDFs.current = []; validFiles.current = []; }); @@ -81,6 +84,9 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { if (error) { setIsAttachmentInvalid(true); setFileError(error); + if (error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE) { + setInvalidFileExtension(splitExtensionFromFileName(originalFile.name ?? '').fileExtension); + } return false; } return true; @@ -167,11 +173,11 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const ErrorModal = ( `${fileType} files aren't supported. Only supported file types will be uploaded. Learn more about supported formats.`, + passwordProtected: "Password-protected PDFs aren't supported. Only supported files will be uploaded.", }, dropzone: { addAttachments: 'Add attachments', @@ -983,7 +984,11 @@ const translations = { amount: 'Amount', taxAmount: 'Tax amount', taxRate: 'Tax rate', - approve: ({formattedAmount}: {formattedAmount?: string} = {}) => (formattedAmount ? `Approve ${formattedAmount}` : 'Approve'), + approve: ({ + formattedAmount, + }: { + formattedAmount?: string; + } = {}) => (formattedAmount ? `Approve ${formattedAmount}` : 'Approve'), approved: 'Approved', cash: 'Cash', card: 'Card', diff --git a/src/languages/es.ts b/src/languages/es.ts index efb7989ab36b0..72e0c5dfdfb85 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -81,6 +81,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -642,11 +643,12 @@ const translations = { attachmentImageTooLarge: 'Esta imagen es demasiado grande para obtener una vista previa antes de subirla.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Solamente puedes suber ${fileLimit} archivos a la vez.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo supera los ${maxUploadSizeInMB} MB. Por favor, vuelve a intentarlo.`, - someFilesCantBeUploaded: "Algunos archivos no se pueden subir", - sizeLimitExceeded: "Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.", - maxFileLimitExceeded: "Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.", - unsupportedFileType: "archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles.", - passwordProtected: "Los PDFs con contraseña no son compatibles. Solo se subirán los archivos compatibles" + someFilesCantBeUploaded: 'Algunos archivos no se pueden subir', + sizeLimitExceeded: 'Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.', + maxFileLimitExceeded: 'Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.', + unsupportedFileType: ({fileType}: FileTypeParams) => + `${fileType} archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles.`, + passwordProtected: 'Los PDFs con contraseña no son compatibles. Solo se subirán los archivos compatibles', }, dropzone: { addAttachments: 'Añadir archivos adjuntos', @@ -978,7 +980,11 @@ const translations = { amount: 'Importe', taxAmount: 'Importe del impuesto', taxRate: 'Tasa de impuesto', - approve: ({formattedAmount}: {formattedAmount?: string} = {}) => (formattedAmount ? `Aprobar ${formattedAmount}` : 'Aprobar'), + approve: ({ + formattedAmount, + }: { + formattedAmount?: string; + } = {}) => (formattedAmount ? `Aprobar ${formattedAmount}` : 'Aprobar'), approved: 'Aprobado', cash: 'Efectivo', card: 'Tarjeta', diff --git a/src/languages/params.ts b/src/languages/params.ts index fc2a8e4e9e798..b1931f526911d 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -667,7 +667,11 @@ type WorkspaceMemberList = { type FileLimitParams = { fileLimit: number; -}; +} + +type FileTypeParams = { + fileType: string; +} type CompanyCardBankName = { bankName: string; @@ -784,6 +788,7 @@ export type { AutoPayApprovedReportsLimitErrorParams, FeatureNameParams, FileLimitParams, + FileTypeParams, SpreadSheetColumnParams, SpreadFieldNameParams, AssignedCardParams, diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index cc87137e1b0c5..a3f082cb58d87 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -400,8 +400,15 @@ const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, return ''; }; +type TranslationAdditionalData = { + maxUploadSizeInMB?: number; + fileLimit?: number; + fileType?: string; +}; + const getFileValidationErrorText = ( validationError: ValueOf | null, + additionalData: TranslationAdditionalData = {}, ): { title: string; reason: string; @@ -421,7 +428,7 @@ const getFileValidationErrorText = ( case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: return { title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), - reason: translateLocal('attachmentPicker.unsupportedFileType'), + reason: translateLocal('attachmentPicker.unsupportedFileType', {fileType: additionalData.fileType ?? ''}), }; case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: return { From a41837d583cdc5700d5772c3bc85552ff2d1489d Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 12 Jun 2025 20:15:01 +0200 Subject: [PATCH 21/79] fix: show link --- src/hooks/useFilesValidation.tsx | 20 +++++++++++++++++++- src/languages/en.ts | 3 ++- src/languages/es.ts | 3 ++- src/libs/Permissions.ts | 1 + 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 4e4286ed34bb1..f266ee559c661 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -5,6 +5,8 @@ import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import ConfirmModal from '@components/ConfirmModal'; import PDFThumbnail from '@components/PDFThumbnail'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import {getFileValidationErrorText, resizeImageIfNeeded, splitExtensionFromFileName, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; import useLocalize from './useLocalize'; @@ -171,13 +173,29 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { )) : undefined; + const getModalPrompt = () => { + if (!fileError) { + return ''; + } + const prompt = getFileValidationErrorText(fileError, {fileType: invalidFileExtension}).reason; + if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE) { + return ( + + {prompt} + {translate('attachmentPicker.learnMoreAboutSupportedFiles')} + + ); + } + return prompt; + }; + const ErrorModal = ( `${fileType} files aren't supported. Only supported file types will be uploaded. Learn more about supported formats.`, + unsupportedFileType: ({fileType}: FileTypeParams) => `${fileType} files aren't supported. Only supported file types will be uploaded.`, + learnMoreAboutSupportedFiles: 'Learn more about supported formats.', passwordProtected: "Password-protected PDFs aren't supported. Only supported files will be uploaded.", }, dropzone: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 72e0c5dfdfb85..4da9aff5b4f3f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -647,7 +647,8 @@ const translations = { sizeLimitExceeded: 'Los archivos deben ser menores a 10 MB. Los archivos más grandes no se subirán.', maxFileLimitExceeded: 'Puedes subir hasta 30 recibos a la vez. Los extras no se subirán.', unsupportedFileType: ({fileType}: FileTypeParams) => - `${fileType} archivos no son compatibles. Solo se subirán los archivos compatibles. Obtén más información sobre los formatos compatibles.`, + `${fileType} archivos no son compatibles. Solo se subirán los archivos compatibles.`, + learnMoreAboutSupportedFiles: 'Obtén más información sobre los formatos compatibles.', passwordProtected: 'Los PDFs con contraseña no son compatibles. Solo se subirán los archivos compatibles', }, dropzone: { diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 90b9fa6d835d7..d451051cab176 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; function canUseAllBetas(betas: OnyxEntry): boolean { + return true; return !!betas?.includes(CONST.BETAS.ALL); } From ebd4a38ded1f03a2c60fdc27c852330ba765b7ec Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 16 Jun 2025 10:14:16 +0200 Subject: [PATCH 22/79] feat: handle max file limit error --- src/CONST/index.ts | 2 +- src/hooks/useFilesValidation.tsx | 49 +++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f96359b4ade3c..e6bddb87fbeb1 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -441,7 +441,7 @@ const CONST = { // Allowed extensions for receipts ALLOWED_RECEIPT_EXTENSIONS: ['heif', 'heic', 'jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'], - MAX_FILE_LIMIT: 2, + MAX_FILE_LIMIT: 30, }, // Allowed extensions for spreadsheets import diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index f266ee559c661..54b27e0d0855c 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -27,6 +27,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const validatedPDFs = useRef([]); const validFiles = useRef([]); + const filesToValidate = useRef([]); const resetValidationState = () => { setIsAttachmentInvalid(false); @@ -38,6 +39,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setInvalidFileExtension(''); validatedPDFs.current = []; validFiles.current = []; + filesToValidate.current = []; }; const hideModalAndReset = () => { @@ -51,26 +53,20 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setInvalidFileExtension(''); validatedPDFs.current = []; validFiles.current = []; + filesToValidate.current = []; }); }; - const onConfirm = () => { - if (validFilesToUpload.length) { - proceedWithFileAction(validFilesToUpload[0]); - } - hideModalAndReset(); - }; - useEffect(() => { - if (fileError) { + if (isAttachmentInvalid) { return; } - if (validFilesToUpload.length && !fileError) { + if (validFilesToUpload.length && !isAttachmentInvalid) { // @ts-expect-error it won't be undefined proceedWithFileAction(validFilesToUpload.at(0)); resetValidationState(); } - }, [fileError, proceedWithFileAction, validFilesToUpload]); + }, [isAttachmentInvalid, proceedWithFileAction, validFilesToUpload]); /** * Sets the upload receipt error modal content when an invalid receipt is uploaded */ @@ -99,7 +95,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }); }; - const checkIfAllValidatedAndProceed = (shouldProceed: boolean) => { + const checkIfAllValidatedAndProceed = (hasError: boolean) => { if (!validatedPDFs.current || !validFiles.current) { return; } @@ -108,15 +104,12 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { return; } - if (!shouldProceed) { + if (!hasError) { setValidFilesToUpload(validFiles.current); } }; const validateFiles = (files: FileObject[]) => { - if (files.length > 1) { - setIsValidatingMultipleFiles(true); - } Promise.all(files.map((file) => isValidFile(file, files.length > 1).then((isValid) => (isValid ? file : null)))) .then((validationResults) => { const filteredResults = validationResults.filter((result): result is FileObject => result !== null); @@ -148,6 +141,30 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }); }; + const handleFilesValidation = (files: FileObject[]) => { + if (files.length > 1) { + setIsValidatingMultipleFiles(true); + } + if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { + filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); + setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); + } else { + validateFiles(files); + } + }; + + const onConfirm = () => { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + setIsAttachmentInvalid(false); + validateFiles(filesToValidate.current); + return; + } + if (validFilesToUpload.length) { + proceedWithFileAction(validFilesToUpload[0]); + } + hideModalAndReset(); + }; + const PDFValidationComponent = pdfFilesToRender.length ? pdfFilesToRender.map((file) => ( void) { isAttachmentInvalid, isLoadingReceipt, PDFValidationComponent, - validateFiles, + validateFiles: handleFilesValidation, ErrorModal, }; } From 6ac2a505de5d96bbee6fc0e39f00f0303a8337f0 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 16 Jun 2025 16:48:35 +0200 Subject: [PATCH 23/79] feat: accept multiple files on mobile --- .../AttachmentPicker/index.native.tsx | 159 +++++++++++------- .../AttachmentPickerWithMenuItems.tsx | 18 +- .../ReportActionCompose.tsx | 1 + 3 files changed, 118 insertions(+), 60 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 7d7f87016bf99..ccd12670b40b2 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -152,46 +152,70 @@ function AttachmentPicker({ return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); } - // TODO: refactor this to use multiple files - const targetAsset = response.assets?.[0]; - const targetAssetUri = targetAsset?.uri; - if (!targetAssetUri) { + const assets = response.assets; + if (!assets || assets.length === 0) { return resolve(); } - if (targetAsset?.type?.startsWith('image')) { - verifyFileFormat({fileUri: targetAssetUri, formatSignatures: CONST.HEIC_SIGNATURES}) - .then((isHEIC) => { - // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature - if (isHEIC && targetAssetUri) { - ImageManipulator.manipulate(targetAssetUri) - .renderAsync() - .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) - .then((manipulationResult) => { - const uri = manipulationResult.uri; - const convertedAsset = { - uri, - name: uri - .substring(uri.lastIndexOf('/') + 1) - .split('?') - .at(0), - type: 'image/jpeg', - width: manipulationResult.width, - height: manipulationResult.height, - }; - - return resolve([convertedAsset]); - }) - .catch((err) => reject(err)); - } else { - return resolve(response.assets); - } - }) - .catch((err) => reject(err)); - } else { - return resolve(response.assets); - } + // Process all assets + const processedAssets: Asset[] = []; + let processedCount = 0; + + const checkAllProcessed = () => { + processedCount++; + if (processedCount === assets.length) { + resolve(processedAssets.length > 0 ? processedAssets : undefined); + } + }; + + assets.forEach((asset) => { + if (!asset.uri) { + checkAllProcessed(); + return; + } + + if (asset.type?.startsWith('image')) { + verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}) + .then((isHEIC) => { + // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature + if (isHEIC && asset.uri) { + ImageManipulator.manipulate(asset.uri) + .renderAsync() + .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) + .then((manipulationResult) => { + const uri = manipulationResult.uri; + const convertedAsset = { + uri, + name: uri + .substring(uri.lastIndexOf('/') + 1) + .split('?') + .at(0), + type: 'image/jpeg', + width: manipulationResult.width, + height: manipulationResult.height, + }; + processedAssets.push(convertedAsset); + checkAllProcessed(); + }) + .catch((err) => { + console.error(`Error processing HEIC asset: ${asset.uri}`, err); + checkAllProcessed(); + }); + } else { + processedAssets.push(asset); + checkAllProcessed(); + } + }) + .catch((err) => { + console.error(`Error verifying file format for asset: ${asset.uri}`, err); + checkAllProcessed(); + }); + } else { + processedAssets.push(asset); + checkAllProcessed(); + } + }); }); }), [fileLimit, showGeneralAlert, type], @@ -313,16 +337,15 @@ function AttachmentPicker({ * sends the selected attachment to the caller (parent component) */ const pickAttachment = useCallback( - (attachments: Asset[] | LocalCopy[] | void = []): Promise | undefined => { + (attachments: Asset[] | LocalCopy[] | void = []): Promise | undefined => { if (!attachments || attachments.length === 0) { onCanceled.current(); - return Promise.resolve([]); + return Promise.resolve(); } const filesToProcess = attachments.map((fileData) => { if (!fileData) { - onCanceled.current(); - return Promise.resolve(); + return Promise.resolve(null); } /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ @@ -345,20 +368,10 @@ function AttachmentPicker({ fileDataObject.height = height; return fileDataObject; }) - .then((file) => { - return getDataForUpload(file) - .then((result) => completeAttachmentSelection.current([result])) - .catch((error) => { - if (error instanceof Error) { - showGeneralAlert(error.message); - } else { - showGeneralAlert('An unknown error occurred'); - } - throw error; - }); - }) + .then((file) => getDataForUpload(file)) .catch(() => { showImageCorruptionAlert(); + return null; }); } @@ -370,21 +383,53 @@ function AttachmentPicker({ if (fileDataObject.width <= 0 || fileDataObject.height <= 0) { showImageCorruptionAlert(); - return Promise.resolve(); // Skip processing this corrupted file + return null; + } + + // Check if the file dimensions indicate corruption + if ((typeof fileDataObject.width === 'number' && fileDataObject.width <= 0) || (typeof fileDataObject.height === 'number' && fileDataObject.height <= 0)) { + showImageCorruptionAlert(); + return null; } - return validateAndCompleteAttachmentSelection(fileDataObject); + return getDataForUpload(fileDataObject); }) .catch(() => { showImageCorruptionAlert(); + return null; }); } - return validateAndCompleteAttachmentSelection(fileDataObject); + + // For non-image files + if ((typeof fileDataObject.width === 'number' && fileDataObject.width <= 0) || (typeof fileDataObject.height === 'number' && fileDataObject.height <= 0)) { + showImageCorruptionAlert(); + return Promise.resolve(null); + } + + return getDataForUpload(fileDataObject).catch((error: Error) => { + showGeneralAlert(error.message); + return null; + }); }); - return Promise.all(filesToProcess); + return Promise.all(filesToProcess) + .then((results) => { + const validResults = results.filter((result): result is FileObject => result !== null); + if (validResults.length > 0) { + completeAttachmentSelection.current(validResults); + } else { + onCanceled.current(); + } + }) + .catch((error) => { + if (error instanceof Error) { + showGeneralAlert(error.message); + } else { + showGeneralAlert('An unknown error occurred'); + } + }); }, - [shouldValidateImage, validateAndCompleteAttachmentSelection, showGeneralAlert, showImageCorruptionAlert], + [shouldValidateImage, showGeneralAlert, showImageCorruptionAlert], ); /** diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index be6f65180b198..857913c2a7187 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -51,6 +51,9 @@ type AttachmentPickerWithMenuItemsProps = { /** Callback to open the file in the modal */ displayFileInModal: (url: FileObject) => void; + /** Callback to open multiple files in the modal */ + displayMultipleFilesInModal: (files: FileObject[]) => void; + /** Whether or not the full size composer is available */ isFullComposerAvailable: boolean; @@ -108,6 +111,7 @@ function AttachmentPickerWithMenuItems({ currentUserPersonalDetails, reportParticipantIDs, displayFileInModal, + displayMultipleFilesInModal, isFullComposerAvailable, isComposerFullSize, reportID, @@ -281,16 +285,24 @@ function AttachmentPickerWithMenuItems({ // 4. And the Create button is at the bottom. const createButtonContainerStyles = [styles.flexGrow0, styles.flexShrink0]; + const isMultipleDragAndDropEnabled = isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP); + return ( {({openPicker}) => { const triggerAttachmentPicker = () => { onTriggerAttachmentPicker(); openPicker({ - onPicked: (data) => displayFileInModal(data.at(0) ?? {}), + onPicked: (data) => { + if (data.length > 1 && isMultipleDragAndDropEnabled) { + displayMultipleFilesInModal(data); + } else { + displayFileInModal(data.at(0) ?? {}); + } + }, onCanceled: onCanceledAttachmentPicker, }); }; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 14ab1a8940771..e14d5c9f263ee 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -560,6 +560,7 @@ function ReportActionCompose({ <> Date: Mon, 16 Jun 2025 21:02:01 +0200 Subject: [PATCH 24/79] feat: handle multiple different errors --- src/hooks/useFilesValidation.tsx | 171 ++++++++++++++++++++----------- src/pages/Search/SearchPage.tsx | 13 +-- 2 files changed, 112 insertions(+), 72 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 54b27e0d0855c..a11473d8e8ecf 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; @@ -14,65 +14,56 @@ import useThemeStyles from './useThemeStyles'; // TODO: merge with useFilesValidation later to prevent code duplication +type ErrorObject = { + error: ValueOf; + fileExtension?: string; +}; + function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [fileError, setFileError] = useState | null>(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [invalidFileExtension, setInvalidFileExtension] = useState(''); + const [errorQueue, setErrorQueue] = useState([]); + const [currentErrorIndex, setCurrentErrorIndex] = useState(0); const validatedPDFs = useRef([]); const validFiles = useRef([]); const filesToValidate = useRef([]); + const collectedErrors = useRef([]); - const resetValidationState = () => { - setIsAttachmentInvalid(false); + const resetValidationState = useCallback(() => { + setIsErrorModalVisible(false); setPdfFilesToRender([]); setIsLoadingReceipt(false); setIsValidatingMultipleFiles(false); setFileError(null); setValidFilesToUpload([]); setInvalidFileExtension(''); + setErrorQueue([]); + setCurrentErrorIndex(0); validatedPDFs.current = []; validFiles.current = []; filesToValidate.current = []; - }; + collectedErrors.current = []; + }, []); - const hideModalAndReset = () => { - setIsAttachmentInvalid(false); + const hideModalAndReset = useCallback(() => { + setIsErrorModalVisible(false); InteractionManager.runAfterInteractions(() => { - setPdfFilesToRender([]); - setIsLoadingReceipt(false); - setIsValidatingMultipleFiles(false); - setFileError(null); - setValidFilesToUpload([]); - setInvalidFileExtension(''); - validatedPDFs.current = []; - validFiles.current = []; - filesToValidate.current = []; + resetValidationState(); }); - }; + }, [resetValidationState]); - useEffect(() => { - if (isAttachmentInvalid) { - return; - } - if (validFilesToUpload.length && !isAttachmentInvalid) { - // @ts-expect-error it won't be undefined - proceedWithFileAction(validFilesToUpload.at(0)); - resetValidationState(); - } - }, [isAttachmentInvalid, proceedWithFileAction, validFilesToUpload]); - /** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - */ - const setUploadReceiptError = (error: ValueOf) => { - setIsAttachmentInvalid(true); + + const setErrorAndOpenModal = (error: ValueOf) => { setFileError(error); + setIsErrorModalVisible(true); }; const isValidFile = (originalFile: FileObject, isCheckingMultipleFiles?: boolean) => { @@ -80,22 +71,22 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { .then(() => { const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); if (error) { - setIsAttachmentInvalid(true); - setFileError(error); - if (error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE) { - setInvalidFileExtension(splitExtensionFromFileName(originalFile.name ?? '').fileExtension); - } + const errorData = { + error, + fileExtension: error === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE ? splitExtensionFromFileName(originalFile.name ?? '').fileExtension : undefined, + }; + collectedErrors.current.push(errorData); return false; } return true; }) .catch(() => { - setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); return false; }); }; - const checkIfAllValidatedAndProceed = (hasError: boolean) => { + const checkIfAllValidatedAndProceed = useCallback(() => { if (!validatedPDFs.current || !validFiles.current) { return; } @@ -104,12 +95,35 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { return; } - if (!hasError) { + if (validFiles.current.length > 0) { setValidFilesToUpload(validFiles.current); } - }; - const validateFiles = (files: FileObject[]) => { + if (collectedErrors.current.length > 0) { + setErrorQueue(collectedErrors.current); + setCurrentErrorIndex(0); + const firstError = collectedErrors.current.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); + } + } else if (validFiles.current.length > 0) { + // No errors, proceed with valid files + const firstValidFile = validFiles.current.at(0); + if (firstValidFile) { + proceedWithFileAction(firstValidFile); + resetValidationState(); + } + } + }, [pdfFilesToRender.length, proceedWithFileAction, resetValidationState]); + + const validateAndResizeFiles = (files: FileObject[]) => { + // Reset collected errors for new validation + collectedErrors.current = []; + Promise.all(files.map((file) => isValidFile(file, files.length > 1).then((isValid) => (isValid ? file : null)))) .then((validationResults) => { const filteredResults = validationResults.filter((result): result is FileObject => result !== null); @@ -136,31 +150,67 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { validFiles.current = processedImages; setPdfFilesToRender(pdfsToLoad); } else { - setValidFilesToUpload(processedImages); + if (processedImages.length > 0) { + setValidFilesToUpload(processedImages); + } + + if (collectedErrors.current.length > 0) { + setErrorQueue(collectedErrors.current); + setCurrentErrorIndex(0); + const firstError = collectedErrors.current.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); + } + } else if (processedImages.length > 0) { + // No errors, proceed with valid files immediately + const firstValidFile = processedImages.at(0); + if (firstValidFile) { + proceedWithFileAction(firstValidFile); + resetValidationState(); + } + } } }); }; - const handleFilesValidation = (files: FileObject[]) => { + const validateFiles = (files: FileObject[]) => { if (files.length > 1) { setIsValidatingMultipleFiles(true); } if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) { filesToValidate.current = files.slice(0, CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT); - setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); + setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); } else { - validateFiles(files); + validateAndResizeFiles(files); } }; const onConfirm = () => { if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { - setIsAttachmentInvalid(false); - validateFiles(filesToValidate.current); + setIsErrorModalVisible(false); + validateAndResizeFiles(filesToValidate.current); return; } - if (validFilesToUpload.length) { - proceedWithFileAction(validFilesToUpload[0]); + + if (currentErrorIndex < errorQueue.length - 1) { + const nextIndex = currentErrorIndex + 1; + const nextError = errorQueue.at(nextIndex); + if (nextError) { + setCurrentErrorIndex(nextIndex); + setFileError(nextError.error); + setInvalidFileExtension(nextError.fileExtension ?? ''); + return; + } + } + + // All errors have been shown, proceed with valid files + const firstValidFile = validFilesToUpload.at(0); + if (firstValidFile) { + proceedWithFileAction(firstValidFile); } hideModalAndReset(); }; @@ -174,23 +224,23 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { onLoadSuccess={() => { validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; validFiles.current = [...(validFiles.current ?? []), file]; - checkIfAllValidatedAndProceed(false); + checkIfAllValidatedAndProceed(); }} onPassword={() => { validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; - setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE); - checkIfAllValidatedAndProceed(true); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); + checkIfAllValidatedAndProceed(); }} onLoadError={() => { validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; - setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); - checkIfAllValidatedAndProceed(true); + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + checkIfAllValidatedAndProceed(); }} /> )) : undefined; - const getModalPrompt = () => { + const getModalPrompt = useCallback(() => { if (!fileError) { return ''; } @@ -199,19 +249,19 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { return ( {prompt} - {translate('attachmentPicker.learnMoreAboutSupportedFiles')} + {translate('attachmentPicker.learnMoreAboutSupportedFiles')} ); } return prompt; - }; + }, [fileError, invalidFileExtension, translate]); const ErrorModal = ( void) { ); return { - isAttachmentInvalid, isLoadingReceipt, PDFValidationComponent, - validateFiles: handleFilesValidation, + validateFiles, ErrorModal, }; } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9d8f5f9201cc6..67ae59306ebd3 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -37,7 +37,6 @@ import { search, unholdMoneyRequestOnSearch, } from '@libs/actions/Search'; -import {getFileValidationErrorText} from '@libs/fileDownload/FileUtils'; import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -461,7 +460,7 @@ function SearchPage({route}: SearchPageProps) { <> {isLoadingReceipt && } - {PDFThumbnailView} + {PDFValidationComponent} - + {ErrorModal} {!!selectionMode && selectionMode?.isEnabled && ( From 4db66820510a3f833a0141ad61038dc682bb3716 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Wed, 18 Jun 2025 20:01:13 +0200 Subject: [PATCH 25/79] feat: move function to be reused --- src/libs/actions/TransactionEdit.ts | 32 ++++++++++++- .../step/IOURequestStepScan/index.native.tsx | 38 +++++---------- .../request/step/IOURequestStepScan/index.tsx | 47 +++++-------------- 3 files changed, 52 insertions(+), 65 deletions(-) diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 4e992dc0660b0..e791757db78be 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -1,9 +1,11 @@ +import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {Connection, OnyxEntry} from 'react-native-onyx'; +import {formatCurrentUserToAttendee} from '@libs/IOUUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; -import {getDraftTransactions} from './Transaction'; +import type {PersonalDetails, Transaction} from '@src/types/onyx'; +import {generateTransactionID, getDraftTransactions} from './Transaction'; let connection: Connection; @@ -117,6 +119,31 @@ function removeTransactionReceipt(transactionID: string | undefined) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {receipt: null}); } +type BuildOptimisticTransactionParams = { + initialTransaction: Partial; + currentUserPersonalDetails: PersonalDetails; + reportID: string; +}; + +function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUserPersonalDetails, reportID}: BuildOptimisticTransactionParams): Transaction { + const newTransactionID = generateTransactionID(); + const {currency, iouRequestType, isFromGlobalCreate, splitPayerAccountIDs} = initialTransaction ?? {}; + const newTransaction = { + amount: 0, + created: format(new Date(), 'yyyy-MM-dd'), + currency, + comment: {attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID)}, + iouRequestType, + reportID, + transactionID: newTransactionID, + isFromGlobalCreate, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + splitPayerAccountIDs, + } as Transaction; + createDraftTransaction(newTransaction); + return newTransaction; +} + export { createBackupTransaction, removeBackupTransaction, @@ -126,4 +153,5 @@ export { removeTransactionReceipt, removeDraftTransactions, removeDraftSplitTransaction, + buildOptimisticTransactionAndCreateDraft, }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 2d933597c81e4..03928545df881 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -1,5 +1,4 @@ import {useFocusEffect} from '@react-navigation/core'; -import {format} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, Alert, AppState, InteractionManager, StyleSheet, View} from 'react-native'; @@ -11,7 +10,6 @@ import {RESULTS} from 'react-native-permissions'; import Animated, {runOnJS, useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated'; import type {Camera, PhotoFile, Point} from 'react-native-vision-camera'; import {useCameraDevice} from 'react-native-vision-camera'; -import type {TupleToUnion} from 'type-fest'; import MultiScan from '@assets/images/educational-illustration__multi-scan.svg'; import TestReceipt from '@assets/images/fake-receipt.png'; import Hand from '@assets/images/hand.svg'; @@ -34,14 +32,14 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import setTestReceipt from '@libs/actions/setTestReceipt'; import {dismissProductTraining} from '@libs/actions/Welcome'; -import {isValidReceiptExtension, readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; +import {isValidReceiptExtension, readFileAsync, resizeImageIfNeeded, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import getCurrentPosition from '@libs/getCurrentPosition'; import getPlatform from '@libs/getPlatform'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import HapticFeedback from '@libs/HapticFeedback'; -import {formatCurrentUserToAttendee, navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; +import {navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; @@ -65,8 +63,7 @@ import { updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import type {GpsPoint} from '@userActions/IOU'; -import {generateTransactionID} from '@userActions/Transaction'; -import {createDraftTransaction, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -359,25 +356,6 @@ function IOURequestStepScan({ [backToReport, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login, iouType, report, transactions], ); - const buildOptimisticTransaction = useCallback((): Transaction => { - const newTransactionID = generateTransactionID(); - const {currency, iouRequestType, isFromGlobalCreate, splitPayerAccountIDs} = initialTransaction ?? {}; - const newTransaction = { - amount: 0, - created: format(new Date(), 'yyyy-MM-dd'), - currency, - comment: {attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID)}, - iouRequestType, - reportID, - transactionID: newTransactionID, - isFromGlobalCreate, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - splitPayerAccountIDs, - } as Transaction; - createDraftTransaction(newTransaction); - return newTransaction; - }, [currentUserPersonalDetails, initialTransaction, reportID]); - const navigateToConfirmationStep = useCallback( (files: ReceiptFile[], locationPermissionGranted = false, isTestTransaction = false) => { if (backTo) { @@ -685,7 +663,14 @@ function IOURequestStepScan({ .then((photo: PhotoFile) => { // Store the receipt on the transaction object in Onyx const source = getPhotoSource(photo.path); - const transaction = isMultiScanEnabled && initialTransaction?.receipt?.source ? buildOptimisticTransaction() : initialTransaction; + const transaction = + isMultiScanEnabled && initialTransaction?.receipt?.source + ? buildOptimisticTransactionAndCreateDraft({ + initialTransaction, + currentUserPersonalDetails, + reportID, + }) + : initialTransaction; const transactionID = transaction?.transactionID ?? initialTransactionID; setMoneyRequestReceipt(transactionID, source, photo.path, !isEditing); @@ -733,7 +718,6 @@ function IOURequestStepScan({ isPlatformMuted, submitReceipts, receiptFiles, - buildOptimisticTransaction, initialTransaction, initialTransactionID, isEditing, diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 83b95aadf1d37..f0ec09067b308 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -1,5 +1,4 @@ import {useIsFocused} from '@react-navigation/native'; -import {format} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState} from 'react'; import {ActivityIndicator, InteractionManager, PanResponder, PixelRatio, StyleSheet, View} from 'react-native'; @@ -44,7 +43,7 @@ import {isMobile, isMobileWebKit} from '@libs/Browser'; import {base64ToFile, isLocalFile as isLocalFileFileUtils, resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; import getCurrentPosition from '@libs/getCurrentPosition'; -import {formatCurrentUserToAttendee, navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; +import {navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; @@ -70,8 +69,7 @@ import { trackExpense, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; -import {generateTransactionID} from '@userActions/Transaction'; -import {createDraftTransaction, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -366,25 +364,6 @@ function IOURequestStepScan({ [backToReport, iouType, reportID, initialTransactionID], ); - const buildOptimisticTransaction = useCallback((): Transaction => { - const newTransactionID = generateTransactionID(); - const {currency, iouRequestType, isFromGlobalCreate, splitPayerAccountIDs} = initialTransaction ?? {}; - const newTransaction = { - amount: 0, - created: format(new Date(), 'yyyy-MM-dd'), - currency, - comment: {attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID)}, - iouRequestType, - reportID, - transactionID: newTransactionID, - isFromGlobalCreate, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - splitPayerAccountIDs, - } as Transaction; - createDraftTransaction(newTransaction); - return newTransaction; - }, [currentUserPersonalDetails, initialTransaction, reportID]); - const createTransaction = useCallback( (files: ReceiptFile[], participant: Participant, gpsPoints?: GpsPoint, policyParams?: {policy: OnyxEntry}, billable?: boolean) => { files.forEach((receiptFile: ReceiptFile, index) => { @@ -733,7 +712,14 @@ function IOURequestStepScan({ const filename = `receipt_${Date.now()}.png`; const file = base64ToFile(imageBase64 ?? '', filename); const source = URL.createObjectURL(file); - const transaction = isMultiScanEnabled && initialTransaction?.receipt?.source ? buildOptimisticTransaction() : initialTransaction; + const transaction = + isMultiScanEnabled && initialTransaction?.receipt?.source + ? buildOptimisticTransactionAndCreateDraft({ + initialTransaction, + currentUserPersonalDetails, + reportID, + }) + : initialTransaction; const transactionID = transaction?.transactionID ?? initialTransactionID; const newReceiptFiles = [...receiptFiles, {file, source, transactionID}]; @@ -750,18 +736,7 @@ function IOURequestStepScan({ } submitReceipts(newReceiptFiles); - }, [ - receiptFiles, - showBlink, - buildOptimisticTransaction, - initialTransaction, - initialTransactionID, - isEditing, - isMultiScanEnabled, - submitReceipts, - requestCameraPermission, - updateScanAndNavigate, - ]); + }, [receiptFiles, showBlink, initialTransaction, initialTransactionID, isEditing, isMultiScanEnabled, submitReceipts, requestCameraPermission, updateScanAndNavigate]); const toggleMultiScan = () => { if (!dismissedProductTraining?.[CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]) { From 985687688fb66f066687adac44d6a5fad8ecb23e Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Fri, 20 Jun 2025 09:53:19 +0200 Subject: [PATCH 26/79] feat: support multiple files --- src/hooks/useFilesValidation.tsx | 27 +++++++-------------------- src/pages/Search/SearchPage.tsx | 23 +++++++++++++---------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index a11473d8e8ecf..e33200f97324b 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -19,7 +19,7 @@ type ErrorObject = { fileExtension?: string; }; -function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { +function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); @@ -60,7 +60,6 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { }); }, [resetValidationState]); - const setErrorAndOpenModal = (error: ValueOf) => { setFileError(error); setIsErrorModalVisible(true); @@ -111,14 +110,10 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setIsErrorModalVisible(true); } } else if (validFiles.current.length > 0) { - // No errors, proceed with valid files - const firstValidFile = validFiles.current.at(0); - if (firstValidFile) { - proceedWithFileAction(firstValidFile); - resetValidationState(); - } + proceedWithFilesAction(validFiles.current); + resetValidationState(); } - }, [pdfFilesToRender.length, proceedWithFileAction, resetValidationState]); + }, [pdfFilesToRender.length, proceedWithFilesAction, resetValidationState]); const validateAndResizeFiles = (files: FileObject[]) => { // Reset collected errors for new validation @@ -166,12 +161,8 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { setIsErrorModalVisible(true); } } else if (processedImages.length > 0) { - // No errors, proceed with valid files immediately - const firstValidFile = processedImages.at(0); - if (firstValidFile) { - proceedWithFileAction(firstValidFile); - resetValidationState(); - } + proceedWithFilesAction(processedImages); + resetValidationState(); } } }); @@ -207,11 +198,7 @@ function useFilesValidation(proceedWithFileAction: (file: FileObject) => void) { } } - // All errors have been shown, proceed with valid files - const firstValidFile = validFilesToUpload.at(0); - if (firstValidFile) { - proceedWithFileAction(firstValidFile); - } + proceedWithFilesAction(validFilesToUpload); hideModalAndReset(); }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 67ae59306ebd3..4b575389aeff1 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -378,17 +378,20 @@ function SearchPage({route}: SearchPageProps) { }); }; - const saveFileAndInitMoneyRequest = (file: FileObject) => { - const source = URL.createObjectURL(file as Blob); - const newReportID = generateReportID(); - initMoneyRequest({ - isFromGlobalCreate: true, - reportID: newReportID, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { + files.forEach((file) => { + const source = URL.createObjectURL(file as Blob); + const newReportID = generateReportID(); + console.log(file); + // initMoneyRequest({ + // isFromGlobalCreate: true, + // reportID: newReportID, + // newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + // }); + // // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + // setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, file.name || '', true); + // navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, file.name || '', true); - navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }; const {validateFiles, isLoadingReceipt, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); From aeba16825f6eb6dd77a2856b271be238aa427271 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 23 Jun 2025 14:05:42 +0200 Subject: [PATCH 27/79] feat: enable dropping multiple files on the Reports Page --- src/libs/actions/IOU.ts | 12 ++++--- src/pages/Search/SearchPage.tsx | 34 ++++++++++++------- .../request/step/IOURequestStepScan/index.tsx | 2 +- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3e3debb6be4e2..8086afdfbf34a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -931,9 +931,7 @@ function initMoneyRequest({reportID, policy, isFromGlobalCreate, currentIouReque } } - // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later - // Use set() here so that there is no way that data will be leaked between objects when it gets reset - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { + const newTransaction = { amount: 0, comment, created, @@ -945,7 +943,13 @@ function initMoneyRequest({reportID, policy, isFromGlobalCreate, currentIouReque isFromGlobalCreate, merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, splitPayerAccountIDs: currentUserPersonalDetails ? [currentUserPersonalDetails.accountID] : undefined, - }); + }; + + // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later + // Use set() here so that there is no way that data will be leaked between objects when it gets reset + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, newTransaction); + + return newTransaction; } function createDraftTransaction(transaction: OnyxTypes.Transaction) { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4b575389aeff1..e9409a9b843e8 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -17,6 +17,7 @@ import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/ import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {PaymentData, SearchParams} from '@components/Search/types'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -47,11 +48,12 @@ import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUti import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; import variables from '@styles/variables'; import {initMoneyRequest, setMoneyRequestReceipt} from '@userActions/IOU'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {SearchResults} from '@src/types/onyx'; +import type {SearchResults, Transaction} from '@src/types/onyx'; import SearchPageNarrow from './SearchPageNarrow'; type SearchPageProps = PlatformStackScreenProps; @@ -65,6 +67,7 @@ function SearchPage({route}: SearchPageProps) { const theme = useTheme(); const {isOffline} = useNetwork(); const {selectedTransactions, clearSelectedTransactions, selectedReports, lastSearchType, setLastSearchType, isExportMode, setExportMode} = useSearchContext(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE, {canBeMissing: true}); const [lastPaymentMethods = {}] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); @@ -379,19 +382,26 @@ function SearchPage({route}: SearchPageProps) { }; const saveFileAndInitMoneyRequest = (files: FileObject[]) => { - files.forEach((file) => { + const newReportID = generateReportID(); + const initialTransaction = initMoneyRequest({ + isFromGlobalCreate: true, + reportID: newReportID, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }); + + files.forEach((file, index) => { const source = URL.createObjectURL(file as Blob); - const newReportID = generateReportID(); - console.log(file); - // initMoneyRequest({ - // isFromGlobalCreate: true, - // reportID: newReportID, - // newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - // }); - // // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - // setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, file.name || '', true); - // navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); + const transaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID: newReportID, + }); + setMoneyRequestReceipt(transaction?.transactionID ?? '', source, file.name ?? '', true); }); + navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); }; const {validateFiles, isLoadingReceipt, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 338d66c0b8642..ad5ba5c0e5cc0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -734,7 +734,7 @@ function IOURequestStepScan({ } submitReceipts(newReceiptFiles); - }, [receiptFiles, showBlink, initialTransaction, initialTransactionID, isEditing, isMultiScanEnabled, submitReceipts, requestCameraPermission, updateScanAndNavigate]); + }, [isMultiScanEnabled, initialTransaction, currentUserPersonalDetails, reportID, initialTransactionID, receiptFiles, isEditing, submitReceipts, requestCameraPermission, showBlink, updateScanAndNavigate]); const toggleMultiScan = () => { if (!dismissedProductTraining?.[CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL]) { From 587b369b421c29d180b0c2afef39b0ffc1e626dd Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 23 Jun 2025 17:01:38 +0200 Subject: [PATCH 28/79] feat: enable multiple receipts in chats --- src/hooks/useFileValidation.ts | 66 -------------- .../ReportActionCompose.tsx | 88 ++++++++----------- 2 files changed, 37 insertions(+), 117 deletions(-) delete mode 100644 src/hooks/useFileValidation.ts diff --git a/src/hooks/useFileValidation.ts b/src/hooks/useFileValidation.ts deleted file mode 100644 index bbbe9504f9e43..0000000000000 --- a/src/hooks/useFileValidation.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {Str} from 'expensify-common'; -import {useState} from 'react'; -import type {ValueOf} from 'type-fest'; -import {resizeImageIfNeeded, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; -import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; -import CONST from '@src/CONST'; - -function useFileValidation() { - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [pdfFile, setPdfFile] = useState(null); - const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); - const [fileError, setFileError] = useState | null>(null); - - /** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - */ - const setUploadReceiptError = (error: ValueOf) => { - setIsAttachmentInvalid(true); - setFileError(error); - setPdfFile(null); - }; - - const validateAndResizeFile = (originalFile: FileObject, setReceiptAndNavigate: (file: FileObject) => void, isPdfValidated?: boolean, isCheckingMultipleFiles?: boolean) => { - validateImageForCorruption(originalFile) - .then(() => { - const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); - if (error) { - setIsAttachmentInvalid(true); - setFileError(error); - return false; - } - // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { - setPdfFile(originalFile); - return; - } - - // With the image size > 24MB, we use manipulateAsync to resize the image. - // It takes a long time so we should display a loading indicator while the resize image progresses. - if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsLoadingReceipt(true); - } - resizeImageIfNeeded(originalFile).then((resizedFile) => { - setIsLoadingReceipt(false); - setReceiptAndNavigate(resizedFile); - }); - }) - .catch(() => { - setFileError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED); - return false; - }); - }; - - return { - validateAndResizeFile, - isAttachmentInvalid, - setIsAttachmentInvalid, - pdfFile, - setPdfFile, - setUploadReceiptError, - isLoadingReceipt, - fileError, - }; -} - -export default useFileValidation; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index d36f752c407a8..35d3b73060ad9 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -9,7 +9,6 @@ import {runOnUI, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import AttachmentModal from '@components/AttachmentModal'; -import ConfirmModal from '@components/ConfirmModal'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DropZoneUI from '@components/DropZone/DropZoneUI'; import DualDropZone from '@components/DropZone/DualDropZone'; @@ -21,10 +20,9 @@ import type {Mention} from '@components/MentionSuggestions'; import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxProvider'; -import PDFThumbnail from '@components/PDFThumbnail'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; -import useFileValidation from '@hooks/useFileValidation'; +import useFilesValidation from '@hooks/useFilesValidation'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useLocalize from '@hooks/useLocalize'; @@ -36,7 +34,6 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {getDraftComment} from '@libs/DraftCommentUtils'; -import {getFileValidationErrorText} from '@libs/fileDownload/FileUtils'; import getModalState from '@libs/getModalState'; import Performance from '@libs/Performance'; import { @@ -65,6 +62,7 @@ import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActio import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; import {addAttachment as addAttachmentReportActions, setIsComposerFullSize} from '@userActions/Report'; import Timing from '@userActions/Timing'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -150,8 +148,6 @@ function ReportActionCompose({ // TODO: remove beta check after the feature is enabled const {isBetaEnabled} = usePermissions(); - const {validateAndResizeFile, setIsAttachmentInvalid, isAttachmentInvalid, setUploadReceiptError, pdfFile, setPdfFile, fileError} = useFileValidation(); - /** * Updates the Highlight state of the composer */ @@ -394,6 +390,7 @@ function ReportActionCompose({ const composerRefShared = useSharedValue<{ clear: (() => void) | undefined; }>({clear: undefined}); + const handleSendMessage = useCallback(() => { 'worklet'; @@ -476,51 +473,48 @@ function ReportActionCompose({ [isComposerFullSize, reportID, debouncedValidate], ); - // TODO: to be refactored in step 3 - const hideReceiptModal = () => { - setIsAttachmentInvalid(false); - }; - - const saveFileAndInitMoneyRequest = (file: FileObject) => { - const source = URL.createObjectURL(file as Blob); - + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { if (isTransactionThreadView && transactionID) { - replaceReceipt({transactionID, file: file as File, source}); + const source = URL.createObjectURL(files.at(0) as Blob); + replaceReceipt({transactionID, file: files.at(0) as File, source}); } else { - initMoneyRequest({reportID, newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN}); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, file.name || '', true); - setMoneyRequestParticipantsFromReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report).then(() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); + const initialTransaction = initMoneyRequest({ + reportID, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }); + + files.forEach((file, index) => { + const source = URL.createObjectURL(file as Blob); + const newTransaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + const newTransactionID = newTransaction?.transactionID ?? ''; + setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true); + setMoneyRequestParticipantsFromReport(newTransactionID, report); }); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); } }; - const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateAndResizeFile(originalFile, saveFileAndInitMoneyRequest, isPdfValidated); - }; + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); const handleAddingReceipt = (e: DragEvent) => { - const file = e?.dataTransfer?.files[0]; - if (file) { - file.uri = URL.createObjectURL(file); - setReceiptAndNavigate(file); + const files = Array.from(e?.dataTransfer?.files ?? []); + if (files.length === 0) { + return; } - }; + files.forEach((file) => { + // eslint-disable-next-line no-param-reassign + file.uri = URL.createObjectURL(file); + }); - // TODO: to be refactored in step 3 - const PDFThumbnailView = pdfFile ? ( - { - setPdfFile(null); - setReceiptAndNavigate(pdfFile, true); - }} - onPassword={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE)} - onLoadError={() => setUploadReceiptError(CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED)} - /> - ) : null; + validateFiles(files); + }; return ( @@ -547,7 +541,7 @@ function ReportActionCompose({ !!exceededMaxLength && styles.borderColorDanger, ]} > - {PDFThumbnailView} + {PDFValidationComponent} - + {ErrorModal} Date: Mon, 23 Jun 2025 17:09:05 +0200 Subject: [PATCH 29/79] fix: minor fix --- .../ReportActionCompose/ReportActionCompose.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 35d3b73060ad9..239247bc8f3f7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -232,6 +232,8 @@ function ReportActionCompose({ const hasReceipt = useMemo(() => hasReceiptTransactionUtils(transaction), [transaction]); + const isEditingReceipt = isTransactionThreadView && transactionID && hasReceipt; + // Placeholder to display in the chat input. const inputPlaceholder = useMemo(() => { if (includesConcierge && userBlockedFromConcierge) { @@ -474,7 +476,7 @@ function ReportActionCompose({ ); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { - if (isTransactionThreadView && transactionID) { + if (isEditingReceipt) { const source = URL.createObjectURL(files.at(0) as Blob); replaceReceipt({transactionID, file: files.at(0) as File, source}); } else { @@ -504,6 +506,15 @@ function ReportActionCompose({ const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); const handleAddingReceipt = (e: DragEvent) => { + if (isEditingReceipt) { + const file = e?.dataTransfer?.files?.[0]; + if (file) { + file.uri = URL.createObjectURL(file); + validateFiles([file]); + return; + } + } + const files = Array.from(e?.dataTransfer?.files ?? []); if (files.length === 0) { return; From 06e3b1437082bd91cae61bb926d078e4febfd32e Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 23 Jun 2025 17:50:31 +0200 Subject: [PATCH 30/79] fix: minor fix --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index fa382dcbf662e..37b81aac7392e 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -36,7 +36,7 @@ import {rand64} from '@libs/NumberUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {generateReportID, getBankAccountRoute, getReportOrDraftReport, isProcessingReport, isReportOutstanding, isSelectedManagerMcTest} from '@libs/ReportUtils'; -import {getDefaultTaxCode, getRateID, getRequestType, getValidWaypoints, isScanRequest} from '@libs/TransactionUtils'; +import {getDefaultTaxCode, getRateID, getRequestType, getValidWaypoints, hasReceipt, isScanRequest} from '@libs/TransactionUtils'; import ReceiptDropUI from '@pages/iou/ReceiptDropUI'; import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; import type {GpsPoint} from '@userActions/IOU'; @@ -160,6 +160,7 @@ function IOURequestStepConfirmation({ const receiptFilename = transaction?.filename; const receiptPath = transaction?.receipt?.source; + const isEditingReceipt = hasReceipt(transaction); const customUnitRateID = getRateID(transaction) ?? ''; const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = (transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? ''; @@ -1092,9 +1093,9 @@ function IOURequestStepConfirmation({ }} > From 31fd6eeaef1c4a6435f332f871500ec875569ca4 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Mon, 23 Jun 2025 18:10:28 +0200 Subject: [PATCH 31/79] feat: use new hook in Confirmation step --- .../step/IOURequestStepConfirmation.tsx | 152 +++++------------- 1 file changed, 36 insertions(+), 116 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 37b81aac7392e..c25aa685a8717 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,8 +1,6 @@ -import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import ConfirmModal from '@components/ConfirmModal'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; @@ -12,12 +10,12 @@ import * as Expensicons from '@components/Icon/Expensicons'; import LocationPermissionModal from '@components/LocationPermissionModal'; import MoneyRequestConfirmationList from '@components/MoneyRequestConfirmationList'; import {usePersonalDetails} from '@components/OnyxProvider'; -import PDFThumbnail from '@components/PDFThumbnail'; import PrevNextButtons from '@components/PrevNextButtons'; import ScreenWrapper from '@components/ScreenWrapper'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useFetchRoute from '@hooks/useFetchRoute'; +import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; @@ -26,7 +24,7 @@ import useThreeDotsAnchorPosition from '@hooks/useThreeDotsAnchorPosition'; import {completeTestDriveTask} from '@libs/actions/Task'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {isLocalFile as isLocalFileFileUtils, resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; @@ -62,7 +60,6 @@ import { import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; import {removeDraftTransactions} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -115,7 +112,12 @@ function IOURequestStepConfirmation({ () => (!isLoadingCurrentTransaction ? (optimisticTransaction ?? existingTransaction) : undefined), [existingTransaction, optimisticTransaction, isLoadingCurrentTransaction], ); - const transactionsCategories = useDeepCompareRef(transactions.map(({transactionID, category}) => ({transactionID, category}))); + const transactionsCategories = useDeepCompareRef( + transactions.map(({transactionID, category}) => ({ + transactionID, + category, + })), + ); const realPolicyID = getIOURequestPolicyID(initialTransaction, reportReal); const draftPolicyID = getIOURequestPolicyID(initialTransaction, reportDraft); @@ -146,12 +148,6 @@ function IOURequestStepConfirmation({ const [selectedParticipantList, setSelectedParticipantList] = useState([]); const [isDraggingOver, setIsDraggingOver] = useState(false); - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(); - const [attachmentInvalidReason, setAttachmentValidReason] = useState(); - const [pdfFile, setPdfFile] = useState(null); - const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); - const [receiptFiles, setReceiptFiles] = useState>({}); const requestType = getRequestType(transaction); const isDistanceRequest = requestType === CONST.IOU.REQUEST_TYPE.DISTANCE; @@ -222,21 +218,12 @@ function IOURequestStepConfirmation({ const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); const formHasBeenSubmitted = useRef(false); - const confirmModalPrompt = useMemo(() => { - if (!attachmentInvalidReason) { - return ''; - } - if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { - return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); - } - return translate(attachmentInvalidReason); - }, [attachmentInvalidReason, translate]); - useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); useEffect(() => { Performance.markEnd(CONST.TIMING.OPEN_CREATE_EXPENSE_APPROVE); }, []); + useEffect(() => { const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat); if (policyExpenseChat?.policyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { @@ -311,51 +298,6 @@ function IOURequestStepConfirmation({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [transactionIDs, requestType, defaultCategory, policy?.id]); - /** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - */ - const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => { - setIsAttachmentInvalid(isInvalid); - setAttachmentInvalidReasonTitle(title); - setAttachmentValidReason(reason); - setPdfFile(null); - }; - - const hideReceiptModal = () => { - setIsAttachmentInvalid(false); - }; - - /** - * Sets the Receipt object when dragging and dropping a file - */ - const setReceiptOnDrop = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => { - if (!isFileValid) { - return; - } - - // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { - setPdfFile(originalFile); - setIsLoadingReceipt(true); - return; - } - - // With the image size > 24MB, we use manipulateAsync to resize the image. - // It takes a long time so we should display a loading indicator while the resize image progresses. - if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsLoadingReceipt(true); - } - resizeImageIfNeeded(originalFile).then((file) => { - setIsLoadingReceipt(false); - // Store the receipt on the transaction object in Onyx - const source = URL.createObjectURL(file as Blob); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - setMoneyRequestReceipt(currentTransactionID, source, file.name || '', true); - }); - }); - }; - const navigateBack = useCallback(() => { // If the action is categorize and there's no policies other than personal one, we simply call goBack(), i.e: dismiss the whole flow together // We don't need to subscribe to policy_ collection as we only need to check on the latest collection value @@ -1000,30 +942,32 @@ function IOURequestStepConfirmation({ setIsConfirming(false); }; - if (isLoadingTransaction) { + /** + * Sets the Receipt object when dragging and dropping a file + */ + const setReceiptOnDrop = (files: FileObject[]) => { + const file = files.at(0); + if (!file) { + return; + } + const source = URL.createObjectURL(file as Blob); + setMoneyRequestReceipt(currentTransactionID, source, file.name ?? '', true); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal, isLoadingReceipt} = useFilesValidation(setReceiptOnDrop); + + const handleDroppingReceipt = (e: DragEvent) => { + const file = e?.dataTransfer?.files[0]; + if (file) { + file.uri = URL.createObjectURL(file); + validateFiles([file]); + } + }; + + if (isLoadingTransaction ?? isLoadingReceipt) { return ; } - const PDFThumbnailView = pdfFile ? ( - { - setPdfFile(null); - setIsLoadingReceipt(false); - setReceiptOnDrop(pdfFile, true); - }} - onPassword={() => { - setIsLoadingReceipt(false); - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setIsLoadingReceipt(false); - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} - /> - ) : null; - const showNextTransaction = () => { const nextTransaction = transactions.at(currentTransactionIndex + 1); if (nextTransaction) { @@ -1080,18 +1024,10 @@ function IOURequestStepConfirmation({ ) : null} {(isLoading || isLoadingReceipt || (isScanRequest(transaction) && !Object.values(receiptFiles).length)) && } - {PDFThumbnailView} + {PDFValidationComponent} {/* TODO: remove beta check after the feature is enabled */} {isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP) ? ( - { - const file = e?.dataTransfer?.files[0]; - if (file) { - file.uri = URL.createObjectURL(file); - setReceiptOnDrop(file); - } - }} - > + ) : ( - { - const file = e?.dataTransfer?.files[0]; - if (file) { - file.uri = URL.createObjectURL(file); - setReceiptOnDrop(file); - } - }} - /> + )} - + {ErrorModal} {!!gpsRequired && ( Date: Tue, 24 Jun 2025 19:02:29 +0200 Subject: [PATCH 32/79] feat: useFilesValidation --- .../request/step/IOURequestStepScan/index.tsx | 104 ++++++------------ 1 file changed, 32 insertions(+), 72 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index ad5ba5c0e5cc0..b8ea6dfa04abc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -30,6 +30,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; @@ -100,11 +101,6 @@ function IOURequestStepScan({ const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); - // Grouping related states - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(); - const [attachmentInvalidReason, setAttachmentValidReason] = useState(); - const [pdfFile, setPdfFile] = useState(null); const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); const [receiptFiles, setReceiptFiles] = useState([]); const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); @@ -319,20 +315,6 @@ function IOURequestStepScan({ setReceiptFiles([]); }, [isMultiScanEnabled]); - const hideReceiptModal = () => { - setIsAttachmentInvalid(false); - }; - - /** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - */ - const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => { - setIsAttachmentInvalid(isInvalid); - setAttachmentInvalidReasonTitle(title); - setAttachmentValidReason(reason); - setPdfFile(null); - }; - const navigateBack = useCallback(() => { Navigation.goBack(backTo); }, [backTo]); @@ -584,14 +566,13 @@ function IOURequestStepScan({ * Sets the Receipt objects and navigates the user to the next page */ const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => { + validateReceipt(originalFile, () => {}).then((isFileValid) => { if (!isFileValid) { return; } // If we have a pdf file and if it is not validated then set the pdf file for validation and return if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { - setPdfFile(originalFile); return; } @@ -652,6 +633,25 @@ function IOURequestStepScan({ }); }; + const setReceiptFromFile = (files: FileObject[]) => { + console.log({files}); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(setReceiptFromFile); + + const handleDropReceipt = (e: DragEvent) => { + const files = Array.from(e?.dataTransfer?.files ?? []); + if (files.length === 0) { + return; + } + files.forEach((file) => { + // eslint-disable-next-line no-param-reassign + file.uri = URL.createObjectURL(file); + }); + + validateFiles(files); + }; + /** * Sets a test receipt from CONST.TEST_RECEIPT_URL and navigates to the confirmation step */ @@ -790,33 +790,6 @@ function IOURequestStepScan({ [], ); - const PDFThumbnailView = pdfFile ? ( - { - setPdfFile(null); - setReceiptAndNavigate(pdfFile, true); - }} - onPassword={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} - /> - ) : null; - - const getConfirmModalPrompt = () => { - if (!attachmentInvalidReason) { - return ''; - } - if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { - return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); - } - return translate(attachmentInvalidReason); - }; - const dismissMultiScanEducationalPopup = () => { InteractionManager.runAfterInteractions(() => { dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL); @@ -827,7 +800,7 @@ function IOURequestStepScan({ const mobileCameraView = () => ( <> - {PDFThumbnailView} + {PDFValidationComponent} {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( - + {({openPicker}) => ( { openPicker({ - onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}), + onPicked: (data) => setReceiptFromFile(data), }); }} > @@ -999,7 +975,7 @@ function IOURequestStepScan({ const desktopUploadView = () => ( <> - {PDFThumbnailView} + {PDFValidationComponent} setReceiptImageTopPosition(PixelRatio.roundToNearestPixel((nativeEvent.layout as DOMRect).top))}> - + {({openPicker}) => (