Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,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 @@ -1143,6 +1144,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/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,7 @@
};

let allPersonalDetails: OnyxTypes.PersonalDetailsList = {};
Onyx.connect({

Check warning on line 774 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand Down Expand Up @@ -871,7 +871,7 @@

let allTransactions: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION,

Check warning on line 874 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -885,7 +885,7 @@

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,

Check warning on line 888 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
allTransactionDrafts = value ?? {};
Expand All @@ -894,7 +894,7 @@

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,

Check warning on line 897 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -908,7 +908,7 @@

let allPolicyTags: OnyxCollection<OnyxTypes.PolicyTagLists> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY_TAGS,

Check warning on line 911 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -921,7 +921,7 @@

let allReports: OnyxCollection<OnyxTypes.Report>;
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,

Check warning on line 924 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
allReports = value;
Expand All @@ -930,7 +930,7 @@

let allReportNameValuePairs: OnyxCollection<OnyxTypes.ReportNameValuePairs>;
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS,

Check warning on line 933 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (value) => {
allReportNameValuePairs = value;
Expand All @@ -940,7 +940,7 @@
let userAccountID = -1;
let currentUserEmail = '';
Onyx.connect({
key: ONYXKEYS.SESSION,

Check warning on line 943 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
callback: (value) => {
currentUserEmail = value?.email ?? '';
userAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID;
Expand All @@ -949,7 +949,7 @@

let deprecatedCurrentUserPersonalDetails: OnyxEntry<OnyxTypes.PersonalDetails>;
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,

Check warning on line 952 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
callback: (value) => {
deprecatedCurrentUserPersonalDetails = value?.[userAccountID] ?? undefined;
},
Expand All @@ -957,7 +957,7 @@

let allReportActions: OnyxCollection<OnyxTypes.ReportActions>;
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,

Check warning on line 960 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
waitForCollectionCallback: true,
callback: (actions) => {
if (!actions) {
Expand Down Expand Up @@ -1538,10 +1538,19 @@
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {reportID});
}

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 ? {thumbnail} : {})},
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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';
Expand Down Expand Up @@ -78,5 +79,18 @@ function cropImageToAspectRatio(
.catch(() => image);
}

/**
* Generate a low-resolution thumbnail from an image URI.
* Used on native to avoid decoding the full 12MP camera photo on the confirmation page.
*/
function generateThumbnail(sourceUri: string, maxWidth = 512): 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: 0.8, format: SaveFormat.JPEG}))
.then((result) => result.uri)
.catch(() => undefined);
}

export type {ImageObject};
export {calculateCropRect, cropImageToAspectRatio};
export {calculateCropRect, cropImageToAspectRatio, generateThumbnail};
10 changes: 10 additions & 0 deletions src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import ROUTES from '@src/ROUTES';
import type {FileObject} from '@src/types/utils/Attachment';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import CameraPermission from './CameraPermission';
import {generateThumbnail} from './cropImageToAspectRatio';
import NavigationAwareCamera from './NavigationAwareCamera/Camera';
import ReceiptPreviews from './ReceiptPreviews';
import type IOURequestStepScanProps from './types';
Expand Down Expand Up @@ -386,6 +387,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 @@ -397,6 +399,14 @@ function IOURequestStepScan({

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

// Generate thumbnail off the critical path — update Onyx when ready
generateThumbnail(source).then((thumbnailUri) => {
if (!thumbnailUri) {
return;
}
setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri);
});

if (isEditing) {
updateScanAndNavigate(cameraFile as FileObject, source);
return;
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 @@ -229,6 +229,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