Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d6fa27a
feat: Add thumbnail support for receipts and improve image handling
kubabutkiewicz Mar 11, 2026
35be2b4
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 12, 2026
23bb98f
Refactor IOURequestStepScan to generate thumbnails off the critical p…
kubabutkiewicz Mar 12, 2026
6c74437
resolve lint issue
kubabutkiewicz Mar 12, 2026
90397b1
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 13, 2026
af737d4
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 23, 2026
ddff150
feat: Add local receipt thumbnail generation for improved image handl…
kubabutkiewicz Mar 24, 2026
8b0918c
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 24, 2026
f4cf69d
refactor: Enhance local receipt thumbnail handling in MoneyRequestCon…
kubabutkiewicz Mar 24, 2026
ecc9d6c
resolve comments
kubabutkiewicz Mar 27, 2026
336ecb9
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 27, 2026
05b4b40
fix lint
kubabutkiewicz Mar 27, 2026
fd0e6fe
Enhance thumbnail generation logic and update logging import. Added s…
kubabutkiewicz Mar 30, 2026
946ddbb
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 30, 2026
f8cac08
Fix: Update style property to use absoluteFill for camera overlay in …
kubabutkiewicz Mar 30, 2026
a3e143e
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Mar 31, 2026
cbfb4f1
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Apr 4, 2026
8e8bcf0
Implement thumbnail pre-generation for improved performance in confir…
kubabutkiewicz Apr 4, 2026
f24b70f
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Apr 7, 2026
5beb1fa
Enhance thumbnail generation for confirmation screen by reducing max …
kubabutkiewicz Apr 7, 2026
6acd919
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Apr 7, 2026
3b1ef08
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Apr 7, 2026
49d3c60
Merge branch 'main' of github.com:callstack-internal/Expensify-App in…
kubabutkiewicz Apr 7, 2026
512c481
fix lint
kubabutkiewicz Apr 7, 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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@
"zxldvw",
"مثال",
"Airwallex",
"deprioritizes",
"AMRO",
"Bancorporation",
"Banque",
Expand Down
24 changes: 18 additions & 6 deletions src/components/MoneyRequestConfirmationListFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {emailSelector} from '@selectors/Session';
import {accountIDSelector, emailSelector} from '@selectors/Session';
import {format} from 'date-fns';
import {Str} from 'expensify-common';
import {deepEqual} from 'fast-equals';
Expand All @@ -10,6 +10,7 @@ import type {ValueOf} from 'type-fest';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useLocalReceiptThumbnail from '@hooks/useLocalReceiptThumbnail';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useOutstandingReports from '@hooks/useOutstandingReports';
Expand Down Expand Up @@ -365,9 +366,7 @@ function MoneyRequestConfirmationListFooter({
const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses();

const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector});
const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {
selector: (session) => session?.accountID ?? CONST.DEFAULT_NUMBER_ID,
});
const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK;

Expand Down Expand Up @@ -536,6 +535,19 @@ function MoneyRequestConfirmationListFooter({
const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? '');
const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? '');

const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile);

// For local files: use the pre-generated thumbnail if it was ready by first render.
// If the thumbnail arrives late we keep showing the full-res image to avoid a
// visible source swap (flash). For remote files: existing behavior unchanged.
const initialLocalSourceRef = useRef<string | undefined>(undefined);
if (isLocalFile && initialLocalSourceRef.current === undefined) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
initialLocalSourceRef.current = thumbnailUri || String(resolvedReceiptImage ?? '') || '';
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const effectiveReceiptSource = isLocalFile ? initialLocalSourceRef.current || '' : resolvedThumbnail || resolvedReceiptImage || '';

