Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add PDFThumbnail to preview PDF receipt #35255

Merged
merged 13 commits into from
Mar 5, 2024
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 @@ -844,7 +853,36 @@ 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)}
isClickable={false}
/>
) : (
<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 @@ -869,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 @@ -909,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
4 changes: 4 additions & 0 deletions src/components/PDFThumbnail/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* These style overrides are necessary so that the PDF thumbnail shows default pointer when it's not clickable */
.react-pdf__Thumbnail--notClickable {
cursor: default;
}
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);
53 changes: 53 additions & 0 deletions src/components/PDFThumbnail/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @ts-expect-error - We use the same method as PDFView to import the worker
import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker';
eh2077 marked this conversation as resolved.
Show resolved Hide resolved
import React, {useEffect, 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 './index.css';
import type PDFThumbnailProps from './types';

function PDFThumbnail({previewSourceURL, style, isAuthTokenRequired = false, enabled = true, onPassword = () => {}, isClickable = true}: PDFThumbnailProps) {
const styles = useThemeStyles();

useEffect(() => {
const workerURL = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'}));
if (pdfjs.GlobalWorkerOptions.workerSrc !== workerURL) {
pdfjs.GlobalWorkerOptions.workerSrc = workerURL;
}
}, []);
eh2077 marked this conversation as resolved.
Show resolved Hide resolved

const thumbnail = useMemo(
() => (
<Document
loading={<FullScreenLoadingIndicator />}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming for this issue #39356 , since we are not passing the error prop to the Document, the Document will display the message 'Failed to load PDF file.' text in case of an error. This, however, causes a UI issue in the dark theme of our app. we should have passed a custom error component here to avoid UI issue.

file={isAuthTokenRequired ? addEncryptedAuthTokenToURL(previewSourceURL) : previewSourceURL}
options={{
cMapUrl: 'cmaps/',
cMapPacked: true,
}}
externalLinkTarget="_blank"
onPassword={() => {
onPassword();
}}
>
<Thumbnail
pageIndex={0}
className={isClickable ? '' : 'react-pdf__Thumbnail--notClickable'}
eh2077 marked this conversation as resolved.
Show resolved Hide resolved
/>
</Document>
),
[isAuthTokenRequired, previewSourceURL, onPassword, isClickable],
);

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);
23 changes: 23 additions & 0 deletions src/components/PDFThumbnail/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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;

/** Whether the PDF thumbnail is clickable */
isClickable?: boolean;
eh2077 marked this conversation as resolved.
Show resolved Hide resolved
};

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 @@ -78,7 +79,7 @@ function ReportActionItemImage({
/>
</View>
);
} else if (thumbnail && !isLocalFile && !Str.isPDF(imageSource as string)) {
} else if (thumbnail && !isLocalFile) {
receiptImageComponent = (
<ThumbnailImage
previewSourceURL={thumbnailSource}
Expand All @@ -89,6 +90,13 @@ function ReportActionItemImage({
shouldDynamicallyResize={false}
/>
);
} else if (isLocalFile && filename && Str.isPDF(filename) && typeof imageSource === 'string') {
receiptImageComponent = (
<PDFThumbnail
previewSourceURL={imageSource}
style={[styles.w100, styles.h100]}
/>
);
} else {
receiptImageComponent = (
<Image
Expand Down
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 password no son compatibles',
eh2077 marked this conversation as resolved.
Show resolved Hide resolved
},
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
Loading