diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d7d495f20f056..7929d2260c86c 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -253,6 +253,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, }, // Allowed extensions for spreadsheets import @@ -1879,6 +1881,19 @@ const CONST = { // Video MimeTypes allowed by iOS photos app. VIDEO: /\.(mov|mp4)$/, }, + + 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', + MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded', + PROTECTED_FILE: 'protectedFile', + }, + 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/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index edcd5e698d67a..0560e14ec9c7a 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -153,45 +153,68 @@ function AttachmentPicker({ return reject(new Error(`Error during attachment selection: ${response.errorMessage}`)); } - 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); - } + 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((error: Error) => { + showGeneralAlert(error.message ?? 'An unknown error occurred'); + checkAllProcessed(); + }); + } else { + processedAssets.push(asset); + checkAllProcessed(); + } + }) + .catch((error: Error) => { + showGeneralAlert(error.message ?? 'An unknown error occurred'); + checkAllProcessed(); + }); + } else { + processedAssets.push(asset); + checkAllProcessed(); + } + }); }); }), [fileLimit, showGeneralAlert, type], @@ -287,42 +310,20 @@ function AttachmentPicker({ setIsVisible(false); }; - const validateAndCompleteAttachmentSelection = useCallback( - (fileData: FileResponse) => { - // Check if the file dimensions indicate corruption - // The width/height for a corrupted file is -1 on android native and 0 on ios native - // We must check only numeric values because the width/height can be undefined for non-image files - if ((typeof fileData.width === 'number' && fileData.width <= 0) || (typeof fileData.height === 'number' && fileData.height <= 0)) { - showImageCorruptionAlert(); - return Promise.resolve(); - } - return getDataForUpload(fileData) - .then((result) => { - completeAttachmentSelection.current([result]); - }) - .catch((error: Error) => { - showGeneralAlert(error.message); - throw error; - }); - }, - [showGeneralAlert, showImageCorruptionAlert], - ); - /** * Handles the image/document picker result and * 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 +346,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 +361,41 @@ function AttachmentPicker({ if (fileDataObject.width <= 0 || fileDataObject.height <= 0) { showImageCorruptionAlert(); - return Promise.resolve(); // Skip processing this corrupted file + return null; } - return validateAndCompleteAttachmentSelection(fileDataObject); + return getDataForUpload(fileDataObject); }) .catch(() => { showImageCorruptionAlert(); + return null; }); } - return validateAndCompleteAttachmentSelection(fileDataObject); + + 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/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx index 1b11bdcf047ef..6b11c82e60b34 100644 --- a/src/components/AttachmentPicker/index.tsx +++ b/src/components/AttachmentPicker/index.tsx @@ -60,9 +60,15 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a return; } - const file = e.target.files[0]; - - if (file) { + if (allowMultiple && e.target.files.length > 1) { + const files = Array.from(e.target.files).map((currentFile) => { + // eslint-disable-next-line no-param-reassign + currentFile.uri = URL.createObjectURL(currentFile); + return currentFile as FileObject; + }); + onPicked.current(files); + } else if (e.target.files[0]) { + const file = e.target.files[0]; file.uri = URL.createObjectURL(file); onPicked.current([file]); } diff --git a/src/hooks/useFileValidation.ts b/src/hooks/useFileValidation.ts deleted file mode 100644 index 88621ef8dbb76..0000000000000 --- a/src/hooks/useFileValidation.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {Str} from 'expensify-common'; -import {useState} from 'react'; -import {resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; -import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; -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); - - /** - * 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 validateAndResizeFile = (originalFile: FileObject, setReceiptAndNavigate: (file: FileObject) => void, 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); - 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); - }); - }); - }; - - return { - validateAndResizeFile, - isAttachmentInvalid, - setIsAttachmentInvalid, - attachmentInvalidReason, - attachmentInvalidReasonTitle, - setUploadReceiptError, - pdfFile, - setPdfFile, - isLoadingReceipt, - }; -} - -export default useFileValidation; diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx new file mode 100644 index 0000000000000..a8f7d335559cd --- /dev/null +++ b/src/hooks/useFilesValidation.tsx @@ -0,0 +1,298 @@ +import {Str} from 'expensify-common'; +import React, {useCallback, 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 {useFullScreenLoader} from '@components/FullScreenLoaderContext'; +import PDFThumbnail from '@components/PDFThumbnail'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import {getFileValidationErrorText, isHeicOrHeifImage, resizeImageIfNeeded, splitExtensionFromFileName, validateAttachment, validateImageForCorruption} from '@libs/fileDownload/FileUtils'; +import convertHeicImage from '@libs/fileDownload/heicConverter'; +import CONST from '@src/CONST'; +import useLocalize from './useLocalize'; +import useThemeStyles from './useThemeStyles'; + +type ErrorObject = { + error: ValueOf; + fileExtension?: string; +}; + +function useFilesValidation(proceedWithFilesAction: (files: FileObject[]) => void) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [fileError, setFileError] = useState | null>(null); + const [pdfFilesToRender, setPdfFilesToRender] = useState([]); + 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 {setIsLoaderVisible} = useFullScreenLoader(); + + const validatedPDFs = useRef([]); + const validFiles = useRef([]); + const filesToValidate = useRef([]); + const collectedErrors = useRef([]); + + const resetValidationState = useCallback(() => { + setIsErrorModalVisible(false); + setPdfFilesToRender([]); + setIsLoaderVisible(false); + setIsValidatingMultipleFiles(false); + setFileError(null); + setValidFilesToUpload([]); + setInvalidFileExtension(''); + setErrorQueue([]); + setCurrentErrorIndex(0); + validatedPDFs.current = []; + validFiles.current = []; + filesToValidate.current = []; + collectedErrors.current = []; + }, [setIsLoaderVisible]); + + const hideModalAndReset = useCallback(() => { + setIsErrorModalVisible(false); + InteractionManager.runAfterInteractions(() => { + resetValidationState(); + }); + }, [resetValidationState]); + + const setErrorAndOpenModal = (error: ValueOf) => { + setFileError(error); + setIsErrorModalVisible(true); + }; + + const isValidFile = (originalFile: FileObject, isCheckingMultipleFiles?: boolean) => { + return validateImageForCorruption(originalFile) + .then(() => { + const error = validateAttachment(originalFile, isCheckingMultipleFiles, true); + if (error) { + 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(() => { + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + return false; + }); + }; + + const convertHeicImageToJpegPromise = (file: FileObject): Promise => { + return new Promise((resolve, reject) => { + convertHeicImage(file, { + onSuccess: (convertedFile) => resolve(convertedFile), + onError: (nonConvertedFile) => { + reject(nonConvertedFile); + }, + }); + }); + }; + + const checkIfAllValidatedAndProceed = useCallback(() => { + if (!validatedPDFs.current || !validFiles.current) { + return; + } + + if (validatedPDFs.current.length !== pdfFilesToRender.length) { + return; + } + + if (validFiles.current.length > 0) { + setValidFilesToUpload(validFiles.current); + } + + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); + } + } else if (validFiles.current.length > 0) { + proceedWithFilesAction(validFiles.current); + resetValidationState(); + } + }, [pdfFilesToRender.length, proceedWithFilesAction, 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); + const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); + const otherFiles = filteredResults.filter((file) => !Str.isPDF(file.name ?? '')); + + // Check if we need to convert images + if (otherFiles.some((file) => isHeicOrHeifImage(file))) { + setIsLoaderVisible(true); + + return Promise.all(otherFiles.map((file) => convertHeicImageToJpegPromise(file))).then((convertedImages) => { + // Check if we need to resize images + if (convertedImages.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + return Promise.all(convertedImages.map((file) => resizeImageIfNeeded(file))).then((processedFiles) => { + setIsLoaderVisible(false); + return Promise.resolve({processedFiles, pdfsToLoad}); + }); + } + + // No resizing needed, just return the converted images + setIsLoaderVisible(false); + return Promise.resolve({processedFiles: convertedImages, pdfsToLoad}); + }); + } + + // No conversion needed, but check if we need to resize images + if (otherFiles.some((file) => (file.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE)) { + setIsLoaderVisible(true); + return Promise.all(otherFiles.map((file) => resizeImageIfNeeded(file))).then((processedFiles) => { + setIsLoaderVisible(false); + return Promise.resolve({processedFiles, pdfsToLoad}); + }); + } + + // No conversion or resizing needed, just return the valid images + return Promise.resolve({processedFiles: otherFiles, pdfsToLoad}); + }) + .then(({processedFiles, pdfsToLoad}) => { + if (pdfsToLoad.length) { + validFiles.current = processedFiles; + setPdfFilesToRender(pdfsToLoad); + } else { + if (processedFiles.length > 0) { + setValidFilesToUpload(processedFiles); + } + + if (collectedErrors.current.length > 0) { + const uniqueErrors = Array.from(new Set(collectedErrors.current.map((error) => JSON.stringify(error)))).map((errorStr) => JSON.parse(errorStr) as ErrorObject); + setErrorQueue(uniqueErrors); + setCurrentErrorIndex(0); + const firstError = uniqueErrors.at(0); + if (firstError) { + setFileError(firstError.error); + if (firstError.fileExtension) { + setInvalidFileExtension(firstError.fileExtension); + } + setIsErrorModalVisible(true); + } + } else if (processedFiles.length > 0) { + proceedWithFilesAction(processedFiles); + resetValidationState(); + } + } + }); + }; + + 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); + setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); + } else { + validateAndResizeFiles(files); + } + }; + + const onConfirm = () => { + if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { + setIsErrorModalVisible(false); + validateAndResizeFiles(filesToValidate.current); + return; + } + + if (currentErrorIndex < errorQueue.length - 1) { + const nextIndex = currentErrorIndex + 1; + const nextError = errorQueue.at(nextIndex); + if (nextError) { + if (validFiles.current.length === 0) { + setIsValidatingMultipleFiles(false); + } + setCurrentErrorIndex(nextIndex); + setFileError(nextError.error); + setInvalidFileExtension(nextError.fileExtension ?? ''); + return; + } + } + + proceedWithFilesAction(validFilesToUpload); + hideModalAndReset(); + }; + + const PDFValidationComponent = pdfFilesToRender.length + ? pdfFilesToRender.map((file) => ( + { + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + validFiles.current = [...(validFiles.current ?? []), file]; + checkIfAllValidatedAndProceed(); + }} + onPassword={() => { + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); + checkIfAllValidatedAndProceed(); + }} + onLoadError={() => { + validatedPDFs.current = [...(validatedPDFs.current ?? []), file]; + collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED}); + checkIfAllValidatedAndProceed(); + }} + /> + )) + : undefined; + + const getModalPrompt = useCallback(() => { + 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; + }, [fileError, invalidFileExtension, translate]); + + const ErrorModal = ( + + ); + + return { + PDFValidationComponent, + validateFiles, + ErrorModal, + }; +} + +export default useFilesValidation; diff --git a/src/languages/de.ts b/src/languages/de.ts index 279ff2efdf3ed..19213fa7f4fda 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'Lange ID', bankAccounts: 'Bankkonten', chooseFile: 'Datei auswählen', + chooseFiles: 'Dateien auswählen', dropTitle: 'Lass es los', dropMessage: 'Datei hier ablegen', ignore: 'Ignore', @@ -666,6 +668,12 @@ const translations = { attachmentImageTooLarge: 'Dieses Bild ist zu groß, um es vor dem Hochladen in der Vorschau anzuzeigen.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Sie können jeweils nur bis zu ${fileLimit} Dateien hochladen.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Dateien überschreiten ${maxUploadSizeInMB} MB. Bitte versuchen Sie es erneut.`, + someFilesCantBeUploaded: 'Einige Dateien können nicht hochgeladen werden', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `Dateien müssen unter ${maxUploadSizeInMB} MB sein. Größere Dateien werden nicht hochgeladen.`, + maxFileLimitExceeded: 'Sie können bis zu 30 Belege gleichzeitig hochladen. Weitere werden nicht hochgeladen.', + unsupportedFileType: ({fileType}: FileTypeParams) => `${fileType} Dateien werden nicht unterstützt. Nur unterstützte Dateitypen werden hochgeladen.`, + learnMoreAboutSupportedFiles: 'Erfahren Sie mehr über unterstützte Formate.', + passwordProtected: 'Passwortgeschützte PDFs werden nicht unterstützt. Nur unterstützte Dateien werden hochgeladen.', }, dropzone: { addAttachments: 'Anhänge hinzufügen', @@ -955,9 +963,13 @@ const translations = { }, receipt: { upload: 'Beleg hochladen', + uploadMultiple: 'Belege hochladen', dragReceiptBeforeEmail: 'Ziehen Sie eine Quittung auf diese Seite oder leiten Sie eine Quittung weiter an', + dragReceiptsBeforeEmail: 'Ziehen Sie Quittungen auf diese Seite oder leiten Sie Quittungen weiter an', dragReceiptAfterEmail: 'oder wählen Sie unten eine Datei zum Hochladen aus.', + dragReceiptsAfterEmail: 'oder wählen Sie unten Dateien zum Hochladen aus.', chooseReceipt: 'Wählen Sie eine Quittung zum Hochladen aus oder leiten Sie eine Quittung weiter an', + chooseReceipts: 'Wählen Sie Quittungen zum Hochladen aus oder leiten Sie Quittungen weiter an ', takePhoto: 'Ein Foto machen', cameraAccess: 'Der Kamerazugriff ist erforderlich, um Fotos von Belegen zu machen.', deniedCameraAccess: 'Kamerazugriff wurde noch nicht gewährt, bitte folgen Sie', diff --git a/src/languages/en.ts b/src/languages/en.ts index e2e46cdbb5c0d..f172671260dcb 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -87,6 +87,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -553,6 +554,7 @@ const translations = { longID: 'Long ID', bankAccounts: 'Bank accounts', chooseFile: 'Choose file', + chooseFiles: 'Choose files', dropTitle: 'Let it go', dropMessage: 'Drop your file here', ignore: 'Ignore', @@ -656,6 +658,12 @@ 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: ({maxUploadSizeInMB}: SizeExceededParams) => `Files must be under ${maxUploadSizeInMB} 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: ({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: { addAttachments: 'Add attachments', @@ -944,9 +952,13 @@ const translations = { }, receipt: { upload: 'Upload receipt', + uploadMultiple: 'Upload receipts', dragReceiptBeforeEmail: 'Drag a receipt onto this page, forward a receipt to ', + dragReceiptsBeforeEmail: 'Drag receipts onto this page, forward receipts to ', dragReceiptAfterEmail: ' or choose a file to upload below.', + dragReceiptsAfterEmail: ' or choose files to upload below.', chooseReceipt: 'Choose a receipt to upload or forward a receipt to ', + chooseReceipts: 'Choose receipts to upload or forward receipts to ', takePhoto: 'Take a photo', cameraAccess: 'Camera access is required to take pictures of receipts.', deniedCameraAccess: "Camera access still hasn't been granted, please follow ", diff --git a/src/languages/es.ts b/src/languages/es.ts index 1debf565f16b3..027a9cc004f47 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -86,6 +86,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -546,6 +547,7 @@ const translations = { longID: 'ID largo', bankAccounts: 'Cuentas bancarias', chooseFile: 'Elegir archivo', + chooseFiles: 'Elegir archivos', dropTitle: 'Suéltalo', dropMessage: 'Suelta tu archivo aquí', enabled: 'Habilitado', @@ -651,6 +653,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: ({maxUploadSizeInMB}: SizeExceededParams) => `Los archivos deben ser menores a ${maxUploadSizeInMB} 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.`, + 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: { addAttachments: 'Añadir archivos adjuntos', @@ -939,9 +947,13 @@ const translations = { }, receipt: { upload: 'Subir recibo', + uploadMultiple: 'Subir recibos', dragReceiptBeforeEmail: 'Arrastra un recibo a esta página, reenvíalo a ', + dragReceiptsBeforeEmail: 'Arrastra recibos a esta página, reenvíalos a ', dragReceiptAfterEmail: ' o elije un archivo para subir a continuación.', + dragReceiptsAfterEmail: ' o elije archivos para subir a continuación.', chooseReceipt: 'Elige un recibo para subir o reenvía un recibo a ', + chooseReceipts: 'Elige recibos para subir o reenvía recibos a ', takePhoto: 'Haz una foto', cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.', deniedCameraAccess: 'No se ha concedido el acceso a la cámara, siga ', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 67384733c9c5e..371a593c41332 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'ID long', bankAccounts: 'Comptes bancaires', chooseFile: 'Choisir un fichier', + chooseFiles: 'Choisir des fichiers', dropTitle: 'Laisse tomber', dropMessage: 'Déposez votre fichier ici', ignore: 'Ignore', @@ -666,6 +668,13 @@ const translations = { attachmentImageTooLarge: 'Cette image est trop grande pour être prévisualisée avant le téléchargement.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Vous pouvez télécharger jusqu'à ${fileLimit} fichiers à la fois.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Les fichiers dépassent ${maxUploadSizeInMB} MB. Veuillez réessayer.`, + someFilesCantBeUploaded: 'Certains fichiers ne peuvent pas être téléchargés', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => + `Les fichiers doivent faire moins de ${maxUploadSizeInMB} MB. Les fichiers plus volumineux ne seront pas téléchargés.`, + maxFileLimitExceeded: "Vous pouvez télécharger jusqu'à 30 reçus à la fois. Les fichiers supplémentaires ne seront pas téléchargés.", + unsupportedFileType: ({fileType}: FileTypeParams) => `Les fichiers ${fileType} ne sont pas pris en charge. Seuls les types de fichiers pris en charge seront téléchargés.`, + learnMoreAboutSupportedFiles: 'En savoir plus sur les formats pris en charge.', + passwordProtected: 'Les PDF protégés par mot de passe ne sont pas pris en charge. Seuls les fichiers pris en charge seront téléchargés.', }, dropzone: { addAttachments: 'Ajouter des pièces jointes', @@ -955,9 +964,13 @@ const translations = { }, receipt: { upload: 'Télécharger le reçu', + uploadMultiple: 'Télécharger des reçus', dragReceiptBeforeEmail: 'Faites glisser un reçu sur cette page, transférez un reçu à', + dragReceiptsBeforeEmail: 'Faites glisser des reçus sur cette page, transférez des reçus à', dragReceiptAfterEmail: 'ou choisissez un fichier à télécharger ci-dessous.', + dragReceiptsAfterEmail: 'ou choisissez des fichiers à télécharger ci-dessous.', chooseReceipt: 'Choisissez un reçu à télécharger ou transférez un reçu à', + chooseReceipts: 'Choisissez des reçus à télécharger ou transférez des reçus à', takePhoto: 'Prendre une photo', cameraAccess: "L'accès à la caméra est requis pour prendre des photos des reçus.", deniedCameraAccess: "L'accès à la caméra n'a toujours pas été accordé, veuillez suivre", diff --git a/src/languages/it.ts b/src/languages/it.ts index 82906b432e45f..b573c0147e492 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'ID lungo', bankAccounts: 'Conti bancari', chooseFile: 'Scegli file', + chooseFiles: 'Scegli file', dropTitle: 'Lascia andare', dropMessage: 'Trascina qui il tuo file', ignore: 'Ignore', @@ -666,6 +668,12 @@ const translations = { attachmentImageTooLarge: 'Questa immagine è troppo grande per essere visualizzata in anteprima prima del caricamento.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Puoi caricare solo fino a ${fileLimit} file alla volta.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `I file superano ${maxUploadSizeInMB} MB. Per favore riprova.`, + someFilesCantBeUploaded: 'Alcuni file non possono essere caricati', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `I file devono essere inferiori a ${maxUploadSizeInMB} MB. I file più grandi non verranno caricati.`, + maxFileLimitExceeded: 'Puoi caricare fino a 30 ricevute alla volta. Quelle in eccesso non verranno caricate.', + unsupportedFileType: ({fileType}: FileTypeParams) => `I file ${fileType} non sono supportati. Verranno caricati solo i tipi di file supportati.`, + learnMoreAboutSupportedFiles: 'Scopri di più sui formati supportati.', + passwordProtected: 'I PDF protetti da password non sono supportati. Verranno caricati solo i file supportati.', }, dropzone: { addAttachments: 'Aggiungi allegati', @@ -951,9 +959,13 @@ const translations = { }, receipt: { upload: 'Carica ricevuta', + uploadMultiple: 'Carica ricevute', dragReceiptBeforeEmail: 'Trascina una ricevuta su questa pagina, inoltra una ricevuta a', + dragReceiptsBeforeEmail: 'Trascina ricevute su questa pagina, inoltra ricevute a', dragReceiptAfterEmail: 'oppure scegli un file da caricare qui sotto.', + dragReceiptsAfterEmail: 'oppure scegli file da caricare qui sotto.', chooseReceipt: 'Scegli una ricevuta da caricare o inoltra una ricevuta a', + chooseReceipts: 'Scegli ricevute da caricare o inoltra ricevute a', takePhoto: 'Scatta una foto', cameraAccess: "L'accesso alla fotocamera è necessario per scattare foto delle ricevute.", deniedCameraAccess: "L'accesso alla fotocamera non è ancora stato concesso, si prega di seguire", diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 80d4d4b5d8aad..289f3a842f030 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'Long ID', bankAccounts: '銀行口座', chooseFile: 'ファイルを選択', + chooseFiles: 'ファイルを選択', dropTitle: 'そのままにしておく', dropMessage: 'ここにファイルをドロップしてください', ignore: 'Ignore', @@ -666,6 +668,12 @@ const translations = { attachmentImageTooLarge: 'この画像はアップロード前にプレビューするには大きすぎます。', tooManyFiles: ({fileLimit}: FileLimitParams) => `一度にアップロードできるファイルは${fileLimit}個までです。`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `ファイルが ${maxUploadSizeInMB} MB を超えています。もう一度お試しください。`, + someFilesCantBeUploaded: '一部のファイルはアップロードできません', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `ファイルは${maxUploadSizeInMB}MB未満である必要があります。それより大きいファイルはアップロードされません。`, + maxFileLimitExceeded: '一度に最大30枚の領収書をアップロードできます。超過分はアップロードされません。', + unsupportedFileType: ({fileType}: FileTypeParams) => `${fileType}ファイルはサポートされていません。サポートされているファイルタイプのみがアップロードされます。`, + learnMoreAboutSupportedFiles: 'サポートされているフォーマットについて詳しく知る。', + passwordProtected: 'パスワード保護されたPDFはサポートされていません。サポートされているファイルのみがアップロードされます。', }, dropzone: { addAttachments: '添付ファイルを追加', @@ -954,9 +962,13 @@ const translations = { }, receipt: { upload: '領収書をアップロード', + uploadMultiple: '領収書をアップロード', dragReceiptBeforeEmail: '領収書をこのページにドラッグするか、領収書を転送する', + dragReceiptsBeforeEmail: '領収書をこのページにドラッグするか、領収書を転送する', dragReceiptAfterEmail: 'または、以下にアップロードするファイルを選択してください。', + dragReceiptsAfterEmail: 'または、以下にアップロードするファイルを選択してください。', chooseReceipt: 'アップロードするレシートを選択するか、レシートを転送してください', + chooseReceipts: 'アップロードするレシートを選択するか、レシートを転送してください', takePhoto: '写真を撮る', cameraAccess: '領収書の写真を撮るためにカメラへのアクセスが必要です。', deniedCameraAccess: 'カメラへのアクセスがまだ許可されていません。以下の手順に従ってください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 02c3e046c551a..d04e35e4e07aa 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'Lang ID', bankAccounts: 'Bankrekeningen', chooseFile: 'Bestand kiezen', + chooseFiles: 'Bestanden kiezen', dropTitle: 'Laat het gaan', dropMessage: 'Sleep hier je bestand in.', ignore: 'Ignore', @@ -665,6 +667,12 @@ const translations = { attachmentImageTooLarge: 'Deze afbeelding is te groot om te bekijken voordat deze wordt geüpload.', tooManyFiles: ({fileLimit}: FileLimitParams) => `U kunt maximaal ${fileLimit} bestanden tegelijk uploaden.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Bestanden overschrijden ${maxUploadSizeInMB} MB. Probeer het opnieuw.`, + someFilesCantBeUploaded: 'Sommige bestanden kunnen niet worden geüpload', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `Bestanden moeten kleiner zijn dan ${maxUploadSizeInMB} MB. Grotere bestanden worden niet geüpload.`, + maxFileLimitExceeded: "U kunt maximaal 30 bonnetjes tegelijk uploaden. Extra's worden niet geüpload.", + unsupportedFileType: ({fileType}: FileTypeParams) => `${fileType} bestanden worden niet ondersteund. Alleen ondersteunde bestandstypen worden geüpload.`, + learnMoreAboutSupportedFiles: 'Meer informatie over ondersteunde formaten.', + passwordProtected: "Met wachtwoord beveiligde PDF's worden niet ondersteund. Alleen ondersteunde bestanden worden geüpload.", }, dropzone: { addAttachments: 'Bijlagen toevoegen', @@ -952,9 +960,13 @@ const translations = { }, receipt: { upload: 'Bonnetje uploaden', + uploadMultiple: 'Bonnetjes uploaden', dragReceiptBeforeEmail: 'Sleep een bon naar deze pagina, stuur een bon door naar', + dragReceiptsBeforeEmail: 'Sleep bonnen naar deze pagina, stuur bonnen door naar', dragReceiptAfterEmail: 'of kies hieronder een bestand om te uploaden.', + dragReceiptsAfterEmail: 'of kies hieronder bestanden om te uploaden.', chooseReceipt: 'Kies een bon om te uploaden of stuur een bon door naar', + chooseReceipts: 'Kies bonnen om te uploaden of stuur bonnen door naar', takePhoto: 'Maak een foto', cameraAccess: "Cameratoegang is vereist om foto's van bonnetjes te maken.", deniedCameraAccess: 'Camera-toegang is nog steeds niet verleend, volg alstublieft', diff --git a/src/languages/params.ts b/src/languages/params.ts index 037ad2b71eab0..6a21f85f0ce69 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -677,6 +677,10 @@ type FileLimitParams = { fileLimit: number; }; +type FileTypeParams = { + fileType: string; +}; + type CompanyCardBankName = { bankName: string; }; @@ -806,6 +810,7 @@ export type { AutoPayApprovedReportsLimitErrorParams, FeatureNameParams, FileLimitParams, + FileTypeParams, SpreadSheetColumnParams, SpreadFieldNameParams, AssignedCardParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ea8bad34c3743..4420749b8fd05 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'Długi identyfikator', bankAccounts: 'Konta bankowe', chooseFile: 'Wybierz plik', + chooseFiles: 'Wybierz pliki', dropTitle: 'Puść to', dropMessage: 'Prześlij swój plik tutaj', ignore: 'Ignore', @@ -666,6 +668,12 @@ const translations = { attachmentImageTooLarge: 'Ten obraz jest zbyt duży, aby wyświetlić podgląd przed przesłaniem.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Możesz przesłać jednocześnie maksymalnie ${fileLimit} plików.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Plik przekracza ${maxUploadSizeInMB} MB. Proszę spróbować ponownie.`, + someFilesCantBeUploaded: 'Niektóre pliki nie mogą zostać przesłane', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `Pliki muszą mieć mniej niż ${maxUploadSizeInMB} MB. Większe pliki nie zostaną przesłane.`, + maxFileLimitExceeded: 'Możesz przesłać maksymalnie 30 paragonów naraz. Dodatkowe nie zostaną przesłane.', + unsupportedFileType: ({fileType}: FileTypeParams) => `Pliki ${fileType} nie są obsługiwane. Tylko obsługiwane typy plików zostaną przesłane.`, + learnMoreAboutSupportedFiles: 'Dowiedz się więcej o obsługiwanych formatach.', + passwordProtected: 'Pliki PDF chronione hasłem nie są obsługiwane. Tylko obsługiwane pliki zostaną przesłane.', }, dropzone: { addAttachments: 'Dodaj załączniki', @@ -950,9 +958,13 @@ const translations = { }, receipt: { upload: 'Prześlij paragon', + uploadMultiple: 'Prześlij paragony', dragReceiptBeforeEmail: 'Przeciągnij paragon na tę stronę, prześlij paragon do', + dragReceiptsBeforeEmail: 'Przeciągnij paragony na tę stronę, prześlij paragony do', dragReceiptAfterEmail: 'lub wybierz plik do przesłania poniżej.', + dragReceiptsAfterEmail: 'lub wybierz pliki do przesłania poniżej.', chooseReceipt: 'Wybierz paragon do przesłania lub prześlij paragon do', + chooseReceipts: 'Wybierz paragony do przesłania lub prześlij paragony do', takePhoto: 'Zrób zdjęcie', cameraAccess: 'Dostęp do aparatu jest wymagany, aby robić zdjęcia paragonów.', deniedCameraAccess: 'Dostęp do kamery nadal nie został przyznany, proszę postępować zgodnie z', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 512e58368c27a..99bdb3adc7268 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'ID longo', bankAccounts: 'Contas bancárias', chooseFile: 'Escolher arquivo', + chooseFiles: 'Escolher arquivos', dropTitle: 'Deixe ir', dropMessage: 'Solte seu arquivo aqui', ignore: 'Ignore', @@ -665,6 +667,12 @@ const translations = { attachmentImageTooLarge: 'Esta imagem é muito grande para pré-visualizar antes de fazer o upload.', tooManyFiles: ({fileLimit}: FileLimitParams) => `Você pode enviar até ${fileLimit} arquivos de uma vez.`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `Os arquivos excedem ${maxUploadSizeInMB} MB. Por favor, tente novamente.`, + someFilesCantBeUploaded: 'Alguns arquivos não podem ser enviados', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `Os arquivos devem ter menos de ${maxUploadSizeInMB} MB. Arquivos maiores não serão enviados.`, + maxFileLimitExceeded: 'Você pode enviar até 30 recibos por vez. Os extras não serão enviados.', + unsupportedFileType: ({fileType}: FileTypeParams) => `Arquivos ${fileType} não são suportados. Apenas os tipos de arquivo suportados serão enviados.`, + learnMoreAboutSupportedFiles: 'Saiba mais sobre formatos suportados.', + passwordProtected: 'PDFs protegidos por senha não são suportados. Apenas arquivos suportados serão enviados.', }, dropzone: { addAttachments: 'Adicionar anexos', @@ -952,9 +960,13 @@ const translations = { }, receipt: { upload: 'Fazer upload de recibo', + uploadMultiple: 'Fazer upload de recibos', dragReceiptBeforeEmail: 'Arraste um recibo para esta página, encaminhe um recibo para', + dragReceiptsBeforeEmail: 'Arraste recibos para esta página, encaminhe recibos para', dragReceiptAfterEmail: 'ou escolha um arquivo para enviar abaixo.', + dragReceiptsAfterEmail: 'ou escolha arquivos para enviar abaixo.', chooseReceipt: 'Escolha um recibo para enviar ou encaminhe um recibo para', + chooseReceipts: 'Escolha recibos para enviar ou encaminhe recibos para', takePhoto: 'Tire uma foto', cameraAccess: 'O acesso à câmera é necessário para tirar fotos dos recibos.', deniedCameraAccess: 'O acesso à câmera ainda não foi concedido, por favor siga', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bb5ff6bbf7c0b..84e836bb1aff0 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -99,6 +99,7 @@ import type { ExportIntegrationSelectedParams, FeatureNameParams, FileLimitParams, + FileTypeParams, FiltersAmountBetweenParams, FlightLayoverParams, FlightParams, @@ -562,6 +563,7 @@ const translations = { longID: 'Long ID', bankAccounts: '银行账户', chooseFile: '选择文件', + chooseFiles: '选择文件', dropTitle: 'Let it go', dropMessage: '在此处拖放您的文件', ignore: 'Ignore', @@ -665,6 +667,12 @@ const translations = { attachmentImageTooLarge: '此图像太大,无法在上传前预览。', tooManyFiles: ({fileLimit}: FileLimitParams) => `您一次最多只能上传 ${fileLimit} 个文件。`, sizeExceededWithValue: ({maxUploadSizeInMB}: SizeExceededParams) => `文件超过 ${maxUploadSizeInMB} MB。请重试。`, + someFilesCantBeUploaded: '有些文件无法上传', + sizeLimitExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `文件必须小于${maxUploadSizeInMB} MB。较大的文件将不会被上传。`, + maxFileLimitExceeded: '您一次最多可上传30张收据。额外的将不会被上传。', + unsupportedFileType: ({fileType}: FileTypeParams) => `${fileType} 文件不受支持。只有受支持的文件类型才会被上传。`, + learnMoreAboutSupportedFiles: '了解更多关于支持的格式。', + passwordProtected: '不支持密码保护的PDF。只有受支持的文件才会被上传。', }, dropzone: { addAttachments: '添加附件', @@ -945,9 +953,13 @@ const translations = { }, receipt: { upload: '上传收据', + uploadMultiple: '上传收据', dragReceiptBeforeEmail: '将收据拖到此页面上,转发收据到', + dragReceiptsBeforeEmail: '将收据拖到此页面上,转发收据到', dragReceiptAfterEmail: '或选择下面的文件上传。', + dragReceiptsAfterEmail: '或选择下面的文件上传。', chooseReceipt: '选择要上传的收据或转发收据到', + chooseReceipts: '选择要上传的收据或转发收据到', takePhoto: '拍照', cameraAccess: '需要相机权限来拍摄收据照片。', deniedCameraAccess: '相机访问权限仍未授予,请按照以下步骤操作', diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1e67d9a7367a5..d833327b33b52 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -953,9 +953,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, @@ -967,7 +965,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/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/libs/deepReplaceKeysAndValues.ts b/src/libs/deepReplaceKeysAndValues.ts index 7fa7adfaa7075..45108215c877e 100644 --- a/src/libs/deepReplaceKeysAndValues.ts +++ b/src/libs/deepReplaceKeysAndValues.ts @@ -26,6 +26,11 @@ function deepReplaceKeysAndValues(target: T, oldVal: Object.entries(target).forEach(([key, val]) => { const newKey = key.replace(oldVal, newVal); + if (val instanceof File || val instanceof Blob) { + newObj[newKey] = val; + return; + } + if (typeof val === 'object') { newObj[newKey] = deepReplaceKeysAndValues(val as T, oldVal, newVal); return; diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 12e9a0c8243ba..52fda826f6449 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 DateUtils from '@libs/DateUtils'; import getPlatform from '@libs/getPlatform'; import {translateLocal} from '@libs/Localize'; @@ -388,6 +388,112 @@ const getConfirmModalPrompt = (attachmentInvalidReason: TranslationPaths | undef return translateLocal(attachmentInvalidReason); }; +const isValidReceiptExtension = (file: FileObject) => { + const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); + return CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( + fileExtension.toLowerCase() as TupleToUnion, + ); +}; + +const isHeicOrHeifImage = (file: FileObject) => { + return ( + file?.type?.startsWith('image') && + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (file.name?.toLowerCase().endsWith('.heic') || file.name?.toLowerCase().endsWith('.heif')) + ); +}; + +const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, isValidatingReceipt?: boolean) => { + const maxFileSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; + if (!Str.isImage(file.name ?? '') && !isHeicOrHeifImage(file) && (file?.size ?? 0) > maxFileSize) { + 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 ''; +}; + +type TranslationAdditionalData = { + maxUploadSizeInMB?: number; + fileLimit?: number; + fileType?: string; +}; + +const getFileValidationErrorText = ( + validationError: ValueOf | null, + additionalData: TranslationAdditionalData = {}, +): { + title: string; + reason: string; +} => { + if (!validationError) { + return { + title: '', + reason: '', + }; + } + switch (validationError) { + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE: + return { + title: translateLocal('attachmentPicker.wrongFileType'), + reason: translateLocal('attachmentPicker.notAllowedExtension'), + }; + case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE: + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.unsupportedFileType', {fileType: additionalData.fileType ?? ''}), + }; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE: + return { + title: translateLocal('attachmentPicker.attachmentTooLarge'), + reason: translateLocal('attachmentPicker.sizeExceeded'), + }; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE: + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.sizeLimitExceeded', { + maxUploadSizeInMB: additionalData.maxUploadSizeInMB ?? CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE / 1024 / 1024, + }), + }; + case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL: + return { + title: translateLocal('attachmentPicker.attachmentTooSmall'), + reason: translateLocal('attachmentPicker.sizeNotMet'), + }; + case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED: + return { + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.folderNotAllowedMessage'), + }; + case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED: + return { + title: translateLocal('attachmentPicker.someFilesCantBeUploaded'), + reason: translateLocal('attachmentPicker.maxFileLimitExceeded'), + }; + case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED: + return { + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.errorWhileSelectingCorruptedAttachment'), + }; + case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE: + return { + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.protectedPDFNotSupported'), + }; + default: + return { + title: translateLocal('attachmentPicker.attachmentError'), + reason: translateLocal('attachmentPicker.errorWhileSelectingCorruptedAttachment'), + }; + } +}; + export { showGeneralErrorAlert, showSuccessAlert, @@ -410,5 +516,9 @@ export { resizeImageIfNeeded, createFile, validateReceipt, + validateAttachment, + isValidReceiptExtension, + getFileValidationErrorText, + isHeicOrHeifImage, getConfirmModalPrompt, }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1ac12c860ad8e..2b7bc2554c91a 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -7,9 +7,7 @@ import DecisionModal from '@components/DecisionModal'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; 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'; @@ -18,7 +16,8 @@ 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 useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -38,22 +37,24 @@ import { search, unholdMoneyRequestOnSearch, } from '@libs/actions/Search'; -import {getConfirmModalPrompt} from '@libs/fileDownload/FileUtils'; import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {hasVBBA} from '@libs/PolicyUtils'; -import {generateReportID} from '@libs/ReportUtils'; +import {hasVBBA, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {generateReportID, getPolicyExpenseChat} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; import type {FileObject} from '@pages/media/AttachmentModalScreen/types'; import variables from '@styles/variables'; -import {initMoneyRequest, setMoneyRequestReceipt} from '@userActions/IOU'; +import {initMoneyRequest, setMoneyRequestParticipantsFromReport, 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; @@ -67,24 +68,17 @@ 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}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: false}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); const [isDownloadExportModalVisible, setIsDownloadExportModalVisible] = useState(false); - const { - validateAndResizeFile, - setIsAttachmentInvalid, - isAttachmentInvalid, - attachmentInvalidReason, - attachmentInvalidReasonTitle, - setUploadReceiptError, - pdfFile, - setPdfFile, - isLoadingReceipt, - } = useFileValidation(); const {q} = route.params; @@ -398,33 +392,67 @@ 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); + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const newReportID = generateReportID(); - initMoneyRequest({ + const initialTransaction = 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 setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateAndResizeFile(originalFile, saveFileAndInitMoneyRequest, isPdfValidated); + const newReceiptFiles: ReceiptFile[] = []; + + files.forEach((file, index) => { + const source = URL.createObjectURL(file as Blob); + const transaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID: newReportID, + }); + const transactionID = transaction.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + newReceiptFiles.push({ + file, + source, + transactionID, + }); + setMoneyRequestReceipt(transactionID, source, file.name ?? '', true); + }); + + if (isPaidGroupPolicy(activePolicy) && activePolicy?.isPolicyExpenseChatEnabled && !shouldRestrictUserBillableActions(activePolicy.id)) { + const activePolicyExpenseChat = getPolicyExpenseChat(currentUserPersonalDetails.accountID, activePolicy?.id); + const setParticipantsPromises = newReceiptFiles.map((receiptFile) => setMoneyRequestParticipantsFromReport(receiptFile.transactionID, activePolicyExpenseChat)); + Promise.all(setParticipantsPromises).then(() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + CONST.IOU.TYPE.SUBMIT, + initialTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + activePolicyExpenseChat?.reportID, + ), + ), + ); + } else { + navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); + } }; + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(saveFileAndInitMoneyRequest); + const initScanRequest = (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); + }); + + validateFiles(files); }; const createExportAll = useCallback(() => { @@ -448,24 +476,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(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} - /> - ) : null; - // Handles video player cleanup: // 1. On mount: Resets player if navigating from report screen // 2. On unmount: Stops video when leaving this screen @@ -496,9 +506,8 @@ function SearchPage({route}: SearchPageProps) { if (shouldUseNarrowLayout) { return ( <> - {isLoadingReceipt && } - {PDFThumbnailView} + {PDFValidationComponent} - + {ErrorModal} {!!selectionMode && selectionMode?.isEnabled && ( @@ -582,9 +583,8 @@ function SearchPage({route}: SearchPageProps) { shouldShowOfflineIndicatorInWideScreen={!!shouldShowOfflineIndicator} offlineIndicatorStyle={styles.mtAuto} > - {isLoadingReceipt && } - {PDFThumbnailView} + {PDFValidationComponent} - + {ErrorModal} )} hasReceiptTransactionUtils(transaction), [transaction]); + const isEditingReceipt = isTransactionThreadView && transactionID && hasReceipt; + // Placeholder to display in the chat input. const inputPlaceholder = useMemo(() => { if (includesConcierge && userBlockedFromConcierge) { @@ -391,6 +391,7 @@ function ReportActionCompose({ const composerRefShared = useSharedValue<{ clear: (() => void) | undefined; }>({clear: undefined}); + const handleSendMessage = useCallback(() => { 'worklet'; @@ -473,55 +474,71 @@ 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); - - if (isTransactionThreadView && transactionID) { - replaceReceipt({transactionID, file: file as File, source}); + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { + if (files.length === 0) { + return; + } + if (isEditingReceipt) { + 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 ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true); + setMoneyRequestParticipantsFromReport(newTransactionID, report); + }); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + isSelfDM(report) ? CONST.IOU.TYPE.TRACK : 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); + if (policy && shouldRestrictUserBillableActions(policy.id)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + if (isEditingReceipt) { + const file = e?.dataTransfer?.files?.[0]; + if (file) { + file.uri = URL.createObjectURL(file); + validateFiles([file]); + return; + } } - }; - // TODO: to be refactored in step 3 - const PDFThumbnailView = pdfFile ? ( - { - setPdfFile(null); - setReceiptAndNavigate(pdfFile, true); - }} - onPassword={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); - }} - onLoadError={() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - }} - /> - ) : null; + 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); + }; return ( @@ -548,7 +565,7 @@ function ReportActionCompose({ !!exceededMaxLength && styles.borderColorDanger, ]} > - {PDFThumbnailView} + {PDFValidationComponent} - + {ErrorModal} (!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; @@ -160,6 +156,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) ?? ''; @@ -221,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) { @@ -310,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 @@ -1001,30 +944,32 @@ function IOURequestStepConfirmation({ setIsConfirming(false); }; + /** + * 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} = useFilesValidation(setReceiptOnDrop); + + const handleDroppingReceipt = (e: DragEvent) => { + const file = e?.dataTransfer?.files[0]; + if (file) { + file.uri = URL.createObjectURL(file); + validateFiles([file]); + } + }; + if (isLoadingTransaction) { 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,47 +1025,23 @@ function IOURequestStepConfirmation({ /> ) : null} - {(isLoading || isLoadingReceipt || (isScanRequest(transaction) && !Object.values(receiptFiles).length)) && } - {PDFThumbnailView} + {(isLoading || (isScanRequest(transaction) && !Object.values(receiptFiles).length)) && } + {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 && ( (null); - const defaultTaxCode = getDefaultTaxCode(policy, initialTransaction); const transactionTaxCode = (initialTransaction?.taxCode ? initialTransaction?.taxCode : defaultTaxCode) ?? ''; const transactionTaxAmount = initialTransaction?.taxAmount ?? 0; @@ -256,32 +249,6 @@ function IOURequestStepScan({ setReceiptFiles([]); }, [isMultiScanEnabled]); - const validateReceipt = (file: FileObject) => { - const {fileExtension} = splitExtensionFromFileName(file?.name ?? ''); - if ( - !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( - fileExtension.toLowerCase() as TupleToUnion, - ) - ) { - Alert.alert(translate('attachmentPicker.wrongFileType'), translate('attachmentPicker.notAllowedExtension')); - return false; - } - - if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - Alert.alert( - translate('attachmentPicker.attachmentTooLarge'), - translate('attachmentPicker.sizeExceededWithLimit', {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}), - ); - return false; - } - - if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - Alert.alert(translate('attachmentPicker.attachmentTooSmall'), translate('attachmentPicker.sizeNotMet')); - return false; - } - return true; - }; - const navigateBack = () => { Navigation.goBack(); }; @@ -312,7 +279,15 @@ function IOURequestStepScan({ ); const createTransaction = useCallback( - (files: ReceiptFile[], participant: Participant, gpsPoints?: GpsPoint, policyParams?: {policy: OnyxEntry}, billable?: boolean) => { + ( + files: ReceiptFile[], + participant: Participant, + gpsPoints?: GpsPoint, + policyParams?: { + policy: OnyxEntry; + }, + billable?: boolean, + ) => { files.forEach((receiptFile: ReceiptFile, index) => { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -366,25 +341,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) { @@ -398,7 +354,17 @@ function IOURequestStepScan({ if (!managerMcTestParticipant.reportID && report?.reportID) { reportIDParam = generateReportID(); } - setMoneyRequestParticipants(initialTransactionID, [{...managerMcTestParticipant, reportID: reportIDParam, selected: true}], true).then(() => { + setMoneyRequestParticipants( + initialTransactionID, + [ + { + ...managerMcTestParticipant, + reportID: reportIDParam, + selected: true, + }, + ], + true, + ).then(() => { navigateToConfirmationPage(true, reportIDParam); }); return; @@ -536,22 +502,6 @@ function IOURequestStepScan({ }); }, [initialTransactionID, isEditing, navigateToConfirmationStep]); - /** - * Converts HEIC image to JPEG using promises - */ - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { - return new Promise((resolve, reject) => { - convertHeicImage(file, { - onStart: () => setIsLoaderVisible(true), - onSuccess: (convertedFile) => resolve(convertedFile), - onError: (nonConvertedFile) => { - reject(nonConvertedFile); - }, - onFinish: () => setIsLoaderVisible(false), - }); - }); - }; - const dismissMultiScanEducationalPopup = () => { InteractionManager.runAfterInteractions(() => { dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL); @@ -562,69 +512,57 @@ function IOURequestStepScan({ /** * Sets the Receipt objects and navigates the user to the next page */ - const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - if (!validateReceipt(originalFile)) { + const setReceiptFilesAndNavigate = (files: FileObject[]) => { + if (files.length === 0) { return; } + // Store the receipt on the transaction object in Onyx + const newReceiptFiles: ReceiptFile[] = []; - // 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); + if (isEditing) { + const file = files.at(0); + if (!file) { + return; + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setMoneyRequestReceipt(initialTransactionID, file.uri || '', file.name || '', !isEditing); + updateScanAndNavigate(file, file.uri ?? ''); return; } - // Helper function to process the file after any conversion - const processFile = (file: FileObject) => { - resizeImageIfNeeded(file).then((resizedFile) => { - // Store the receipt on the transaction object in Onyx - // On Android devices, fetching blob for a file with name containing spaces fails to retrieve the type of file. - // So, let us also save the file type in receipt for later use during blob fetch - setMoneyRequestReceipt(initialTransactionID, resizedFile?.uri ?? '', resizedFile.name ?? '', !isEditing, resizedFile.type); - - if (isEditing) { - updateScanAndNavigate(resizedFile, resizedFile?.uri ?? ''); - return; - } + files.forEach((file, index) => { + const transaction = + !isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP) || (index === 0 && transactions.length === 1 && !initialTransaction?.receipt) + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction.transactionID ?? initialTransactionID; + newReceiptFiles.push({file, source: file.uri ?? '', transactionID}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setMoneyRequestReceipt(transactionID, file.uri ?? '', file.name || '', true); + }); - const newReceiptFiles = [{file: resizedFile, source: resizedFile?.uri ?? '', transactionID: initialTransactionID}]; + if (shouldSkipConfirmation) { + setReceiptFiles(newReceiptFiles); + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; + if (gpsRequired) { + const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); - if (shouldSkipConfirmation) { - setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && resizedFile; - - if (gpsRequired) { - const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); - if (beginLocationPermissionFlow) { - setStartLocationPermissionFlow(true); - return; - } - } + if (beginLocationPermissionFlow) { + setStartLocationPermissionFlow(true); + return; } - navigateToConfirmationStep(newReceiptFiles, false); - }); - }; - - // Check if the file is HEIC/HEIF and needs conversion - if ( - originalFile?.type?.startsWith('image') && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (originalFile.name?.toLowerCase().endsWith('.heic') || originalFile.name?.toLowerCase().endsWith('.heif')) - ) { - convertHeicImageToJpegPromise(originalFile) - .then((convertedFile) => { - processFile(convertedFile); - }) - .catch((fallbackFile: FileObject) => { - // Use the original file if conversion fails - processFile(fallbackFile); - }); - return; + } } - - // Process the file directly if no conversion is needed - processFile(originalFile); + navigateToConfirmationStep(newReceiptFiles, false); }; + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(setReceiptFilesAndNavigate); + const submitReceipts = useCallback( (files: ReceiptFile[]) => { if (shouldSkipConfirmation) { @@ -692,7 +630,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); @@ -738,12 +683,13 @@ function IOURequestStepScan({ flash, hasFlash, isPlatformMuted, - submitReceipts, - receiptFiles, - buildOptimisticTransaction, initialTransaction, + currentUserPersonalDetails, + reportID, initialTransactionID, isEditing, + receiptFiles, + submitReceipts, updateScanAndNavigate, ]); @@ -780,26 +726,7 @@ function IOURequestStepScan({ onLayout(setTestReceiptAndNavigate); }} > - {!!pdfFile && ( - { - setPdfFile(null); - if (pdfFile) { - setReceiptAndNavigate(pdfFile, true); - } - }} - onPassword={() => { - setPdfFile(null); - Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.protectedPDFNotSupported')); - }} - onLoadError={() => { - setPdfFile(null); - Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedAttachment')); - }} - /> - )} + {PDFValidationComponent} {cameraPermissionStatus !== RESULTS.GRANTED && ( @@ -888,7 +815,10 @@ function IOURequestStepScan({ /> )} - setIsLoaderVisible(true)}> + setIsLoaderVisible(true)} + fileLimit={isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP) ? CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT : 1} + > {({openPicker}) => ( { openPicker({ - onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}), + onPicked: (data) => validateFiles(data), onCanceled: () => setIsLoaderVisible(false), // makes sure the loader is not visible anymore e.g. when there is an error while uploading a file onClosed: () => { @@ -978,6 +908,7 @@ function IOURequestStepScan({ }} /> )} + {ErrorModal} ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index c79ef1a3fad75..51c0796482c99 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -1,6 +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'; import type {OnyxEntry} from 'react-native-onyx'; @@ -14,22 +12,20 @@ import ReceiptUpload from '@assets/images/receipt-upload.svg'; import Shutter from '@assets/images/shutter.svg'; import AttachmentPicker from '@components/AttachmentPicker'; import Button from '@components/Button'; -import ConfirmModal from '@components/ConfirmModal'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import DownloadAppBanner from '@components/DownloadAppBanner'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import {DragAndDropContext} from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; import FeatureTrainingModal from '@components/FeatureTrainingModal'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import LocationPermissionModal from '@components/LocationPermissionModal'; -import PDFThumbnail from '@components/PDFThumbnail'; 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 useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -41,17 +37,16 @@ import setTestReceipt from '@libs/actions/setTestReceipt'; import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; import {dismissProductTraining} from '@libs/actions/Welcome'; import {isMobile, isMobileWebKit} from '@libs/Browser'; -import {base64ToFile, isLocalFile as isLocalFileFileUtils, resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; -import convertHeicImage from '@libs/fileDownload/heicConverter'; +import {base64ToFile, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; 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'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {generateReportID, getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getDefaultTaxCode} from '@libs/TransactionUtils'; +import {getDefaultTaxCode, hasReceipt} from '@libs/TransactionUtils'; import ReceiptDropUI from '@pages/iou/ReceiptDropUI'; import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -70,10 +65,8 @@ 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'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; @@ -102,11 +95,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); @@ -131,9 +119,9 @@ function IOURequestStepScan({ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: false}); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); - const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const isEditing = action === CONST.IOU.ACTION.EDIT; const canUseMultiScan = isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_SCAN) && !isEditing && iouType !== CONST.IOU.TYPE.SPLIT && !backTo && !backToReport; + const isReplacingReceipt = isEditing && hasReceipt(initialTransaction); const [optimisticTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, { selector: (items) => Object.values(items ?? {}), @@ -151,6 +139,9 @@ function IOURequestStepScan({ const transactionTaxCode = (initialTransaction?.taxCode ? initialTransaction?.taxCode : defaultTaxCode) ?? ''; const transactionTaxAmount = initialTransaction?.taxAmount ?? 0; + const canUseMultiDragAndDrop = isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP); + const shouldAcceptMultipleFiles = canUseMultiDragAndDrop && !isEditing && !backTo; + const blinkOpacity = useSharedValue(0); const blinkStyle = useAnimatedStyle(() => ({ opacity: blinkOpacity.get(), @@ -321,20 +312,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]); @@ -364,27 +341,16 @@ 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: ReceiptFile[], + participant: Participant, + gpsPoints?: GpsPoint, + policyParams?: { + policy: OnyxEntry; + }, + billable?: boolean, + ) => { files.forEach((receiptFile: ReceiptFile, index) => { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -585,92 +551,70 @@ function IOURequestStepScan({ [initialTransactionID, navigateBack], ); - /** - * Converts HEIC image to JPEG using promises - */ - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { - return new Promise((resolve, reject) => { - convertHeicImage(file, { - onStart: () => setIsLoadingReceipt(true), - onSuccess: (convertedFile) => resolve(convertedFile), - onError: (nonConvertedFile) => { - reject(nonConvertedFile); - }, - onFinish: () => setIsLoadingReceipt(false), - }); - }); - }; + const setReceiptFilesAndNavigate = (files: FileObject[]) => { + if (files.length === 0) { + return; + } + // Store the receipt on the transaction object in Onyx + const newReceiptFiles: ReceiptFile[] = []; - /** - * Sets the Receipt objects and navigates the user to the next page - */ - const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => { - if (!isFileValid) { + if (isEditing) { + const file = files.at(0); + if (!file) { return; } + const source = URL.createObjectURL(file as Blob); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setMoneyRequestReceipt(initialTransactionID, source, file.name || '', !isEditing); + updateScanAndNavigate(file, source); + 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; - } + files.forEach((file, index) => { + const source = URL.createObjectURL(file as Blob); + const transaction = + !isBetaEnabled(CONST.BETAS.NEWDOT_MULTI_FILES_DRAG_AND_DROP) || (index === 0 && transactions.length === 1 && !initialTransaction?.receipt) + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction.transactionID ?? initialTransactionID; + newReceiptFiles.push({file, source, transactionID}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setMoneyRequestReceipt(transactionID, source, file.name || '', true); + }); - // Helper function to process the file after any conversion - const processFile = (file: FileObject) => { - // 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(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setIsLoadingReceipt(true); + if (shouldSkipConfirmation) { + setReceiptFiles(newReceiptFiles); + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length; + if (gpsRequired) { + const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); + + if (beginLocationPermissionFlow) { + setStartLocationPermissionFlow(true); + return; } - resizeImageIfNeeded(file).then((resizedFile) => { - setIsLoadingReceipt(false); - // Store the receipt on the transaction object in Onyx - const source = URL.createObjectURL(resizedFile as Blob); - const newReceiptFiles = [{file: resizedFile, source, transactionID: initialTransactionID}]; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - setMoneyRequestReceipt(initialTransactionID, source, resizedFile.name || '', !isEditing); - - if (isEditing) { - updateScanAndNavigate(resizedFile, source); - return; - } - if (shouldSkipConfirmation) { - setReceiptFiles(newReceiptFiles); - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && resizedFile; - if (gpsRequired) { - const beginLocationPermissionFlow = shouldStartLocationPermissionFlow(); - - if (beginLocationPermissionFlow) { - setStartLocationPermissionFlow(true); - return; - } - } - } - navigateToConfirmationStep(newReceiptFiles, false); - }); - }; - - // Check if the file is HEIC/HEIF and needs conversion - if ( - originalFile?.type?.startsWith('image') && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (originalFile.name?.toLowerCase().endsWith('.heic') || originalFile.name?.toLowerCase().endsWith('.heif')) - ) { - convertHeicImageToJpegPromise(originalFile) - .then((convertedFile) => { - processFile(convertedFile); - }) - .catch((fallbackFile: FileObject) => { - // Use the original file if conversion fails - processFile(fallbackFile); - }); - return; } + } + navigateToConfirmationStep(newReceiptFiles, false); + }; - // Process the file directly if no conversion is needed - processFile(originalFile); + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(setReceiptFilesAndNavigate); + + 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); }; /** @@ -731,7 +675,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}]; @@ -749,15 +700,16 @@ function IOURequestStepScan({ submitReceipts(newReceiptFiles); }, [ - receiptFiles, - showBlink, - buildOptimisticTransaction, + isMultiScanEnabled, initialTransaction, + currentUserPersonalDetails, + reportID, initialTransactionID, + receiptFiles, isEditing, - isMultiScanEnabled, submitReceipts, requestCameraPermission, + showBlink, updateScanAndNavigate, ]); @@ -815,33 +767,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); @@ -852,7 +777,7 @@ function IOURequestStepScan({ const mobileCameraView = () => ( <> - {PDFThumbnailView} + {PDFValidationComponent} {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( - + {({openPicker}) => ( { openPicker({ - onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}), + onPicked: (data) => validateFiles(data), }); }} > @@ -1024,7 +952,7 @@ function IOURequestStepScan({ const desktopUploadView = () => ( <> - {PDFThumbnailView} + {PDFValidationComponent} setReceiptImageTopPosition(PixelRatio.roundToNearestPixel((nativeEvent.layout as DOMRect).top))}> - {translate('receipt.upload')} + {translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')} - {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} + {isSmallScreenWidth + ? translate(shouldAcceptMultipleFiles ? 'receipt.chooseReceipts' : 'receipt.chooseReceipt') + : translate(shouldAcceptMultipleFiles ? 'receipt.dragReceiptsBeforeEmail' : 'receipt.dragReceiptBeforeEmail')} - {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} + {isSmallScreenWidth ? null : translate(shouldAcceptMultipleFiles ? 'receipt.dragReceiptsAfterEmail' : 'receipt.dragReceiptAfterEmail')} - + {({openPicker}) => (