Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -1141,6 +1142,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 @@
currency: string;
created: string;
merchant: string;
receipt: OnyxEntry<Receipt>;

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
category?: string;
tag?: string;
taxCode?: string;
Expand Down Expand Up @@ -870,7 +870,7 @@
transactions: OnyxCollection<OnyxTypes.Transaction>;
violations: OnyxCollection<OnyxTypes.TransactionViolations>;
isSingleTransactionView: boolean | undefined;
isChatReportArchived: boolean | undefined;

Check warning on line 873 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
isChatIOUReportArchived: boolean | undefined;
allTransactionViolationsParam: OnyxCollection<OnyxTypes.TransactionViolations>;
currentUserAccountID: number;
Expand All @@ -884,7 +884,7 @@
iouReport: OnyxEntry<OnyxTypes.Report>;
chatReport: OnyxEntry<OnyxTypes.Report>;
isChatIOUReportArchived?: boolean | undefined;
isSingleTransactionView?: boolean;

Check warning on line 887 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
transactionIDsPendingDeletion?: string[];
selectedTransactionIDs?: string[];
allTransactionViolationsParam: OnyxCollection<OnyxTypes.TransactionViolations>;
Expand All @@ -893,7 +893,7 @@

type PayMoneyRequestFunctionParams = {
paymentType: PaymentMethodType;
chatReport: OnyxTypes.Report;

Check warning on line 896 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
iouReport: OnyxEntry<OnyxTypes.Report>;
introSelected: OnyxEntry<OnyxTypes.IntroSelected>;
iouReportCurrentNextStepDeprecated: OnyxEntry<OnyxTypes.ReportNextStepDeprecated>;
Expand All @@ -907,7 +907,7 @@
};

let allTransactions: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 910 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.COLLECTION.TRANSACTION,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -920,7 +920,7 @@
},
});

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};

Check warning on line 923 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
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,
waitForCollectionCallback: true,
Expand All @@ -929,7 +929,7 @@
},
});

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};

Check warning on line 932 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
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
waitForCollectionCallback: true,
Expand All @@ -939,7 +939,7 @@
return;
}

allTransactionViolations = value;

Check warning on line 942 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
},
});

Expand All @@ -948,7 +948,7 @@
key: ONYXKEYS.COLLECTION.POLICY_TAGS,
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {

Check warning on line 951 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
allPolicyTags = {};
return;
}
Expand All @@ -956,7 +956,7 @@
},
});

let allReports: OnyxCollection<OnyxTypes.Report>;

Check warning on line 959 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
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
Expand Down Expand Up @@ -1517,10 +1517,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};
44 changes: 24 additions & 20 deletions src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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 @@ -382,32 +383,35 @@ function IOURequestStepScan({
const transactionID = transaction?.transactionID ?? initialTransactionID;
const source = getPhotoSource(photo.path);
const filename = photo.path;
endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);

const cameraFile = {
uri: source,
name: filename,
type: 'image/jpeg',
source,
};
return generateThumbnail(source).then((thumbnailUri) => {
endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);

setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg');
const cameraFile = {
uri: source,
name: filename,
type: 'image/jpeg',
source,
};

if (isEditing) {
updateScanAndNavigate(cameraFile as FileObject, source);
return;
}
setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri);

const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}];
setReceiptFiles(newReceiptFiles);
if (isEditing) {
updateScanAndNavigate(cameraFile as FileObject, source);
return;
}

if (isMultiScanEnabled) {
setDidCapturePhoto(false);
isCapturingPhoto.current = false;
return;
}
const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}];
setReceiptFiles(newReceiptFiles);

submitReceipts(newReceiptFiles);
if (isMultiScanEnabled) {
setDidCapturePhoto(false);
isCapturingPhoto.current = false;
return;
}

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 @@ -228,6 +228,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