const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy;
// Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate
const shouldShowTimeRequestFields = isTimeRequest && action === CONST.IOU.ACTION.CREATE;
Expand Down Expand Up @@ -1428,7 +1440,7 @@ function MoneyRequestConfirmationListFooter({
<ReceiptImage
isThumbnail={isThumbnail}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
source={resolvedThumbnail || resolvedReceiptImage || ''}
source={effectiveReceiptSource}
// 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 an expense/split
// So if we have a thumbnail, it means we're retrieving the image from the server
Expand Down Expand Up @@ -1464,7 +1476,7 @@ function MoneyRequestConfirmationListFooter({
onPDFLoadError,
onPDFPassword,
isThumbnail,
resolvedThumbnail,
effectiveReceiptSource,
receiptThumbnail,
fileExtension,
isDistanceRequest,
Expand Down
113 changes: 113 additions & 0 deletions src/hooks/useLocalReceiptThumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {useEffect, useRef, useState, useTransition} from 'react';
import {Image} from 'react-native';
import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';

const thumbnailCache = new Map<string, string>();
/** Track how many mounted hook instances reference each sourceUri */
const thumbnailRefCount = new Map<string, number>();

function retainUri(uri: string) {
thumbnailRefCount.set(uri, (thumbnailRefCount.get(uri) ?? 0) + 1);
}

function releaseUri(uri: string) {
const count = (thumbnailRefCount.get(uri) ?? 1) - 1;
if (count <= 0) {
thumbnailRefCount.delete(uri);
thumbnailCache.delete(uri);
} else {
thumbnailRefCount.set(uri, count);
}
}

/**
* Pre-populate the thumbnail cache so the confirm screen can use it
* synchronously on first render, avoiding any source swap / flash.
*/
function pregenerateThumbnail(sourceUri: string): Promise<string | undefined> {
if (thumbnailCache.has(sourceUri)) {
return Promise.resolve(thumbnailCache.get(sourceUri));
}
return generateThumbnail(sourceUri).then((uri) => {
if (uri) {
thumbnailCache.set(sourceUri, uri);
// Pre-decode the thumbnail in the native image pipeline so the
// confirmation screen can display it instantly without decode latency.
Image.prefetch(uri);
}
return uri;
});
}

/**
* Returns a cached low-resolution thumbnail for a local receipt image.
* The thumbnail should be pre-generated via `pregenerateThumbnail` before
* navigating to the confirm screen. If it wasn't, this hook generates it
* as a fallback, but in that case a source swap (flash) may occur.
*/
function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean): {thumbnailUri: string | undefined; isGenerating: boolean} {
const [asyncResult, setAsyncResult] = useState<{source: string; uri?: string; done: boolean} | undefined>();
const [, startTransition] = useTransition();
const retainedUriRef = useRef<string | undefined>(undefined);

// Resolve cached thumbnails synchronously during render (fast path)
const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined;
const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined;
const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri;

const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri;
const isGenerating = shouldGenerate && !resultForCurrentSource?.done;

// Retain / release the cache entry so it lives as long as at least one
// mounted hook instance references it, and is cleaned up after the last
// consumer unmounts.
useEffect(() => {
if (!sourceUri || !isLocalFile) {
return;
}

retainUri(sourceUri);
retainedUriRef.current = sourceUri;

return () => {
releaseUri(sourceUri);
retainedUriRef.current = undefined;
};
}, [sourceUri, isLocalFile]);

// Fallback: generate if not already in cache (e.g. gallery pick path)
useEffect(() => {
if (!sourceUri || !isLocalFile || thumbnailCache.has(sourceUri)) {
return;
}

let cancelled = false;
generateThumbnail(sourceUri)
.then((uri) => {
if (cancelled) {
return;
}
if (uri) {
thumbnailCache.set(sourceUri, uri);
}
startTransition(() => {
setAsyncResult({source: sourceUri, uri: uri ?? undefined, done: true});
});
})
.catch(() => {
if (cancelled) {
return;
}
setAsyncResult({source: sourceUri, done: true});
});

return () => {
cancelled = true;
};
}, [sourceUri, isLocalFile, startTransition]);

return {thumbnailUri, isGenerating};
}

export {pregenerateThumbnail};
export default useLocalReceiptThumbnail;
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin
reservationList: 'string',
isTestReceipt: 'boolean',
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
});
case 'childRecentReceiptTransactionIDs':
return validateObject<ObjectElement<ReportAction, 'childRecentReceiptTransactionIDs'>>(value, {}, 'string');
Expand Down Expand Up @@ -1152,6 +1153,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
reservationList: 'array',
isTestReceipt: 'boolean',
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
});
case 'taxRate':
return validateObject<ObjectElement<Transaction, 'taxRate'>>(value, {
Expand Down
4 changes: 2 additions & 2 deletions src/libs/ReceiptUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry<Transaction>, receiptPa
return {image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction, filename};
}

// For local files, we won't have a thumbnail yet
// For local files, use the pre-generated thumbnail if available for fast preview
if ((isReceiptImage || isReceiptPDF) && typeof path === 'string' && (path.startsWith('blob:') || path.startsWith('file:'))) {
return {image: path, isLocalFile: true, filename};
return {thumbnail: transaction?.receipt?.thumbnail, image: path, isLocalFile: true, filename};
}

if (isReceiptImage) {
Expand Down
13 changes: 11 additions & 2 deletions src/libs/actions/IOU/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,19 @@ function replaceReceipt({transactionID, file, source, state, transactionPolicy,
API.write(WRITE_COMMANDS.REPLACE_RECEIPT, parameters, {optimisticData, successData, failureData});
}

function setMoneyRequestReceipt(transactionID: string, source: string, filename: string, isDraft: boolean, type?: string, isTestReceipt = false, isTestDriveReceipt = false) {
function setMoneyRequestReceipt(
transactionID: string,
source: string,
filename: string,
isDraft: boolean,
type?: string,
isTestReceipt = false,
isTestDriveReceipt = false,
thumbnail?: string,
) {
Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
// isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases
receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null},
receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, thumbnail},
});
}

