diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fdf78ffbe9c1..622a42158f45 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1628,6 +1628,15 @@ const CONST = { // Remote PDFs are rendered at this multiple of their on-screen size so the magnified // view shows real pixels instead of an upscaled low-resolution raster. HOVER_ZOOM_SCALE: 2.5, + + // react-fast-pdf pads each page by this much. The single-page PDF layer matches it to stay framed the same. + PDF_PAGE_BORDER: 9, + + // PDF page numbers are 1-indexed. This is the page a receipt opens on, and the page the static badge always shows. + FIRST_PDF_PAGE: 1, + + // Elements with this data attribute pause the hover zoom while the pointer is over them + HOVER_ZOOM_EXCLUDED_ELEMENT: 'receipt-hover-zoom-excluded', }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { @@ -9585,6 +9594,8 @@ const CONST = { }, RECEIPT: { IMAGE: 'Receipt-Image', + PREVIOUS_PAGE_BUTTON: 'Receipt-PreviousPageButton', + NEXT_PAGE_BUTTON: 'Receipt-NextPageButton', ENLARGE_BUTTON: 'Receipt-EnlargeButton', ADD_ATTACHMENT_BUTTON: 'Receipt-AddAttachmentButton', }, diff --git a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx index 9510eeac16f2..c8cd3278e4f4 100644 --- a/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail.tsx @@ -226,7 +226,7 @@ function ConfirmationReceiptThumbnail({ ))} {shouldShowReceiptPageCount && ( )} diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index e3866ba18b31..28d8ad218ca0 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -16,6 +16,13 @@ import PDFThumbnailError from './PDFThumbnailError'; pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); +// Use a root-relative URL so the CMap files (needed to render non-Latin fonts) resolve against +// the server root instead of the current deep route. Shared by every react-pdf in the app. +const PDF_DOCUMENT_OPTIONS = { + cMapUrl: '/cmaps/', + cMapPacked: true, +}; + function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLoadError, onLoadSuccess}: PDFThumbnailProps) { const styles = useThemeStyles(); const [failedToLoad, setFailedToLoad] = useState(false); @@ -25,12 +32,7 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLo } file={previewSourceURL} - options={{ - // Use a root-relative URL so the CMap files (needed to render non-Latin fonts) - // resolve against the server root instead of the current deep route. - cMapUrl: '/cmaps/', - cMapPacked: true, - }} + options={PDF_DOCUMENT_OPTIONS} externalLinkTarget="_blank" onPassword={onPassword} onLoad={() => { @@ -83,5 +85,5 @@ export default React.memo(PDFThumbnail); // Re-exported so other PDF-rendering components reuse this file's worker setup // instead of importing pdfjs-dist/react-pdf directly. -export {Document, Thumbnail}; +export {Document, Thumbnail, PDF_DOCUMENT_OPTIONS}; export type {PDFDocumentProxy}; diff --git a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts index bd8da21a3e3c..b8b27b05732f 100644 --- a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts +++ b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts @@ -1,5 +1,7 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; + import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; @@ -56,6 +58,14 @@ function useReceiptHoverZoom({isEnabled, scale, hoverContainerRef}: UseReceiptHo setIsHovering(false); }; const updateZoomOrigin = (event: PointerEvent) => { + // The whole point of page navigation is to see the page you just flipped to, so zoom-tracking the + // cursor over the controls themselves (and having to move away to release it) defeats that purpose. + if (event.target instanceof Element && event.target.closest(`[data-${CONST.RECEIPT.HOVER_ZOOM_EXCLUDED_ELEMENT}]`)) { + if (hovering) { + endZoom(); + } + return; + } if (!bounds) { bounds = target.getBoundingClientRect(); } diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index a14eac6fcd87..7b2e13385db0 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -60,6 +60,7 @@ import { } from '@libs/ReportUtils'; import trackExpenseCreationError from '@libs/telemetry/trackExpenseCreationError'; import { + canOverlayReceiptPDF, didReceiptScanSucceed as didReceiptScanSucceedTransactionUtils, hasEReceipt, hasPendingDistanceReceiptRegeneration, @@ -85,7 +86,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {ReceiptError, TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; +import type {ReceiptError, ReceiptSource, TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -104,6 +105,7 @@ import {View} from 'react-native'; import HoveredDistanceEReceipt from './HoveredDistanceEReceipt'; import {isElementHovered, resetButtonHoverState} from './receiptHoverUtils'; +import ReceiptPageNavigator from './ReceiptPageNavigator'; import ReportActionItemImage from './ReportActionItemImage'; type MoneyRequestReceiptViewProps = { @@ -242,6 +244,14 @@ function MoneyRequestReceiptView({ const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source; const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource); + // The PDF page layer the pill controls, keyed to the receipt source it belongs to. displayedReceiptSource changes + // the instant the receipt's underlying file changes (a scan finishing, or the receipt being replaced), so + // comparing it against `source` resets stale page/status state in the same render, before anything re-fetches. + const [receiptPDFPagesState, setReceiptPDFPagesState] = useState<{source?: ReceiptSource; page: number; status: 'loading' | 'loaded' | 'failed'}>({ + page: CONST.RECEIPT.FIRST_PDF_PAGE, + status: 'loading', + }); + useEffect(() => { if (!displayedReceiptSource || prevDisplayedReceiptSource === displayedReceiptSource) { return; @@ -616,8 +626,23 @@ function MoneyRequestReceiptView({ // Expanding only opens the receipt to look at, so it asks for none of the permission above const canExpandReceipt = hasReceipt && !isLoading && !mergeTransactionID && !readonly && canInteractWithReport; - // Map distance receipts render as an e-receipt card, so their stored PDF page count is never shown. + // Show the count badge only after a multi-page PDF receipt loads. Map distance receipts render as an e-receipt card, so their stored PDF page count is never shown. const shouldShowReceiptPageCount = receiptPageCount > 1 && Str.isPDF(receiptURIs?.filename ?? '') && !isLoading && !isMapDistanceRequest; + + // Pages can only be flipped where ReportActionItemImage renders the real PDF over the thumbnail (hover-capable + // devices, using the same eligibility check ReportActionItemImage applies to its own overlay). Elsewhere only a + // page 1 thumbnail exists, so the static count badge stays. + const canFlipReceiptPages = shouldShowReceiptPageCount && canZoomReceipt && deviceHasHoverSupport && canOverlayReceiptPDF(displayedTransaction); + + const isCurrentReceiptPDFPagesState = receiptPDFPagesState.source === displayedReceiptSource; + const receiptPage = isCurrentReceiptPDFPagesState ? Math.min(receiptPDFPagesState.page, receiptPageCount || CONST.RECEIPT.FIRST_PDF_PAGE) : CONST.RECEIPT.FIRST_PDF_PAGE; + + // Shown before the PDF loads, with its buttons disabled, so the badge doesn't change size once it can flip. + // A PDF that fails to load leaves only the page 1 thumbnail, so the static badge comes back. + const hasReceiptPDFFailed = isCurrentReceiptPDFPagesState && receiptPDFPagesState.status === 'failed'; + const shouldShowReceiptPageNavigator = canFlipReceiptPages && !hasReceiptPDFFailed; + const isReceiptPDFLoading = !isCurrentReceiptPDFPagesState || receiptPDFPagesState.status !== 'loaded'; + const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; const receiptAuditMessagesRow = ( @@ -761,6 +786,15 @@ function MoneyRequestReceiptView({ shouldUseThumbnailImage={!fillSpace} shouldUseFullHeight={fillSpace} canZoomReceipt={canZoomReceipt} + pdfPage={canFlipReceiptPages ? receiptPage : undefined} + onPDFLoadSuccess={() => + setReceiptPDFPagesState((prev) => ({ + source: displayedReceiptSource, + page: prev.source === displayedReceiptSource ? prev.page : CONST.RECEIPT.FIRST_PDF_PAGE, + status: 'loaded', + })) + } + onPDFLoadFailure={() => setReceiptPDFPagesState({source: displayedReceiptSource, page: CONST.RECEIPT.FIRST_PDF_PAGE, status: 'failed'})} thumbnail={receiptURIs?.thumbnail} fileExtension={receiptURIs?.fileExtension} isThumbnail={receiptURIs?.isThumbnail} @@ -780,9 +814,17 @@ function MoneyRequestReceiptView({ )} - {shouldShowReceiptPageCount && ( + {shouldShowReceiptPageNavigator && ( + setReceiptPDFPagesState({source: displayedReceiptSource, page, status: 'loaded'})} + /> + )} + {shouldShowReceiptPageCount && !shouldShowReceiptPageNavigator && ( )} diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6e64cd2d4184..df6582ee17b5 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -1,3 +1,5 @@ +import {Document, PDF_DOCUMENT_OPTIONS, Thumbnail} from '@components/PDFThumbnail'; + import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,8 +19,8 @@ import {View} from 'react-native'; import type ReceiptPDFOverlayProps from './types'; const oversamplePercent = `${CONST.RECEIPT.HOVER_ZOOM_SCALE * 100}%`; -const oversampleContainerStyle = { - position: 'absolute' as const, +const oversampleContainerStyle: React.CSSProperties = { + position: 'absolute', top: 0, left: 0, width: oversamplePercent, @@ -27,12 +29,83 @@ const oversampleContainerStyle = { transformOrigin: 'top left', }; -function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure}: ReceiptPDFOverlayProps) { +const pageContainerStyle: React.CSSProperties = { + position: 'absolute', + top: CONST.RECEIPT.PDF_PAGE_BORDER, + left: 0, +}; + +type CanvasLimits = { + maxCanvasWidth?: number; + maxCanvasHeight?: number; + maxCanvasArea?: number; +}; + +/** + * Lowers the render resolution when the default one would exceed the browser's canvas limits, which otherwise + * leaves the canvas blank on very tall pages. Mirrors PDFPreviewer's calculation. + */ +function getDevicePixelRatio(width: number, height: number, {maxCanvasWidth, maxCanvasHeight, maxCanvasArea}: CanvasLimits): number | undefined { + if (!maxCanvasWidth || !maxCanvasHeight || !maxCanvasArea || !width || !height) { + return undefined; + } + const ratio = Math.min(maxCanvasHeight / height, maxCanvasWidth / width, Math.sqrt(maxCanvasArea / (width * height))); + return ratio > window.devicePixelRatio ? undefined : ratio; +} + +type ReceiptPDFPageProps = CanvasLimits & { + /** 1-indexed page to render */ + pageNumber: number; + + /** Width to render the page at */ + width: number; + + /** Called once the page has been drawn */ + onRenderSuccess?: () => void; + + /** Called when the page fails to load or draw */ + onError: () => void; +}; + +function ReceiptPDFPage({pageNumber, width, onRenderSuccess, onError, ...canvasLimits}: ReceiptPDFPageProps) { + const [aspectRatio, setAspectRatio] = useState(); + const devicePixelRatio = aspectRatio ? getDevicePixelRatio(width, width * aspectRatio, canvasLimits) : undefined; + + return ( +
+ setAspectRatio(originalHeight / originalWidth)} + onRenderSuccess={onRenderSuccess} + onLoadError={onError} + onRenderError={onError} + /> +
+ ); +} + +function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure, onLoadSuccess, page}: ReceiptPDFOverlayProps) { const styles = useThemeStyles(); const [session] = useOnyx(ONYXKEYS.SESSION); const [maxCanvasArea] = useOnyx(ONYXKEYS.MAX_CANVAS_AREA); const [maxCanvasHeight] = useOnyx(ONYXKEYS.MAX_CANVAS_HEIGHT); const [maxCanvasWidth] = useOnyx(ONYXKEYS.MAX_CANVAS_WIDTH); + const [oversampleWidth, setOversampleWidth] = useState(0); + const shouldRenderOnlyCurrentPage = page !== undefined; + + // A ResizeObserver reports the size as soon as it starts observing, unlike onLayout added after mount + const observeOversampleWidth = (container: HTMLDivElement | null) => { + if (!container || !shouldRenderOnlyCurrentPage) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => setOversampleWidth(entry.contentRect.width)); + observer.observe(container); + return () => observer.disconnect(); + }; useEffect(() => { // Verify the per-browser canvas limits have been calculated, mirroring PDFView. @@ -55,11 +128,25 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure const [failedURL, setFailedURL] = useState(null); const hasFailed = failedURL !== null && failedURL === fileURL; + // The page last drawn stays on screen while the next one renders, so the thumbnail underneath never flashes + // through. Keyed to the URL so a replaced receipt doesn't briefly show the old page number from the new file. + const [renderedPageState, setRenderedPageState] = useState({fileURL, page}); + const renderedPage = renderedPageState.fileURL === fileURL ? renderedPageState.page : undefined; + // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. if (hasFailed) { return null; } + const handleLoadError = () => { + setFailedURL(fileURL); + onLoadFailure?.(); + }; + + // Matches PDFPreviewer's page width on small screens: the full width of the oversized container plus its border + const pageWidth = oversampleWidth + CONST.RECEIPT.PDF_PAGE_BORDER * 2; + const canvasLimits = {maxCanvasWidth, maxCanvasHeight, maxCanvasArea}; + return ( {/*
is required here because `transformOrigin` is a CSS-only property unsupported by React Native's View. The oversample-then-scale technique relies on it to anchor the downscale to the top-left corner. */} -
- { - setFailedURL(fileURL); - onLoadFailure?.(); - }} - /> +
+ {!shouldRenderOnlyCurrentPage ? ( + + ) : ( + // Only the visible page is loaded. PDFPreviewer measures every page before showing any, which takes + // tens of seconds on long PDFs and isn't needed when one page is shown at a time. + oversampleWidth > 0 && ( + + {renderedPage !== undefined && ( + + )} + {page !== renderedPage && ( + setRenderedPageState({fileURL, page})} + onError={handleLoadError} + {...canvasLimits} + /> + )} + + ) + )}
); diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts index 4e88be683bc9..a1b8a7a24bc3 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/types.ts @@ -7,6 +7,12 @@ type ReceiptPDFOverlayProps = { /** Called when the PDF fails to load so the caller can fall back to the thumbnail */ onLoadFailure?: () => void; + + /** Called once the PDF has loaded and its pages can be shown. Only fires when `page` is set. */ + onLoadSuccess?: () => void; + + /** 1-indexed page to show at the top of the overlay */ + page?: number; }; export default ReceiptPDFOverlayProps; diff --git a/src/components/ReportActionItem/ReceiptPageNavigator.tsx b/src/components/ReportActionItem/ReceiptPageNavigator.tsx new file mode 100644 index 000000000000..00df1e8b9eb5 --- /dev/null +++ b/src/components/ReportActionItem/ReceiptPageNavigator.tsx @@ -0,0 +1,131 @@ +import Icon from '@components/Icon'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; + +import useHover from '@hooks/useHover'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import React from 'react'; +import {View} from 'react-native'; + +type PageButtonProps = { + /** Arrow icon to show */ + icon: IconAsset; + + /** Tooltip and accessibility label */ + label: string; + + /** Whether the button can be pressed */ + isDisabled: boolean; + + /** Called when the button is pressed */ + onPress: () => void; + + /** Label used to identify the button in Sentry */ + sentryLabel: string; +}; + +function PageButton({icon, label, isDisabled, onPress, sentryLabel}: PageButtonProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {hovered, bind} = useHover(); + + // Static: icon color. Hover: text color, to read as more prominent/active. Disabled: icon color, dimmed. + const iconFill = !isDisabled && hovered ? theme.text : theme.icon; + + return ( + + {/* A real tap target around the glyph: react-native-web's Pressable doesn't support hitSlop */} + + + + + ); +} + +type ReceiptPageNavigatorProps = { + /** The 1-indexed page currently shown */ + page: number; + + /** Total number of pages in the receipt */ + pageCount: number; + + /** Whether the PDF is still loading, which keeps both buttons disabled */ + isLoading: boolean; + + /** Called with the page to show when a navigation button is pressed */ + onChangePage: (page: number) => void; +}; + +/** "Page X of N" pill with previous/next buttons for flipping through a multi-page PDF receipt. */ +function ReceiptPageNavigator({page, pageCount, isLoading, onChangePage}: ReceiptPageNavigatorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'BackArrow']); + const labelStyle = [styles.badgeText, styles.textStrong, styles.badgeDefaultText, styles.textNoWrap]; + + return ( + + onChangePage(page - 1)} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.PREVIOUS_PAGE_BUTTON} + /> + + {/* Hidden copy of the widest label (the last page) that sizes the pill, so it neither clips a long translation nor resizes between pages. */} + + {translate('receipt.pageCount', {page: pageCount, pageCount})} + + + {translate('receipt.pageCount', {page, pageCount})} + + + = pageCount} + onPress={() => onChangePage(page + 1)} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.NEXT_PAGE_BUTTON} + /> + + ); +} + +export default ReceiptPageNavigator; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 97bf88d70d18..cf74b5fcf1f7 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -15,12 +15,12 @@ import Navigation from '@libs/Navigation/Navigation'; import ReceiptStorage from '@libs/ReceiptStorage'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import { + canOverlayReceiptPDF, hasEReceipt, hasPendingDistanceReceiptRegeneration, hasReceiptSource, isDistanceRequest, isManualDistanceRequest, - isMapBasedDistanceRequest, isPerDiemRequest, } from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; @@ -98,6 +98,15 @@ type ReportActionItemImageProps = { /** Whether the receipt can be hover-zoomed. When true, remote PDFs render the actual PDF on top of the thumbnail so magnification stays sharp (web only). */ canZoomReceipt?: boolean; + /** 1-indexed page of a multi-page PDF to show in the high-res overlay */ + pdfPage?: number; + + /** Called once the high-res PDF overlay has loaded its pages. Only fires when `pdfPage` is set. */ + onPDFLoadSuccess?: () => void; + + /** Called when the high-res PDF overlay fails, leaving only the thumbnail */ + onPDFLoadFailure?: () => void; + /** Callback to be called when the image loads */ onLoad?: (event?: {nativeEvent: {width: number; height: number}}) => void; @@ -130,6 +139,9 @@ function ReportActionItemImage({ report: reportProp, shouldUseThumbnailImage, canZoomReceipt = false, + pdfPage, + onPDFLoadSuccess, + onPDFLoadFailure, onLoad, onLoadFailure, }: ReportActionItemImageProps) { @@ -223,11 +235,10 @@ function ReportActionItemImage({ // A remote PDF is shown as the server's low-resolution JPG thumbnail, which blurs when hover-zoomed. // Where zooming is available (web only), render the actual PDF on top of the thumbnail so the magnified // view stays sharp. The thumbnail stays underneath as an instant preview and as a fallback if the PDF fails. - // Map/route distance requests are excluded: their hover overlay is a DistanceEReceipt card, not the PDF. - // isMapBasedDistanceRequest covers map, GPS, and manual-typed transactions that still carry waypoints. + // Page navigation also needs the real PDF for local files, whose thumbnail only shows page 1. const pdfSourceURL = typeof originalImageSource === 'string' && !!originalImageSource ? originalImageSource : undefined; - const isRemotePDF = !!isPDF && !effectiveIsLocalFile && !isEReceipt && !isMapBasedDistanceRequest(transaction) && !!pdfSourceURL; - const shouldOverlayHighResPDF = canZoomReceipt && isRemotePDF && hasHoverSupport(); + const canOverlayPDF = !!isPDF && canOverlayReceiptPDF(transaction) && !!pdfSourceURL; + const shouldOverlayHighResPDF = canZoomReceipt && canOverlayPDF && hasHoverSupport() && (!effectiveIsLocalFile || pdfPage !== undefined); const renderReceiptContent = (receiptImage: React.ReactNode) => shouldOverlayHighResPDF ? ( @@ -239,7 +250,13 @@ function ReportActionItemImage({ > { + onLoadFailure?.(); + onPDFLoadFailure?.(); + }} + page={pdfPage} + onLoadSuccess={onPDFLoadSuccess} /> diff --git a/src/languages/de.ts b/src/languages/de.ts index 872300374feb..8e502ef03051 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1328,7 +1328,7 @@ const translations: TranslationDeepObject = { phrase1: 'Beleg hinzufügen', phrase2: 'oder ziehe eine hierher und lege sie ab', }, - pageCount: ({pageCount}: {pageCount: number}) => `Seite 1 von ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Seite ${page} von ${pageCount}`, }, quickAction: { scanReceipt: 'Beleg scannen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1f8811310519..0a7c39fbee5f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1373,7 +1373,7 @@ const translations: TranslationDeepObject = { phrase1: 'Προσθήκη απόδειξης', phrase2: 'ή σύρετε και αποθέστε ένα εδώ', }, - pageCount: ({pageCount}: {pageCount: number}) => `Σελίδα 1 από ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Σελίδα ${page} από ${pageCount}`, }, quickAction: { scanReceipt: 'Σαρώστε απόδειξη', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2df7a9ab4af1..35fe2d47e89d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1453,7 +1453,7 @@ const translations = { addAdditionalReceipt: 'Add additional receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", crop: 'Crop', - pageCount: ({pageCount}: {pageCount: number}) => `Page 1 of ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Page ${page} of ${pageCount}`, addAReceipt: { phrase1: 'Add a receipt', phrase2: 'or drag and drop one here', diff --git a/src/languages/es.ts b/src/languages/es.ts index aa408a7da996..ba8ef24f2709 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1318,7 +1318,7 @@ const translations: TranslationDeepObject = { addAdditionalReceipt: 'Añadir recibo adicional', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el importe.', crop: 'Recortar', - pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Página ${page} de ${pageCount}`, addAReceipt: { phrase1: 'Añade un recibo', phrase2: 'o arrastra y suelta uno aquí', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5fc64c2ebe76..6f8d2a5f4bf1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1333,7 +1333,7 @@ const translations: TranslationDeepObject = { phrase1: 'Ajouter un reçu', phrase2: 'ou faites-en glisser un ici', }, - pageCount: ({pageCount}: {pageCount: number}) => `Page 1 sur ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Page ${page} sur ${pageCount}`, }, quickAction: { scanReceipt: 'Scanner le reçu', diff --git a/src/languages/it.ts b/src/languages/it.ts index 45db25154298..efb93bcc1fa6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1325,7 +1325,7 @@ const translations: TranslationDeepObject = { phrase1: 'Aggiungi una ricevuta', phrase2: 'o trascinalo qui', }, - pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 di ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Pagina ${page} di ${pageCount}`, }, quickAction: { scanReceipt: 'Scansiona ricevuta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c5aa93b9171d..9f220de99dcd 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { phrase1: '領収書を追加', phrase2: 'または、ここにファイルをドラッグ&ドロップしてください', }, - pageCount: ({pageCount}: {pageCount: number}) => `${pageCount} ページ中 1 ページ`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `${pageCount} ページ中 ${page} ページ目`, }, quickAction: { scanReceipt: 'レシートをスキャン', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 721de86a5802..38c77f1c001e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1326,7 +1326,7 @@ const translations: TranslationDeepObject = { phrase1: 'Voeg een bon toe', phrase2: 'of sleep ze hier naartoe', }, - pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 van ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Pagina ${page} van ${pageCount}`, }, quickAction: { scanReceipt: 'Bon scannen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 643b9ff57ebb..e2435fdea6db 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1359,7 +1359,7 @@ const translations: TranslationDeepObject = { phrase1: 'Dodaj paragon', phrase2: 'lub przeciągnij i upuść tutaj', }, - pageCount: ({pageCount}: {pageCount: number}) => `Strona 1 z ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Strona ${page} z ${pageCount}`, }, quickAction: { scanReceipt: 'Zeskanuj paragon', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 69b1709e95fb..40d0420f05a7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1325,7 +1325,7 @@ const translations: TranslationDeepObject = { phrase1: 'Adicionar um recibo', phrase2: 'ou arraste e solte um aqui', }, - pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `Página ${page} de ${pageCount}`, }, quickAction: { scanReceipt: 'Digitalizar recibo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 25b4c533a608..0ece277e6a92 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1272,7 +1272,7 @@ const translations: TranslationDeepObject = { phrase1: '添加收据', phrase2: '或将文件拖放到此处', }, - pageCount: ({pageCount}: {pageCount: number}) => `第 1 页,共 ${pageCount} 页`, + pageCount: ({page, pageCount}: {page: number; pageCount: number}) => `第 ${page} 页,共 ${pageCount} 页`, }, quickAction: { scanReceipt: '扫描收据', diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 5d204cf44bd8..5e438c16c1fd 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -416,6 +416,15 @@ function hasReceiptSource(transaction: OnyxInputOrEntry): boolean { return !!transaction?.receipt?.source; } +/** + * Whether a receipt can be shown as a real PDF rendered over its thumbnail, rather than just the static thumbnail. + * E-receipts have no underlying PDF to render, and map-based distance receipts show the map image instead. + */ +function canOverlayReceiptPDF(transaction: OnyxEntry): boolean { + const isEReceipt = !hasReceiptSource(transaction) && hasEReceipt(transaction); + return !isEReceipt && !isMapBasedDistanceRequest(transaction); +} + /** Check if odometer image has the source file */ function hasOdometerImageSource(transaction: OnyxInputOrEntry, imageType: string): boolean { const odometerImage = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? transaction?.comment?.odometerStartImage : transaction?.comment?.odometerEndImage; @@ -1946,6 +1955,7 @@ export { getDeleteExpenseTitle, getDeleteConfirmationPrompt, hasReceiptSource, + canOverlayReceiptPDF, hasOdometerImageSource, shouldShowAttendees, getAllSortedTransactions, diff --git a/src/styles/index.ts b/src/styles/index.ts index 2eac028b4aa8..2724c82d7986 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -311,6 +311,13 @@ const compactPopoverMenuItemBaseStyle = { const COMPOSER_SIZE_BUTTON_SIZE = 40; const COMPOSER_SIZE_BUTTON_MARGIN = 3; +// Shared by receiptPageCountBadge and receiptPageNavigator so the two can't drift apart. +const RECEIPT_PAGE_BADGE_POSITION = { + position: 'absolute' as const, + bottom: 12, + left: 12, +}; + const staticStyles = (theme: ThemeColors) => StyleSheet.create({ ...spacing, @@ -4164,12 +4171,41 @@ const staticStyles = (theme: ThemeColors) => // Float above the receipt to avoid zooming with it. receiptPageCountBadge: { - position: 'absolute', - bottom: 12, - left: 12, + ...RECEIPT_PAGE_BADGE_POSITION, marginLeft: 0, }, + // The pill grows with its label, so longer translations are never clipped and the buttons' focus outlines stay visible. + receiptPageNavigator: { + ...RECEIPT_PAGE_BADGE_POSITION, + flexDirection: 'row', + alignItems: 'center', + minHeight: variables.componentSizeSmall, + paddingHorizontal: 4, + borderRadius: variables.componentBorderRadiusRounded, + backgroundColor: theme.badgeDefaultBG, + }, + + receiptPageNavigatorLabelContainer: { + alignItems: 'center', + justifyContent: 'center', + }, + + // Overlays the hidden widest label that sizes the pill + receiptPageNavigatorCurrentLabel: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + + // A real tap target around the small arrow glyph; react-native-web's Pressable doesn't support hitSlop + receiptPageNavigatorButton: { + height: 24, + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, + reuseRouteCard: { ...spacing.mh5, ...spacing.mb3, diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index ae4034b720ad..76730328f09c 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -53,11 +53,36 @@ jest.mock( }, ); -const mockReceiptImage = {shouldCompleteLoad: true}; +// The real PDF overlay reports its own load completion on its own timing, independent of the generic image load +// (onLoad) and of re-renders. Tests drive that timing explicitly via reportPDFLoadSuccess/reportPDFLoadFailure, +// instead of this mock inferring "loaded" from a prop changing, which can't represent a load still in flight. +const mockReceiptImage: {shouldCompleteLoad: boolean; lastPDFPage?: number; reportPDFLoadSuccess?: () => void; reportPDFLoadFailure?: () => void} = { + shouldCompleteLoad: true, +}; jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { const {useEffect} = jest.requireActual('react'); - function MockReportActionItemImage({onLoad}: {onLoad?: () => void}) { + function MockReportActionItemImage({ + onLoad, + onPDFLoadSuccess, + onPDFLoadFailure, + pdfPage, + }: { + onLoad?: () => void; + onPDFLoadSuccess?: () => void; + onPDFLoadFailure?: () => void; + pdfPage?: number; + }) { + useEffect(() => { + mockReceiptImage.lastPDFPage = pdfPage; + if (pdfPage === undefined) { + mockReceiptImage.reportPDFLoadSuccess = undefined; + mockReceiptImage.reportPDFLoadFailure = undefined; + return; + } + mockReceiptImage.reportPDFLoadSuccess = onPDFLoadSuccess; + mockReceiptImage.reportPDFLoadFailure = onPDFLoadFailure; + }, [pdfPage, onPDFLoadSuccess, onPDFLoadFailure]); useEffect(() => { if (!mockReceiptImage.shouldCompleteLoad) { return; @@ -69,6 +94,18 @@ jest.mock('@components/ReportActionItem/ReportActionItemImage', () => { return MockReportActionItemImage; }); +const mockDeviceCapabilities = {hasHoverSupport: false}; + +jest.mock('@libs/DeviceCapabilities', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/DeviceCapabilities'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + hasHoverSupport: () => mockDeviceCapabilities.hasHoverSupport, + }; +}); + jest.mock('@src/languages/IntlStore', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const en: Record = require('@src/languages/en').default; @@ -266,6 +303,10 @@ describe('MoneyRequestReceiptView', () => { beforeEach(async () => { mockReceiptImage.shouldCompleteLoad = true; + mockReceiptImage.lastPDFPage = undefined; + mockReceiptImage.reportPDFLoadSuccess = undefined; + mockReceiptImage.reportPDFLoadFailure = undefined; + mockDeviceCapabilities.hasHoverSupport = false; jest.clearAllMocks(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${TEST_PARENT_REPORT_ID}`, { @@ -327,7 +368,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); it('shows the page count for a multi-page PDF receipt', async () => { @@ -343,7 +384,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeTruthy(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); }); it('does not show the page count for a single page PDF receipt', async () => { @@ -362,7 +403,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 1}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 1}))).toBeNull(); }); // An optimistic merge that swaps a PDF for an image can leave the PDF's count behind, so the @@ -383,7 +424,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); // An image receipt carries no page count at all, which is also what a PDF uploaded before the @@ -401,7 +442,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); // The regenerated receipt makes the old count stale @@ -422,7 +463,140 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); + }); + }); + + describe('receipt page navigation', () => { + const renderMultiPagePDFReceipt = async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithMultiPagePDFReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + }; + + it('flips pages once the PDF has loaded on a hover-capable device', async () => { + // Given a multi-page PDF receipt on a device that supports hover, with its PDF already loaded + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(1); + + // When the user clicks next + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + + // Then the pill's label and the page requested from the PDF overlay both move to page 2 + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(2); + + // When the user clicks previous + fireEvent.press(screen.getByLabelText(translateLocal('common.previous'))); + await waitForBatchedUpdatesWithAct(); + + // Then it goes back to page 1 + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(mockReceiptImage.lastPDFPage).toBe(1); + }); + + it('clamps the current page if the page count drops below it', async () => { + // Given the user has flipped to the last page of a 3-page receipt + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 3, pageCount: 3}))).toBeTruthy(); + + // When the same receipt's reported page count drops to 2 (e.g. a later scan corrected it) + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, {receipt: {pageCount: 2}}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the pill clamps down to the new last page, rather than showing "Page 3 of 2" + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 2}))).toBeTruthy(); + }); + + it('disables the page buttons again when the receipt is replaced by a same-named file still loading', async () => { + // Given the user has flipped to page 2 of a loaded receipt named receipt.pdf + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + act(() => mockReceiptImage.reportPDFLoadSuccess?.()); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByLabelText(translateLocal('common.next'))); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 2, pageCount: 3}))).toBeTruthy(); + + // When that receipt is replaced by a new upload with the same filename (a common case: "receipt.pdf" + // uploaded twice) and a different source, before the new file's own load has been reported + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + receipt: {...transactionWithMultiPagePDFReceipt.receipt, source: 'https://example.com/receipt-replacement.pdf'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the pill resets to page 1 with both buttons disabled, instead of staying on the old page 2 as if + // the new file were already loaded + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('common.next'))).toBeDisabled(); + expect(screen.getByLabelText(translateLocal('common.previous'))).toBeDisabled(); + }); + + it('disables the page buttons while the PDF is still loading', async () => { + // Given a multi-page PDF receipt on a hover-capable device, whose PDF has not loaded yet + mockDeviceCapabilities.hasHoverSupport = true; + + // When the receipt view renders + await renderMultiPagePDFReceipt(); + + // Then the pill shows right away (so it doesn't change size once it can flip), but both buttons are + // disabled, because until the PDF loads only the page 1 thumbnail exists and there is nothing to flip to + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('common.next'))).toBeDisabled(); + expect(screen.getByLabelText(translateLocal('common.previous'))).toBeDisabled(); + }); + + it('falls back to the static badge when the PDF fails to load', async () => { + // Given a multi-page PDF receipt on a hover-capable device + mockDeviceCapabilities.hasHoverSupport = true; + await renderMultiPagePDFReceipt(); + + // When its PDF fails to load + act(() => mockReceiptImage.reportPDFLoadFailure?.()); + await waitForBatchedUpdatesWithAct(); + + // Then the view falls back to the plain static badge instead of a pill stuck disabled forever, because + // only the page 1 thumbnail exists and there is nothing to flip to + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); + }); + + it('keeps the static badge on devices without hover support', async () => { + // Given a multi-page PDF receipt on a device without hover support + + // When the receipt view renders + await renderMultiPagePDFReceipt(); + + // Then the static badge shows instead of the pill, because without hover support the real PDF is never + // drawn over the thumbnail, so there is no page to flip to (and nothing to report as loaded) + expect(screen.getByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeTruthy(); + expect(screen.queryByLabelText(translateLocal('common.next'))).toBeNull(); + expect(mockReceiptImage.lastPDFPage).toBeUndefined(); }); it('does not show the page count for a settled map distance receipt', async () => { @@ -441,7 +615,7 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + expect(screen.queryByText(translateLocal('receipt.pageCount', {page: 1, pageCount: 3}))).toBeNull(); }); }); diff --git a/tests/ui/components/ReceiptPDFOverlayTest.tsx b/tests/ui/components/ReceiptPDFOverlayTest.tsx new file mode 100644 index 000000000000..ec389d0e4054 --- /dev/null +++ b/tests/ui/components/ReceiptPDFOverlayTest.tsx @@ -0,0 +1,187 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import type ReceiptPDFOverlayProps from '@components/ReportActionItem/ReceiptPDFOverlay/types'; + +import CONST from '@src/CONST'; + +import type {ComponentType, ReactNode} from 'react'; + +import React from 'react'; + +type MockThumbnailProps = {pageNumber: number; width: number; onRenderSuccess?: () => void; onRenderError?: () => void}; + +const mockPDFPreviewer = jest.fn(() => null); +const mockRenderedThumbnails: MockThumbnailProps[] = []; + +jest.mock('react-fast-pdf', () => ({ + PDFPreviewer: () => mockPDFPreviewer(), +})); + +jest.mock('@components/PDFThumbnail', () => { + const {useEffect} = jest.requireActual('react'); + const {Text: MockText} = jest.requireActual<{Text: ComponentType<{children: ReactNode}>}>('react-native'); + function MockDocument({children, onLoadSuccess}: {children: ReactNode; onLoadSuccess?: () => void}) { + useEffect(() => { + onLoadSuccess?.(); + // Report once, like a document that finished loading + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return children; + } + function MockThumbnail({pageNumber, width, onRenderSuccess, onRenderError}: MockThumbnailProps) { + mockRenderedThumbnails.push({pageNumber, width, onRenderSuccess, onRenderError}); + return {`page-${pageNumber}`}; + } + return {Document: MockDocument, Thumbnail: MockThumbnail}; +}); + +jest.mock('@userActions/CanvasSize', () => ({ + retrieveMaxCanvasArea: jest.fn(), + retrieveMaxCanvasHeight: jest.fn(), + retrieveMaxCanvasWidth: jest.fn(), +})); + +// jest-expo resolves the native variant first, and this test covers the web implementation +const {default: ReceiptPDFOverlay} = jest.requireActual<{default: ComponentType}>('@components/ReportActionItem/ReceiptPDFOverlay/index.tsx'); + +const SOURCE_URL = 'https://example.com/receipt.pdf'; +const OVERSAMPLE_WIDTH = 500; + +// Like the browser's, reports the observed element's size as soon as observing starts +class MockResizeObserver { + private readonly callback: (entries: Array<{contentRect: {width: number}}>) => void; + + constructor(callback: (entries: Array<{contentRect: {width: number}}>) => void) { + this.callback = callback; + } + + observe() { + this.callback([{contentRect: {width: OVERSAMPLE_WIDTH}}]); + } + + disconnect() {} +} + +function renderOverlay(props: Omit) { + // createNodeMock gives the container's ref a node to observe, as the DOM would + return render( + , + {createNodeMock: () => ({})}, + ); +} + +describe('ReceiptPDFOverlay', () => { + beforeAll(() => { + Object.defineProperty(global, 'ResizeObserver', {value: MockResizeObserver, writable: true}); + }); + + beforeEach(() => { + mockPDFPreviewer.mockClear(); + mockRenderedThumbnails.length = 0; + }); + + it('keeps rendering the full PDFPreviewer when no page is requested', () => { + // Given a receipt whose preview has no page navigation (e.g. a single-page PDF, or a device without hover support) + // When the overlay renders with no `page` prop + renderOverlay({}); + + // Then it falls back to the full multi-page PDFPreviewer instead of rendering a single page + expect(mockPDFPreviewer).toHaveBeenCalled(); + expect(screen.queryByText('page-1')).toBeNull(); + }); + + it('renders only the requested page, sized like PDFPreviewer, and reports the load', () => { + // Given a multi-page receipt with page navigation on + const onLoadSuccess = jest.fn(); + + // When the overlay renders with a page requested + renderOverlay({page: 1, onLoadSuccess}); + + // Then it draws only that page (not the full PDFPreviewer, which would measure every page first), sized to + // match PDFPreviewer's framing, and tells the caller the PDF is ready so the pill's buttons can enable + expect(mockPDFPreviewer).not.toHaveBeenCalled(); + expect(screen.getByText('page-1')).toBeTruthy(); + expect(onLoadSuccess).toHaveBeenCalledTimes(1); + expect(mockRenderedThumbnails.at(-1)?.width).toBe(OVERSAMPLE_WIDTH + CONST.RECEIPT.PDF_PAGE_BORDER * 2); + }); + + it('keeps the previous page on screen until the next one has rendered', () => { + // Given page 1 is already drawn + const {rerender} = renderOverlay({page: 1}); + + // When the user clicks next, before page 2 has finished drawing + rerender( + , + ); + + // Then page 1 stays on screen alongside page 2, so the thumbnail underneath never flashes through + expect(screen.getByText('page-1')).toBeTruthy(); + expect(screen.getByText('page-2')).toBeTruthy(); + + act(() => { + mockRenderedThumbnails.findLast((thumbnail) => thumbnail.pageNumber === 2)?.onRenderSuccess?.(); + }); + + // Then, once page 2 finishes drawing, page 1 is dropped + expect(screen.queryByText('page-1')).toBeNull(); + expect(screen.getByText('page-2')).toBeTruthy(); + }); + + it('does not carry the last drawn page over to a replaced receipt', () => { + // Given the user is on page 2 of one receipt + const {rerender} = renderOverlay({page: 2}); + + // When that receipt is replaced by a different file (a new source URL) showing its own page 1 + rerender( + , + ); + + // Then page 2 of the old file is not left on screen under the new file's page 1 + expect(screen.queryByText('page-2')).toBeNull(); + expect(screen.getByText('page-1')).toBeTruthy(); + }); + + it('falls back to the thumbnail when a page fails to render', () => { + // Given a page is being drawn + const onLoadFailure = jest.fn(); + renderOverlay({page: 1, onLoadFailure}); + + // When that page fails to render (e.g. a corrupt page), rather than erroring silently + act(() => { + mockRenderedThumbnails.at(-1)?.onRenderError?.(); + }); + + // Then the overlay removes itself and tells the caller, so the static thumbnail underneath shows instead + expect(screen.queryByText('page-1')).toBeNull(); + expect(onLoadFailure).toHaveBeenCalledTimes(1); + }); + + it('loads the page when navigation switches on after mount, without any hover or layout event', () => { + // Given the overlay mounted before page navigation turned on (it only turns on after the receipt loads) + const {rerender} = renderOverlay({}); + + // When page navigation switches on later, with no further hover or layout event to prompt a measurement + rerender( + , + ); + + // Then the page still loads, because the width is measured by a ResizeObserver, not a layout event + expect(screen.getByText('page-1')).toBeTruthy(); + }); +});