Skip to content

Commit

Permalink
Merge pull request #35255 from eh2077/31432-smart-scan-pdf-thumbnail
Browse files Browse the repository at this point in the history
feat: add PDFThumbnail to preview PDF receipt
  • Loading branch information
luacmartins authored Mar 5, 2024
2 parents 2a89743 + c999e10 commit 6cb4b24
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 31 deletions.
87 changes: 62 additions & 25 deletions src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) ? (
<PDFThumbnail
previewSourceURL={receiptImage}
style={styles.moneyRequestImage}
// We don't support scaning password protected PDF receipt
enabled={!isAttachmentInvalid}
onPassword={() => setIsAttachmentInvalid(true)}
/>
) : (
<Image
style={styles.moneyRequestImage}
source={{uri: receiptThumbnail || receiptImage}}
// AuthToken is required when retrieving the image from the server
// but we don't need it to load the blob:// or file:// image when starting a money request / split bill
// So if we have a thumbnail, it means we're retrieving the image from the server
isAuthTokenRequired={!_.isEmpty(receiptThumbnail)}
/>
),
[receiptFilename, receiptImage, styles, receiptThumbnail, isLocalFile, isAttachmentInvalid],
);

return (
<OptionsSelector
sections={optionSelectorSections}
Expand All @@ -870,29 +907,20 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
<ConfirmedRoute transaction={transaction} />
</View>
)}
{receiptImage || receiptThumbnail ? (
<Image
style={styles.moneyRequestImage}
source={{uri: receiptThumbnail || receiptImage}}
// AuthToken is required when retrieving the image from the server
// but we don't need it to load the blob:// or file:// image when starting a money request / split bill
// So if we have a thumbnail, it means we're retrieving the image from the server
isAuthTokenRequired={!_.isEmpty(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 && (
<ReceiptEmptyState
onPress={() =>
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 && (
<ReceiptEmptyState
onPress={() =>
Navigation.navigate(
ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
)
}
/>
)}
{primaryFields}
{!shouldShowAllFields && (
<View style={[styles.flexRow, styles.justifyContentBetween, styles.mh3, styles.alignItemsCenter, styles.mb2, styles.mt1]}>
Expand All @@ -910,6 +938,15 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
</View>
)}
{shouldShowAllFields && supplementaryFields}
<ConfirmModal
title={translate('attachmentPicker.wrongFileType')}
onConfirm={navigateBack}
onCancel={navigateBack}
isVisible={isAttachmentInvalid}
prompt={translate('attachmentPicker.protectedPDFNotSupported')}
confirmText={translate('common.close')}
shouldShowCancelButton={false}
/>
</OptionsSelector>
);
}
Expand Down
38 changes: 38 additions & 0 deletions src/components/PDFThumbnail/index.native.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[style, styles.overflowHidden]}>
<View style={[sizeStyles, styles.alignItemsCenter, styles.justifyContentCenter]}>
{enabled && (
<Pdf
fitPolicy={0}
trustAllCerts={false}
renderActivityIndicator={() => <FullScreenLoadingIndicator />}
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();
}}
/>
)}
</View>
</View>
);
}

PDFThumbnail.displayName = 'PDFThumbnail';
export default React.memo(PDFThumbnail);
48 changes: 48 additions & 0 deletions src/components/PDFThumbnail/index.tsx
Original file line number Diff line number Diff line change
@@ -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(
() => (
<Document
loading={<FullScreenLoadingIndicator />}
file={isAuthTokenRequired ? addEncryptedAuthTokenToURL(previewSourceURL) : previewSourceURL}
options={{
cMapUrl: 'cmaps/',
cMapPacked: true,
}}
externalLinkTarget="_blank"
onPassword={() => {
onPassword();
}}
>
<View pointerEvents="none">
<Thumbnail pageIndex={0} />
</View>
</Document>
),
[isAuthTokenRequired, previewSourceURL, onPassword],
);

return (
<View style={[style, styles.overflowHidden]}>
<View style={[styles.w100, styles.h100, styles.alignItemsCenter, styles.justifyContentCenter]}>{enabled && thumbnail}</View>
</View>
);
}

PDFThumbnail.displayName = 'PDFThumbnail';
export default React.memo(PDFThumbnail);
20 changes: 20 additions & 0 deletions src/components/PDFThumbnail/types.ts
Original file line number Diff line number Diff line change
@@ -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<ViewStyle>;

/** 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;
10 changes: 9 additions & 1 deletion src/components/ReportActionItem/ReportActionItemImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,7 +87,7 @@ function ReportActionItemImage({
/>
</View>
);
} else if (thumbnail && !isLocalFile && !Str.isPDF(attachmentModalSource as string)) {
} else if (thumbnail && !isLocalFile) {
receiptImageComponent = (
<ThumbnailImage
previewSourceURL={thumbnailSource}
Expand All @@ -97,6 +98,13 @@ function ReportActionItemImage({
shouldDynamicallyResize={false}
/>
);
} else if (isLocalFile && filename && Str.isPDF(filename) && typeof attachmentModalSource === 'string') {
receiptImageComponent = (
<PDFThumbnail
previewSourceURL={attachmentModalSource}
style={[styles.w100, styles.h100]}
/>
);
} else {
receiptImageComponent = (
<Image
Expand Down
3 changes: 2 additions & 1 deletion src/components/ReportActionItem/ReportActionItemImages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report

return (
<View style={[styles.reportActionItemImages, hoverStyle, heightStyle]}>
{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.
Expand All @@ -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}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion src/libs/ReceiptUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,25 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry<Transaction>, 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};
}

if (isReceiptImage) {
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) {
Expand Down

0 comments on commit 6cb4b24

Please sign in to comment.