Expand Down
1 change: 1 addition & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,5 +903,6 @@ export {
getFilesFromClipboardEvent,
cleanFileObject,
cleanFileObjectName,
JPEG_QUALITY,
};
export type {FileValidationError};
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator';
import ImageSize from 'react-native-image-size';
import type {Orientation} from 'react-native-vision-camera';
import cropOrRotateImage from '@libs/cropOrRotateImage';
import getDeviceOrientationAwareImageSize from '@libs/cropOrRotateImage/getDeviceOrientationAwareImageSize';
import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils';
import Log from '@libs/Log';
import type {FileObject} from '@src/types/utils/Attachment';

type ImageObject = {
Expand Down Expand Up @@ -78,5 +81,23 @@ function cropImageToAspectRatio(
.catch(() => image);
}

const THUMBNAIL_MAX_WIDTH = 256;
/**
* Generate a low-resolution thumbnail from an image URI.
* Used on native to avoid decoding the full 12MP camera photo on the confirmation page.
* 256px is sufficient for the confirmation screen preview and decodes ~4x faster than 512px.
*/
function generateThumbnail(sourceUri: string, maxWidth = THUMBNAIL_MAX_WIDTH): Promise<string | undefined> {
return ImageManipulator.manipulate(sourceUri)
Copy link
Copy Markdown
Contributor

@marcaaron marcaaron Mar 12, 2026

Choose a reason for hiding this comment

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

We definitely should check this on Android and iOS both. I think I saw on my crappy Galaxy S8 that the image preview on confirmation page takes about 1 second to load without any resizing.

But, also I think we can control the overall file size by tweaking the vision camera settings e.g. if we use takeSnapshot() instead of takePhoto() then maybe this thumbnail generation is not needed? Or the tradeoff is less? Hard to say! Hacking over here.

.resize({width: maxWidth})
.renderAsync()
.then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}))
.then((result) => result.uri)
.catch((error) => {
Log.warn(`Failed to generate thumbnail: ${error}`);
return undefined;
});
}

export type {ImageObject};
export {calculateCropRect, cropImageToAspectRatio};
export {calculateCropRect, cropImageToAspectRatio, generateThumbnail};
19 changes: 14 additions & 5 deletions src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import {pregenerateThumbnail} from '@hooks/useLocalReceiptThumbnail';
import useNativeCamera from '@hooks/useNativeCamera';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
Expand Down Expand Up @@ -178,6 +179,10 @@ function IOURequestStepScan({
}
endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE);

// Preload the confirmation screen module so its JS is parsed and ready
// when we navigate after capture — eliminates cold-start module load cost.
require('../IOURequestStepConfirmation');

// Pre-create upload directory to avoid latency during capture
const path = getReceiptsUploadFolderPath();
ReactNativeBlobUtil.fs
Expand Down Expand Up @@ -325,6 +330,7 @@ function IOURequestStepScan({
const transactionID = transaction?.transactionID ?? initialTransactionID;
const source = getPhotoSource(photo.path);
const filename = photo.path;

endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);

const cameraFile = {
Expand All @@ -334,9 +340,8 @@ function IOURequestStepScan({
source,
};

setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg');

if (isEditing) {
setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg');
updateScanAndNavigate(cameraFile as FileObject, source);
return;
}
Expand All @@ -345,14 +350,18 @@ function IOURequestStepScan({
setReceiptFiles(newReceiptFiles);

if (isMultiScanEnabled) {
setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg');
setDidCapturePhoto(false);
isCapturingPhoto.current = false;
return;
}

// Defer navigation by one frame so React renders the frozen camera
// state (didCapturePhoto=true) before the screen transitions away.
requestAnimationFrame(() => submitReceipts(newReceiptFiles));
// Fire Onyx merge immediately (non-blocking) while we await thumbnail generation.
// Both run in parallel — navigation proceeds once the thumbnail is cached.
setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg');
pregenerateThumbnail(source).then(() => {
submitReceipts(newReceiptFiles);
});
})
.catch((error: string) => {
isCapturingPhoto.current = false;
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ type Receipt = {

/** Receipt is Test Drive testing receipt */
isTestDriveReceipt?: true;

/** Local thumbnail URI for fast preview on confirmation page */
thumbnail?: string;
};

/** Model of route */
Expand Down
Loading