diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 03dde8f765a1..74a480a2eff7 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -1,8 +1,9 @@ import {useIsFocused} from '@react-navigation/native'; import {format} from 'date-fns'; +import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {Fragment, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -32,12 +33,14 @@ import Button from './Button'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import categoryPropTypes from './categoryPropTypes'; import ConfirmedRoute from './ConfirmedRoute'; +import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; import * as Expensicons from './Icon/Expensicons'; import Image from './Image'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import optionPropTypes from './optionPropTypes'; import OptionsSelector from './OptionsSelector'; +import PDFThumbnail from './PDFThumbnail'; import ReceiptEmptyState from './ReceiptEmptyState'; import SettlementButton from './SettlementButton'; import Switch from './Switch'; @@ -298,6 +301,12 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const [merchantError, setMerchantError] = useState(false); + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + + const navigateBack = () => { + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(iouType, transaction.transactionID, reportID)); + }; + const shouldDisplayFieldError = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -845,7 +854,35 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ (supplementaryField) => supplementaryField.item, ); - const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; + const { + image: receiptImage, + thumbnail: receiptThumbnail, + isLocalFile, + } = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; + + const receiptThumbnailContent = useMemo( + () => + isLocalFile && Str.isPDF(receiptFilename) ? ( + setIsAttachmentInvalid(true)} + /> + ) : ( + + ), + [receiptFilename, receiptImage, styles, receiptThumbnail, isLocalFile, isAttachmentInvalid], + ); + return ( )} - {receiptImage || receiptThumbnail ? ( - - ) : ( - // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(policy) && - !isDistanceRequest && - iouType === CONST.IOU.TYPE.REQUEST && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - /> - ) - )} + {receiptImage || receiptThumbnail + ? receiptThumbnailContent + : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + PolicyUtils.isPaidGroupPolicy(policy) && + !isDistanceRequest && + iouType === CONST.IOU.TYPE.REQUEST && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + )} {primaryFields} {!shouldShowAllFields && ( @@ -910,6 +938,15 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ )} {shouldShowAllFields && supplementaryFields} + ); } diff --git a/src/components/PDFThumbnail/index.native.tsx b/src/components/PDFThumbnail/index.native.tsx new file mode 100644 index 000000000000..4d3a33ae4e67 --- /dev/null +++ b/src/components/PDFThumbnail/index.native.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import Pdf from 'react-native-pdf'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useThemeStyles from '@hooks/useThemeStyles'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import type PDFThumbnailProps from './types'; + +function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword = () => {}}: PDFThumbnailProps) { + const styles = useThemeStyles(); + const sizeStyles = [styles.w100, styles.h100]; + + return ( + + + {enabled && ( + } + source={{uri: isAuthTokenRequired ? addEncryptedAuthTokenToURL(previewSourceURL) : previewSourceURL}} + singlePage + style={sizeStyles} + onError={(error) => { + if (!('message' in error && typeof error.message === 'string' && error.message.match(/password/i))) { + return; + } + onPassword(); + }} + /> + )} + + + ); +} + +PDFThumbnail.displayName = 'PDFThumbnail'; +export default React.memo(PDFThumbnail); diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx new file mode 100644 index 000000000000..e69e4dd5075b --- /dev/null +++ b/src/components/PDFThumbnail/index.tsx @@ -0,0 +1,48 @@ +// @ts-expect-error - This line imports a module from 'pdfjs-dist' package which lacks TypeScript typings. +import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker'; +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import {Document, pdfjs, Thumbnail} from 'react-pdf'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useThemeStyles from '@hooks/useThemeStyles'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import type PDFThumbnailProps from './types'; + +if (!pdfjs.GlobalWorkerOptions.workerSrc) { + pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); +} + +function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword = () => {}}: PDFThumbnailProps) { + const styles = useThemeStyles(); + + const thumbnail = useMemo( + () => ( + } + file={isAuthTokenRequired ? addEncryptedAuthTokenToURL(previewSourceURL) : previewSourceURL} + options={{ + cMapUrl: 'cmaps/', + cMapPacked: true, + }} + externalLinkTarget="_blank" + onPassword={() => { + onPassword(); + }} + > + + + + + ), + [isAuthTokenRequired, previewSourceURL, onPassword], + ); + + return ( + + {enabled && thumbnail} + + ); +} + +PDFThumbnail.displayName = 'PDFThumbnail'; +export default React.memo(PDFThumbnail); diff --git a/src/components/PDFThumbnail/types.ts b/src/components/PDFThumbnail/types.ts new file mode 100644 index 000000000000..11253e462aca --- /dev/null +++ b/src/components/PDFThumbnail/types.ts @@ -0,0 +1,20 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +type PDFThumbnailProps = { + /** Source URL for the preview PDF */ + previewSourceURL: string; + + /** Any additional styles to apply */ + style?: StyleProp; + + /** Whether the PDF thumbnail requires an authToken */ + isAuthTokenRequired?: boolean; + + /** Whether the PDF thumbnail can be loaded */ + enabled?: boolean; + + /** Callback to call if PDF is password protected */ + onPassword?: () => void; +}; + +export default PDFThumbnailProps; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 5ebd1bf88ffb..d47604738fbc 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -8,6 +8,7 @@ import AttachmentModal from '@components/AttachmentModal'; import EReceiptThumbnail from '@components/EReceiptThumbnail'; import * as Expensicons from '@components/Icon/Expensicons'; import Image from '@components/Image'; +import PDFThumbnail from '@components/PDFThumbnail'; import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import ThumbnailImage from '@components/ThumbnailImage'; @@ -86,7 +87,7 @@ function ReportActionItemImage({ /> ); - } else if (thumbnail && !isLocalFile && !Str.isPDF(attachmentModalSource as string)) { + } else if (thumbnail && !isLocalFile) { receiptImageComponent = ( ); + } else if (isLocalFile && filename && Str.isPDF(filename) && typeof attachmentModalSource === 'string') { + receiptImageComponent = ( + + ); } else { receiptImageComponent = ( - {shownImages.map(({thumbnail, image, transaction, isLocalFile}, index) => { + {shownImages.map(({thumbnail, image, transaction, isLocalFile, filename}, index) => { const isLastImage = index === numberOfShownImages - 1; // Show a border to separate multiple images. Shown to the right for each except the last. @@ -79,6 +79,7 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report thumbnail={thumbnail} image={image} isLocalFile={isLocalFile} + filename={filename} transaction={transaction} isSingleImage={numberOfShownImages === 1} /> diff --git a/src/languages/en.ts b/src/languages/en.ts index bd57843e9245..d19833df8f1b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -337,9 +337,10 @@ export default { sizeExceeded: 'Attachment size is larger than 24 MB limit.', attachmentTooSmall: 'Attachment too small', sizeNotMet: 'Attachment size must be greater than 240 bytes.', - wrongFileType: 'Attachment is the wrong type', + wrongFileType: 'Invalid file type', notAllowedExtension: 'This file type is not allowed', folderNotAllowedMessage: 'Uploading a folder is not allowed. Try a different file.', + protectedPDFNotSupported: 'Password-protected PDF is not supported', }, avatarCropModal: { title: 'Edit photo', diff --git a/src/languages/es.ts b/src/languages/es.ts index 83ed2ca1c89c..8cb0bcebde40 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -327,9 +327,10 @@ export default { sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', attachmentTooSmall: 'Archivo adjunto demasiado pequeño', sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.', - wrongFileType: 'El tipo de archivo adjunto es incorrecto', - notAllowedExtension: 'Este tipo de archivo no está permitido', + wrongFileType: 'Tipo de archivo inválido', + notAllowedExtension: 'Este tipo de archivo no es compatible', folderNotAllowedMessage: 'Subir una carpeta no está permitido. Prueba con otro archivo.', + protectedPDFNotSupported: 'Los PDFs con contraseña no son compatibles', }, avatarCropModal: { title: 'Editar foto', diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index 36479136c6ad..75d14be1a907 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -45,13 +45,14 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa const filename = errors?.filename ?? transaction?.filename ?? receiptFileName ?? ''; const isReceiptImage = Str.isImage(filename); const hasEReceipt = transaction?.hasEReceipt; + const isReceiptPDF = Str.isPDF(filename); if (hasEReceipt) { return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction, filename}; } // For local files, we won't have a thumbnail yet - if (isReceiptImage && typeof path === 'string' && (path.startsWith('blob:') || path.startsWith('file:'))) { + if ((isReceiptImage || isReceiptPDF) && typeof path === 'string' && (path.startsWith('blob:') || path.startsWith('file:'))) { return {thumbnail: null, image: path, isLocalFile: true, filename}; } @@ -59,6 +60,10 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa return {thumbnail: `${path}.1024.jpg`, image: path, filename}; } + if (isReceiptPDF && typeof path === 'string') { + return {thumbnail: `${path.substring(0, path.length - 4)}.jpg.1024.jpg`, image: path, filename}; + } + const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension; let image = ReceiptGeneric; if (fileExtension === CONST.IOU.FILE_TYPES.HTML) {