Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
45a5ddb
Add page navigation to multi-page PDF receipts
ishpaul777 Oct 1, 2026
bfc9bbf
Rename overlay flag to pass spellcheck
ishpaul777 Oct 1, 2026
174b5fb
Render only the visible receipt page when flipping pages
ishpaul777 Oct 1, 2026
0323b8e
Reset the last drawn receipt page when the receipt is replaced
ishpaul777 Oct 1, 2026
d1c1b19
Move the PDF page border to CONST and drop a duplicate map check
ishpaul777 Oct 1, 2026
27fb16d
Address review feedback on receipt page navigation
ishpaul777 Oct 2, 2026
8db1138
Stop the receipt page label from truncating
ishpaul777 Oct 2, 2026
9fac172
Keep the receipt page label on one line
ishpaul777 Oct 2, 2026
4210fd3
Tighten the receipt page pill spacing to match the mock
ishpaul777 Oct 2, 2026
76f4fe8
Let the receipt page pill size to its text instead of reserving width
ishpaul777 Oct 2, 2026
a3bcb48
Drop redundant useCallback now that React Compiler memoizes it
ishpaul777 Oct 2, 2026
544b768
Fix stale loaded/failed state and real bugs from MelvinBot's review
ishpaul777 Oct 2, 2026
dcd9692
Fix CI typecheck failures
ishpaul777 Oct 2, 2026
9e0a322
Apply design review: icon colors per state, stop pill jumping
ishpaul777 Oct 2, 2026
898ff8e
Shrink the receipt page pill's minWidth, it was too wide
ishpaul777 Oct 2, 2026
49b4326
Stop blocking hover-zoom over the page nav pill
ishpaul777 Oct 5, 2026
b14b0f6
Merge branch 'Expensify:main' into ishpaul-102435-pdf-page-nav
ishpaul777 Oct 5, 2026
efe57f7
Move the anti-jump minWidth to the label, per design
ishpaul777 Oct 5, 2026
ecdf048
Merge remote-tracking branch 'upstream/main' into ishpaul-102435-pdf-…
ishpaul777 Oct 7, 2026
0dab557
Block hover-zoom over the page nav pill after all
ishpaul777 Oct 7, 2026
a02766d
Bump the receipt page label's minWidth a bit
ishpaul777 Oct 7, 2026
1aee12c
Adjust receipt page navigator label's minWidth from 64 to 60 for impr…
ishpaul777 Oct 7, 2026
cd16a8a
Try a wider minWidth on the receipt page label
ishpaul777 Oct 7, 2026
e661391
Revert "Try a wider minWidth on the receipt page label"
ishpaul777 Oct 7, 2026
d7259c1
Mask the page label instead of reserving width for it
ishpaul777 Oct 7, 2026
09adc7f
Move page label before buttons in DOM order for screen readers
ishpaul777 Oct 7, 2026
67ac322
Announce page changes to screen readers via live region
ishpaul777 Oct 7, 2026
ed033e4
Merge upstream/main into ishpaul-102435-pdf-page-nav
ishpaul777 Oct 8, 2026
636cd48
Address review: type the style objects, document FIRST_PDF_PAGE, drop…
ishpaul777 Oct 9, 2026
d528280
Size the page navigator pill to its label so long translations and fo…
ishpaul777 Oct 9, 2026
3748623
Size the page navigator pill from its widest label instead of a fixed…
ishpaul777 Oct 9, 2026
033e501
Use absoluteFillObject for the page navigator label overlay
ishpaul777 Oct 9, 2026
8050a99
Apply oxfmt to CategoryPicker and TransactionUtils
ishpaul777 Oct 9, 2026
973ee70
Use StyleSheet.absoluteFill for the page navigator label overlay
ishpaul777 Oct 9, 2026
8fe7cde
Merge branch 'Expensify:main' into ishpaul-102435-pdf-page-nav
ishpaul777 Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
ishpaul777 marked this conversation as resolved.

// 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: {
Expand Down Expand Up @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ function ConfirmationReceiptThumbnail({
))}
{shouldShowReceiptPageCount && (
<Badge
text={translate('receipt.pageCount', {pageCount: effectiveReceiptPageCount})}
text={translate('receipt.pageCount', {page: CONST.RECEIPT.FIRST_PDF_PAGE, pageCount: effectiveReceiptPageCount})}
badgeStyles={[styles.receiptPageCountBadge, styles.pointerEventsNone]}
/>
)}
Expand Down
16 changes: 9 additions & 7 deletions src/components/PDFThumbnail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Document> 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);
Expand All @@ -25,12 +32,7 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLo
<Document
loading={<LoadingIndicator />}
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={() => {
Expand Down Expand Up @@ -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};
10 changes: 10 additions & 0 deletions src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
}
Expand Down
50 changes: 46 additions & 4 deletions src/components/ReportActionItem/MoneyRequestReceiptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from '@libs/ReportUtils';
import trackExpenseCreationError from '@libs/telemetry/trackExpenseCreationError';
import {
canOverlayReceiptPDF,
didReceiptScanSucceed as didReceiptScanSucceedTransactionUtils,
hasEReceipt,
hasPendingDistanceReceiptRegeneration,
Expand All @@ -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';

Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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}
Expand All @@ -780,9 +814,17 @@ function MoneyRequestReceiptView({
)}
</ReceiptHoverZoom>
</View>
{shouldShowReceiptPageCount && (
{shouldShowReceiptPageNavigator && (
<ReceiptPageNavigator
page={receiptPage}
pageCount={receiptPageCount}
isLoading={isReceiptPDFLoading}
onChangePage={(page) => setReceiptPDFPagesState({source: displayedReceiptSource, page, status: 'loaded'})}
/>
)}
{shouldShowReceiptPageCount && !shouldShowReceiptPageNavigator && (
<Badge
text={translate('receipt.pageCount', {pageCount: receiptPageCount})}
text={translate('receipt.pageCount', {page: CONST.RECEIPT.FIRST_PDF_PAGE, pageCount: receiptPageCount})}
badgeStyles={[styles.receiptPageCountBadge, styles.pointerEventsNone]}
/>
)}
Expand Down
Loading
Loading