From f59d7925d92b245d83e34b14f2acfafd66c9e800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 12 Mar 2026 16:40:24 +0100 Subject: [PATCH 01/23] refactor: move odometer image stitching to confirmation page --- .../MoneyRequestConfirmationList.tsx | 5 +++ .../MoneyRequestConfirmationListFooter.tsx | 19 ++++++++ .../step/IOURequestStepConfirmation.tsx | 45 +++++++++++++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 39 ++-------------- 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 7287cd3038d0f..d1342b295fb32 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -165,6 +165,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the expense is an odometer distance expense */ isOdometerDistanceRequest?: boolean; + /** Whether the odometer receipt is currently being stitched */ + isLoadingReceipt?: boolean; + /** Whether the expense is a GPS distance expense */ isGPSDistanceRequest: boolean; @@ -236,6 +239,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest = false, isPolicyExpenseChat = false, @@ -1309,6 +1313,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} + isLoadingReceipt={isLoadingReceipt} isGPSDistanceRequest={isGPSDistanceRequest} isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 4296ab5808e36..899bbf9d913b7 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -55,6 +55,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ActivityIndicator from './ActivityIndicator'; import Badge from './Badge'; import Button from './Button'; import ConfirmedRoute from './ConfirmedRoute'; @@ -139,6 +140,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if it is an odometer distance request */ isOdometerDistanceRequest?: boolean; + /** Whether the receipt is currently being stitched */ + isLoadingReceipt?: boolean; + /** Flag indicating if it is a GPS distance request */ isGPSDistanceRequest: boolean; @@ -281,6 +285,7 @@ function MoneyRequestConfirmationListFooter({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest, isTimeRequest, @@ -1268,7 +1273,21 @@ function MoneyRequestConfirmationListFooter({ )} + {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && !hasReceiptImageOrThumbnail && isLoadingReceipt && ( + + + + )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && + !isLoadingReceipt && (hasReceiptImageOrThumbnail ? receiptThumbnailContent : showReceiptEmptyState && ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f02a48fff78b5..4b26c7cffd8a0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; +import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import LocationPermissionModal from '@components/LocationPermissionModal'; @@ -64,6 +65,7 @@ import { isReportOutstanding, isSelectedManagerMcTest, } from '@libs/ReportUtils'; +import stitchOdometerImages from '@libs/stitchOdometerImages'; import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; @@ -285,6 +287,8 @@ function IOURequestStepConfirmation({ const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && !isTestTransaction; const [isConfirmed, setIsConfirmed] = useState(false); const [isConfirming, setIsConfirming] = useState(false); + const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); + const [stitchError, setStitchError] = useState(''); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -382,6 +386,45 @@ function IOURequestStepConfirmation({ } }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; + + useEffect(() => { + if (!isOdometerDistanceRequest) { + return; + } + + if (!odometerStartImage && !odometerEndImage) { + return; + } + + setIsStitchingReceipt(true); + setStitchError(''); + + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (!(stitchedImage ?? odometerStartImage ?? odometerEndImage)) { + return; + } + const uri = + stitchedImage?.uri ?? + (typeof odometerStartImage === 'string' ? odometerStartImage : odometerStartImage?.uri) ?? + (typeof odometerEndImage === 'string' ? odometerEndImage : odometerEndImage?.uri) ?? + ''; + const name = + stitchedImage?.name ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? + ''; + setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action)); + }) + .catch((error: unknown) => { + Log.warn('stitchOdometerImages failed on confirmation page', {error}); + setStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => setIsStitchingReceipt(false)); + }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); + const defaultBillable = !!policy?.defaultBillable; useEffect(() => { if (isMovingTransactionFromTrackExpense) { @@ -1594,6 +1637,7 @@ function IOURequestStepConfirmation({ }} /> )} + {!!stitchError && } (''); const [endReading, setEndReading] = useState(''); const [formError, setFormError] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); // Key to force TextInput remount when resetting state after tab switch const [inputKey, setInputKey] = useState(0); @@ -370,7 +367,7 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); // Navigate to next page following Manual tab pattern - const navigateToNextPage = async () => { + const navigateToNextPage = () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -378,25 +375,6 @@ function IOURequestStepDistanceOdometer({ const calculatedDistance = roundToTwoDecimalPlaces(distance); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); - let stitchedImage: FileObject | null = null; - try { - stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage); - } catch (error) { - Log.warn('stitchOdometerImages failed', {error}); - setFormError(translate('iou.error.stitchOdometerImagesFailed')); - return; - } - - if (stitchedImage ?? odometerStartImage ?? odometerEndImage) { - const uri = stitchedImage?.uri ?? startImageSource ?? endImageSource ?? ''; - const name = - stitchedImage?.name ?? - (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? - (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? - ''; - setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft); - } - if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplit && transaction) { @@ -497,10 +475,6 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { - if (isSubmitting) { - return; - } - // Validation: Start and end readings must not be empty if (!startReading || !endReading) { setFormError(translate('iou.error.invalidReadings')); @@ -533,13 +507,7 @@ function IOURequestStepDistanceOdometer({ } // When validation passes, call navigateToNextPage - setIsSubmitting(true); - navigateToNextPage() - .catch((error) => { - Log.warn('navigateToNextPage failed', {error}); - setFormError(translate('common.genericErrorMessage')); - }) - .finally(() => setIsSubmitting(false)); + navigateToNextPage(); }; return ( @@ -659,7 +627,6 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter - isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]} From d39c77be7f3d12588c551158d899a0d6220dc183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 11:53:10 +0100 Subject: [PATCH 02/23] improvement: extract stitched odometer filename prefix to constants --- src/libs/stitchOdometerImages/constants.ts | 3 +++ src/libs/stitchOdometerImages/index.native.ts | 5 +++-- src/libs/stitchOdometerImages/index.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/libs/stitchOdometerImages/constants.ts diff --git a/src/libs/stitchOdometerImages/constants.ts b/src/libs/stitchOdometerImages/constants.ts new file mode 100644 index 0000000000000..cdee9e92d0375 --- /dev/null +++ b/src/libs/stitchOdometerImages/constants.ts @@ -0,0 +1,3 @@ +const STITCHED_ODOMETER_FILENAME_PREFIX = 'stitched_odometer'; + +export default STITCHED_ODOMETER_FILENAME_PREFIX; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index ed02089e95ce1..4bef73052c8f8 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -2,6 +2,7 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { @@ -46,13 +47,13 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima // Delete any previously stitched files before creating a new one try { const tempDirContents = await RNFS.readDir(RNFS.TemporaryDirectoryPath); - const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith('stitched_odometer_') && f.name.endsWith('.jpg')); + const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith(`${STITCHED_ODOMETER_FILENAME_PREFIX}_`) && f.name.endsWith('.jpg')); await Promise.all(oldStitchedFiles.map((f) => RNFS.unlink(f.path))); } catch (error) { Log.warn('stitchOdometerImages (native) failed to clean up old stitched files', {error}); } - const filename = `stitched_odometer_${Date.now()}.jpg`; + const filename = `${STITCHED_ODOMETER_FILENAME_PREFIX}_${Date.now()}.jpg`; const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; await RNFS.writeFile(tempPath, base64, 'base64'); diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index f388673f5bffc..51bfac1c8a1d5 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,4 +1,5 @@ import type {FileObject} from '@src/types/utils/Attachment'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; // Tracks the single active stitched blob URL so that we can revoke it on the next call so at most one blob URL exists at a time @@ -45,7 +46,7 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F } const uri = URL.createObjectURL(blob); previousBlobUrl = uri; - resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); + resolve({uri, name: `${STITCHED_ODOMETER_FILENAME_PREFIX}.jpg`, type: 'image/jpeg'}); }, 'image/jpeg'); }); }); From 2307c1fb581774953a0bf67c7d38f47a3b075738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 12:00:36 +0100 Subject: [PATCH 03/23] fix: guard stale async state updates in odometer image stitching effect --- .../step/IOURequestStepConfirmation.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 4b26c7cffd8a0..6bf5dce51179a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -398,11 +398,15 @@ function IOURequestStepConfirmation({ return; } + let ignore = false; setIsStitchingReceipt(true); setStitchError(''); stitchOdometerImages(odometerStartImage, odometerEndImage) .then((stitchedImage) => { + if (ignore) { + return; + } if (!(stitchedImage ?? odometerStartImage ?? odometerEndImage)) { return; } @@ -419,10 +423,22 @@ function IOURequestStepConfirmation({ setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action)); }) .catch((error: unknown) => { - Log.warn('stitchOdometerImages failed on confirmation page', {error}); + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); setStitchError(translate('iou.error.stitchOdometerImagesFailed')); }) - .finally(() => setIsStitchingReceipt(false)); + .finally(() => { + if (ignore) { + return; + } + setIsStitchingReceipt(false); + }); + + return () => { + ignore = true; + }; }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); const defaultBillable = !!policy?.defaultBillable; From 16b00646c51131a555c161b2b5314f7feec0ee75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 13 Mar 2026 12:03:44 +0100 Subject: [PATCH 04/23] feat: add util to detect stitched odometer receipt filenames --- src/components/MoneyRequestConfirmationListFooter.tsx | 4 ++-- src/libs/ReceiptUtils.ts | 7 ++++++- .../routes/TransactionReceiptModalContent.tsx | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 899bbf9d913b7..250f5099ad690 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -27,7 +27,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getDestinationForDisplay, getSubratesFields, getSubratesForDisplay, getTimeDifferenceIntervals, getTimeForDisplay} from '@libs/PerDiemRequestUtils'; import {canSendInvoice, getPerDiemCustomUnit} from '@libs/PolicyUtils'; import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import {getThumbnailAndImageURIs, isStitchedOdometerReceiptFilename} from '@libs/ReceiptUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {generateReportID, getDefaultWorkspaceAvatar, getOutstandingReportsForUser, isMoneyRequestReport, isReportOutstanding} from '@libs/ReportUtils'; import {getTagVisibility, hasEnabledTags} from '@libs/TagsOptionsListUtils'; @@ -450,7 +450,7 @@ function MoneyRequestConfirmationListFooter({ } = receiptPath && receiptFilename ? getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : ({} as ThumbnailAndImageURI); const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); + const isStitchedOdometerReceipt = isStitchedOdometerReceiptFilename(receiptFilename); const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index dceabfe0ca4f6..57f13f5fa4046 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -7,6 +7,7 @@ import ROUTES from '@src/ROUTES'; import type {ShareTempFile, Transaction} from '@src/types/onyx'; import type {ReceiptError, ReceiptSource} from '@src/types/onyx/Transaction'; import {isLocalFile as isLocalFileUtils, splitExtensionFromFileName} from './fileDownload/FileUtils'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './stitchOdometerImages/constants'; import {hasReceipt, hasReceiptSource, isFetchingWaypointsFromServer} from './TransactionUtils'; type ThumbnailAndImageURI = { @@ -94,6 +95,10 @@ const shouldValidateFile = (file: ShareTempFile | undefined) => { return file?.mimeType === CONST.SHARE_FILE_MIMETYPE.HEIC || file?.mimeType === CONST.SHARE_FILE_MIMETYPE.IMG; }; +function isStitchedOdometerReceiptFilename(filename: string | undefined): boolean { + return !!filename?.startsWith(STITCHED_ODOMETER_FILENAME_PREFIX); +} + // eslint-disable-next-line import/prefer-default-export -export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename}; +export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename, isStitchedOdometerReceiptFilename}; export type {ThumbnailAndImageURI}; diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index ff1923ddf57a6..e82511cc58de4 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -19,7 +19,7 @@ import fetchImage from '@libs/fetchImage'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; import Navigation from '@libs/Navigation/Navigation'; -import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import {getThumbnailAndImageURIs, isStitchedOdometerReceiptFilename} from '@libs/ReceiptUtils'; import {getReportAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {canEditFieldOfMoneyRequest, isMoneyRequestReport, isTrackExpenseReport} from '@libs/ReportUtils'; import {getRequestType, hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; @@ -131,7 +131,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const receiptFilename = transaction?.receipt?.filename; const isImage = !!receiptFilename && Str.isImage(receiptFilename); - const isStitchedOdometerReceipt = !!receiptFilename?.startsWith('stitched_odometer'); + const isStitchedOdometerReceipt = isStitchedOdometerReceiptFilename(receiptFilename); const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt && !isStitchedOdometerReceipt; const shouldShowDeleteReceiptButton = canDeleteReceipt && !readonly && !isDraftTransaction && !transaction?.receipt?.isTestDriveReceipt; From 1211833c139c2cebf8893a8405b3dc85dce44153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 17 Mar 2026 21:45:23 +0100 Subject: [PATCH 05/23] refactor: skip stitching when only one odometer image is present --- .../step/IOURequestStepConfirmation.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 78ad4850da350..75e0b8508d705 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -395,7 +395,17 @@ function IOURequestStepConfirmation({ return; } - if (!odometerStartImage && !odometerEndImage) { + if (!odometerStartImage || !odometerEndImage) { + const singleImage = odometerStartImage ?? odometerEndImage; + + if (!singleImage) { + return; + } + + const getImageUri = (img: typeof singleImage): string => (typeof img === 'string' ? img : (img.uri ?? '')); + const getImageName = (img: typeof singleImage): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img.name ?? '')); + + setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action)); return; } @@ -405,23 +415,10 @@ function IOURequestStepConfirmation({ stitchOdometerImages(odometerStartImage, odometerEndImage) .then((stitchedImage) => { - if (ignore) { - return; - } - if (!(stitchedImage ?? odometerStartImage ?? odometerEndImage)) { + if (ignore || !stitchedImage) { return; } - const uri = - stitchedImage?.uri ?? - (typeof odometerStartImage === 'string' ? odometerStartImage : odometerStartImage?.uri) ?? - (typeof odometerEndImage === 'string' ? odometerEndImage : odometerEndImage?.uri) ?? - ''; - const name = - stitchedImage?.name ?? - (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? - (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? - ''; - setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action)); + setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); }) .catch((error: unknown) => { if (ignore) { From e35804a8e60033221e90265ae37689db0bc0e62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 17 Mar 2026 21:57:41 +0100 Subject: [PATCH 06/23] refactor: remove unused isStitchedOdometerReceiptFilename function --- src/libs/ReceiptUtils.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index 57f13f5fa4046..dceabfe0ca4f6 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -7,7 +7,6 @@ import ROUTES from '@src/ROUTES'; import type {ShareTempFile, Transaction} from '@src/types/onyx'; import type {ReceiptError, ReceiptSource} from '@src/types/onyx/Transaction'; import {isLocalFile as isLocalFileUtils, splitExtensionFromFileName} from './fileDownload/FileUtils'; -import STITCHED_ODOMETER_FILENAME_PREFIX from './stitchOdometerImages/constants'; import {hasReceipt, hasReceiptSource, isFetchingWaypointsFromServer} from './TransactionUtils'; type ThumbnailAndImageURI = { @@ -95,10 +94,6 @@ const shouldValidateFile = (file: ShareTempFile | undefined) => { return file?.mimeType === CONST.SHARE_FILE_MIMETYPE.HEIC || file?.mimeType === CONST.SHARE_FILE_MIMETYPE.IMG; }; -function isStitchedOdometerReceiptFilename(filename: string | undefined): boolean { - return !!filename?.startsWith(STITCHED_ODOMETER_FILENAME_PREFIX); -} - // eslint-disable-next-line import/prefer-default-export -export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename, isStitchedOdometerReceiptFilename}; +export {getThumbnailAndImageURIs, shouldValidateFile, constructReceiptSourceFromFilename}; export type {ThumbnailAndImageURI}; From 9e28da62a1908227ea596c2af47470d11c4b6450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 18 Mar 2026 16:17:13 +0100 Subject: [PATCH 07/23] fix: remove image preview flickering by moving activity indicator to same container --- .../MoneyRequestConfirmationList.tsx | 2 +- .../MoneyRequestConfirmationListFooter.tsx | 46 ++++++++----------- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 902bf2f649c0e..ca59f27714321 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1414,5 +1414,5 @@ export default memo( prevProps.isTimeRequest === nextProps.isTimeRequest && prevProps.iouTimeCount === nextProps.iouTimeCount && prevProps.iouTimeRate === nextProps.iouTimeRate && - prevProps.shouldHideToSection === nextProps.shouldHideToSection, + prevProps.isLoadingReceipt === nextProps.isLoadingReceipt, ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index b20c287efd788..f84cd0547e235 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1094,10 +1094,11 @@ function MoneyRequestConfirmationListFooter({ return ( - {isLocalFile && Str.isPDF(receiptFilename) ? ( + {isLoadingReceipt && } + {!isLoadingReceipt && (isLocalFile && Str.isPDF(receiptFilename) ? ( { if (!transactionID) { @@ -1161,24 +1162,23 @@ function MoneyRequestConfirmationListFooter({ resizeMode={isOdometerDistanceRequest ? 'contain' : undefined} /> - )} + ))} ); }, [ isCompactMode, compactReceiptContainerStyle, styles.expenseViewImageSmall, - styles.moneyRequestImage, - styles.flex1, styles.h100, + styles.flex1, + styles.moneyRequestImage, + styles.justifyContentCenter, + styles.alignItemsCenter, styles.cursorDefault, + isLoadingReceipt, + handleCompactReceiptContainerLayout, isLocalFile, receiptFilename, - transactionID, - isReceiptEditable, - reportID, - action, - iouType, translate, shouldDisplayReceipt, resolvedReceiptImage, @@ -1189,9 +1189,13 @@ function MoneyRequestConfirmationListFooter({ receiptThumbnail, fileExtension, isDistanceRequest, - isOdometerDistanceRequest, handleReceiptLoad, - handleCompactReceiptContainerLayout, + isOdometerDistanceRequest, + transactionID, + isReceiptEditable, + reportID, + action, + iouType, ]); const visibleFields = fields.filter((field) => field.shouldShow); @@ -1274,22 +1278,8 @@ function MoneyRequestConfirmationListFooter({ )} - {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && !hasReceiptImageOrThumbnail && isLoadingReceipt && ( - - - - )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && - !isLoadingReceipt && - (hasReceiptImageOrThumbnail + (hasReceiptImageOrThumbnail || isLoadingReceipt ? receiptThumbnailContent : showReceiptEmptyState && ( Date: Wed, 18 Mar 2026 16:19:37 +0100 Subject: [PATCH 08/23] chore: prettier run --- .../MoneyRequestConfirmationListFooter.tsx | 125 +++++++++--------- 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index f84cd0547e235..a4b2a5443ea61 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1098,71 +1098,72 @@ function MoneyRequestConfirmationListFooter({ onLayout={isCompactMode ? handleCompactReceiptContainerLayout : undefined} > {isLoadingReceipt && } - {!isLoadingReceipt && (isLocalFile && Str.isPDF(receiptFilename) ? ( - { - if (!transactionID) { - return; - } + {!isLoadingReceipt && + (isLocalFile && Str.isPDF(receiptFilename) ? ( + { + if (!transactionID) { + return; + } - Navigation.navigate( - isReceiptEditable - ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) - : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), - ); - }} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.PDF_RECEIPT_THUMBNAIL} - disabled={!shouldDisplayReceipt} - disabledStyle={styles.cursorDefault} - style={styles.h100} - > - - - ) : ( - { - if (!transactionID) { - return; - } + > + + + ) : ( + { + if (!transactionID) { + return; + } - Navigation.navigate( - isReceiptEditable - ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) - : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), - ); - }} - disabled={!shouldDisplayReceipt || isThumbnail} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL} - disabledStyle={styles.cursorDefault} - style={receiptThumbnailStyle} - > - - - ))} + Navigation.navigate( + isReceiptEditable + ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType) + : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID), + ); + }} + disabled={!shouldDisplayReceipt || isThumbnail} + accessibilityRole={CONST.ROLE.BUTTON} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL} + disabledStyle={styles.cursorDefault} + style={receiptThumbnailStyle} + > + + + ))} ); }, [ From 0ea59707e415a3b589dc327f686adb63cd3773e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 18 Mar 2026 16:31:38 +0100 Subject: [PATCH 09/23] fix: revert faulty removal of props from memo comparison --- src/components/MoneyRequestConfirmationList.tsx | 1 + src/components/MoneyRequestConfirmationListFooter.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index ca59f27714321..601a6c0c27732 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1414,5 +1414,6 @@ export default memo( prevProps.isTimeRequest === nextProps.isTimeRequest && prevProps.iouTimeCount === nextProps.iouTimeCount && prevProps.iouTimeRate === nextProps.iouTimeRate && + prevProps.shouldHideToSection === nextProps.shouldHideToSection && prevProps.isLoadingReceipt === nextProps.isLoadingReceipt, ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index a4b2a5443ea61..9421ff7724c35 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1374,5 +1374,6 @@ export default memo( prevProps.showMoreFields === nextProps.showMoreFields && prevProps.isTimeRequest === nextProps.isTimeRequest && prevProps.iouTimeCount === nextProps.iouTimeCount && + prevProps.iouTimeRate === nextProps.iouTimeRate && prevProps.isLoadingReceipt === nextProps.isLoadingReceipt, ); From 31a1f0d9ecafaeda20788fa5bd90aadc1ff39dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 01:20:37 +0100 Subject: [PATCH 10/23] fix: restore odometer images when navigating back from confirmation page --- src/libs/OdometerImageUtils.ts | 28 +++++++++++ src/libs/actions/IOU/index.ts | 31 +++++------- src/libs/actions/TransactionEdit.ts | 48 +++++++++++++++++++ .../iou/request/DistanceRequestStartPage.tsx | 22 ++++----- .../step/IOURequestStepDistanceOdometer.tsx | 21 ++++++-- .../index.native.tsx | 4 +- .../IOURequestStepOdometerImage/index.tsx | 4 +- .../routes/TransactionReceiptModalContent.tsx | 4 +- 8 files changed, 121 insertions(+), 41 deletions(-) create mode 100644 src/libs/OdometerImageUtils.ts diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts new file mode 100644 index 0000000000000..2b874acfb5afb --- /dev/null +++ b/src/libs/OdometerImageUtils.ts @@ -0,0 +1,28 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +/** + * Revokes a blob URL previously associated with an odometer image, but only when + * the image has actually changed (i.e. the old URL differs from the new one). + * + * Skips revocation when: + * - The `URL` API is not available (non-browser environments / native) + * - The URI is not a blob: URL (e.g. file:// on native, https:// for uploaded images) + * - The old and new URIs are identical (image was not replaced) + */ +function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { + if (typeof URL === 'undefined') { + return; + } + + const currentUri = typeof image === 'string' ? image : image?.uri; + if (!currentUri?.startsWith('blob:')) { + return; + } + const nextUri = typeof nextImage === 'string' ? nextImage : nextImage?.uri; + if (currentUri === nextUri) { + return; + } + URL.revokeObjectURL(currentUri); +} + +export default revokeOdometerImageUri; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 47d72fd6e80e0..eba1fb0d33dde 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -67,6 +67,7 @@ import {isOffline} from '@libs/Network/NetworkStore'; import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; +import revokeOdometerImageUri from '@libs/OdometerImageUtils'; import {getManagerMcTestParticipant, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; @@ -1660,30 +1661,15 @@ function setMoneyRequestOdometerReading(transactionID: string, startReading: num }); } -function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { - if (typeof URL === 'undefined') { - return; - } - - const currentUri = typeof image === 'string' ? image : image?.uri; - if (!currentUri?.startsWith('blob:')) { - return; - } - const nextUri = typeof nextImage === 'string' ? nextImage : nextImage?.uri; - if (currentUri === nextUri) { - return; - } - URL.revokeObjectURL(currentUri); -} - /** * Set odometer image for a transaction * @param transactionID - The transaction ID * @param imageType - 'start' or 'end' * @param file - The image file (File object on web, URI string on native) * @param isDraft - Whether this is a draft transaction + * @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (false when a backup transaction exists making the caller responsible for revoking) */ -function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean) { +function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean, shouldRevokeOldImage = true) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const normalizedFile: FileObject | string = typeof file === 'string' @@ -1696,7 +1682,9 @@ function setMoneyRequestOdometerImage(transactionID: string, imageType: Odometer }; const transaction = isDraft ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const existingImage = transaction?.comment?.[imageKey]; - revokeOdometerImageUri(existingImage, normalizedFile); + if (shouldRevokeOldImage) { + revokeOdometerImageUri(existingImage, normalizedFile); + } Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { [imageKey]: normalizedFile, @@ -1709,12 +1697,15 @@ function setMoneyRequestOdometerImage(transactionID: string, imageType: Odometer * @param transactionID - The transaction ID * @param imageType - 'start' or 'end' * @param isDraft - Whether this is a draft transaction + * @param shouldRevokeOldImage - Whether to revoke the previous blob URL immediately (false when a backup transaction exists making the caller responsible for revoking) */ -function removeMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, isDraft: boolean) { +function removeMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, isDraft: boolean, shouldRevokeOldImage = true) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const transaction = isDraft ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const existingImage = transaction?.comment?.[imageKey]; - revokeOdometerImageUri(existingImage); + if (shouldRevokeOldImage) { + revokeOdometerImageUri(existingImage); + } Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { [imageKey]: null, diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 3450b33c9ead3..e9b4f96ef86b2 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -2,6 +2,7 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {Connection, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {formatCurrentUserToAttendee} from '@libs/IOUUtils'; +import revokeOdometerImageUri from '@libs/OdometerImageUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; @@ -183,10 +184,57 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs return newTransaction; } +function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { + if (!transactionID) { + return; + } + const backupConn = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(backupConn); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(backupTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(backupTransaction?.comment?.odometerEndImage, currentTransaction?.comment?.odometerEndImage); + removeBackupTransaction(transactionID); + onComplete?.(); + }, + }); + }, + }); +} + +function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { + if (!transactionID) { + return; + } + connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(connection); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); + removeBackupTransaction(transactionID); + onComplete?.(); + }, + }); + }, + }); +} + export { createBackupTransaction, removeBackupTransaction, + removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackup, + restoreOriginalTransactionFromBackupWithImageCleanup, createDraftTransaction, removeDraftTransaction, removeTransactionReceipt, diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 2ff4962c1786c..c5b0fd3d9deda 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -223,18 +223,16 @@ function DistanceRequestStartPage({ )} - {showOdometerTab && ( - - {() => ( - - - - )} - - )} + + {() => ( + + + + )} + diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 5fc42464543dd..fe3d726bc21c5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -28,6 +28,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance, setMoneyRequestOdometerReading, updateMoneyRequestDistance} from '@libs/actions/IOU'; import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; +import {createBackupTransaction, removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; @@ -227,6 +228,14 @@ function IOURequestStepDistanceOdometer({ } }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); + useEffect(() => { + if (!isEditingConfirmation) { + return; + } + createBackupTransaction(currentTransaction, isTransactionDraft); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Calculate total distance - updated live after every input change const totalDistance = (() => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); @@ -342,11 +351,14 @@ function IOURequestStepDistanceOdometer({ const navigateBack = useCallback(() => { if (isEditingConfirmation) { - Navigation.goBack(confirmationRoute); + // User didn't saved - we restore original from backup and cleanup blob urls of new images + restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft, () => { + Navigation.goBack(confirmationRoute); + }); return; } Navigation.goBack(); - }, [isEditingConfirmation, confirmationRoute]); + }, [isEditingConfirmation, confirmationRoute, transactionID, isTransactionDraft]); const handlePressStartImage = useCallback(() => { if (odometerStartImage) { @@ -424,7 +436,10 @@ function IOURequestStepDistanceOdometer({ } if (isEditingConfirmation) { - Navigation.goBack(confirmationRoute); + // User saved - we cleanup blob urls of original images from backup + removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft, () => { + Navigation.goBack(confirmationRoute); + }); return; } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index cfedd87c414db..a12e9df64728b 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -184,7 +184,7 @@ function IOURequestStepOdometerImage({ const file = files.at(0); const imageUri = (file as {uri?: string}).uri ?? ''; - setMoneyRequestOdometerImage(transactionID, imageType, imageUri, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, imageUri, isTransactionDraft, isEditingConfirmation !== 'true'); navigateBack(); }; @@ -238,7 +238,7 @@ function IOURequestStepOdometerImage({ const imageObject: ImageObject = {file: photo, filename: photo.path, source: getPhotoSource(photo.path)}; cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, undefined, photo.orientation) .then(({source}) => { - setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft, isEditingConfirmation !== 'true'); navigateBack(); }) .catch((error: unknown) => { diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 3c5d9a94e4ec9..d41fd3bd45e1e 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -88,7 +88,7 @@ function IOURequestStepOdometerImage({ }; const handleImageSelected = (file: FileObject) => { - setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft, isEditingConfirmation !== 'true'); shouldRevokeOnUnmountRef.current = false; navigateBack(); }; @@ -230,7 +230,7 @@ function IOURequestStepOdometerImage({ if (source !== imageObject.source) { URL.revokeObjectURL(imageObject.source); } - setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft, isEditingConfirmation !== 'true'); navigateBack(); }) .catch((error: unknown) => { diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index bc47966ac66b8..a24b950dbe3ff 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -245,9 +245,9 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre if (!transaction?.transactionID || !imageType) { return; } - removeMoneyRequestOdometerImage(transaction.transactionID, imageType, isDraftTransaction); + removeMoneyRequestOdometerImage(transaction.transactionID, imageType, isDraftTransaction, !isEditingConfirmation); navigation.goBack(); - }, [transaction?.transactionID, imageType, isDraftTransaction, navigation]); + }, [transaction?.transactionID, imageType, isDraftTransaction, isEditingConfirmation, navigation]); const onDownloadAttachment = useDownloadAttachment({ isAuthTokenRequired, From f3b35f28b5c7ad6b6ecbca27f77ef15378af86a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 01:39:15 +0100 Subject: [PATCH 11/23] fix: prevent auto-redirect to odometer tab after replacing image and going back --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 75e0b8508d705..dc298ea0a1e3b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/core'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -259,6 +260,7 @@ function IOURequestStepConfirmation({ const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const isFocused = useIsFocused(); const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; @@ -391,7 +393,7 @@ function IOURequestStepConfirmation({ const odometerEndImage = transaction?.comment?.odometerEndImage; useEffect(() => { - if (!isOdometerDistanceRequest) { + if (!isOdometerDistanceRequest || !isFocused) { return; } @@ -437,7 +439,7 @@ function IOURequestStepConfirmation({ return () => { ignore = true; }; - }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); + }, [isOdometerDistanceRequest, isFocused, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); const defaultBillable = !!policy?.defaultBillable; useEffect(() => { From 1d3105ef93d990d8f27576299f000f4843090f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 17:02:57 +0100 Subject: [PATCH 12/23] fix: clear stale odometer backup transaction on distance tab view focus --- .../iou/request/DistanceRequestStartPage.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index a5ca24677e96c..8d553b3c8afef 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -14,6 +14,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; +import {removeBackupTransaction} from '@libs/actions/TransactionEdit'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -142,6 +143,14 @@ function DistanceRequestStartPage({ ], ); + // When the tab creation view is focused, any existing backup for this transaction is stale - + // backups are only meaningful for the standalone odometer step (accessed from confirmation). + useFocusEffect( + useCallback(() => { + removeBackupTransaction(route.params.transactionID); + }, [route.params.transactionID]), + ); + // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID. useFocusEffect( useCallback(() => { @@ -224,16 +233,18 @@ function DistanceRequestStartPage({ )} - - {() => ( - - - - )} - + {showOdometerTab && ( + + {() => ( + + + + )} + + )} From 04ede8d2a8f473065d6b7a5942de5e2599290d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 20:01:01 +0100 Subject: [PATCH 13/23] fix: bypass AsyncStorage race condition when creating fresh odometer backup --- src/libs/actions/TransactionEdit.ts | 11 ++++++++++- .../request/step/IOURequestStepDistanceOdometer.tsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 6e115eabda06a..1c8cbe1a0ffd4 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -13,7 +13,7 @@ let connection: Connection; /** * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction. */ -function createBackupTransaction(transaction: OnyxEntry, isDraft: boolean) { +function createBackupTransaction(transaction: OnyxEntry, isDraft: boolean, shouldAlwaysCreateFreshBackup = false) { if (!transaction) { return; } @@ -25,6 +25,15 @@ function createBackupTransaction(transaction: OnyxEntry, isDraft: b const newTransaction = { ...transaction, }; + + // When shouldAlwaysCreateFreshBackup is true, skip reading the existing backup entirely and directly overwrite it. + // This avoids a race condition where connectWithoutView would fall back to reading from AsyncStorage (which may still + // contain a stale backup from a previous session even if the cache entry was dropped), restoring corrupted data. + if (shouldAlwaysCreateFreshBackup) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, newTransaction); + return; + } + // We need to read the old transaction backup first before writing a new one, otherwise we might overwrite an existing backup. It does not update impact UI rendering since this function is called on page mount. const conn = Onyx.connectWithoutView({ key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transaction.transactionID}`, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index fe3d726bc21c5..2cef42d980234 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -232,7 +232,7 @@ function IOURequestStepDistanceOdometer({ if (!isEditingConfirmation) { return; } - createBackupTransaction(currentTransaction, isTransactionDraft); + createBackupTransaction(currentTransaction, isTransactionDraft, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From b1e7baa79f47e7923b8fac6df66494121d388b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 21:56:09 +0100 Subject: [PATCH 14/23] fix: skip odometer image re-stitching when source images unchanged --- src/libs/OdometerImageUtils.ts | 9 ++++++-- src/libs/stitchOdometerImages/index.native.ts | 5 +++-- src/libs/stitchOdometerImages/index.ts | 5 +++-- .../step/IOURequestStepConfirmation.tsx | 22 +++++++++++++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 2b874acfb5afb..721bd2c19e6bb 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -1,5 +1,9 @@ import type {FileObject} from '@src/types/utils/Attachment'; +function getOdometerImageUri(image: FileObject | string | null | undefined): string | undefined { + return typeof image === 'string' ? image : image?.uri; +} + /** * Revokes a blob URL previously associated with an odometer image, but only when * the image has actually changed (i.e. the old URL differs from the new one). @@ -14,15 +18,16 @@ function revokeOdometerImageUri(image: FileObject | string | null | undefined, n return; } - const currentUri = typeof image === 'string' ? image : image?.uri; + const currentUri = getOdometerImageUri(image); if (!currentUri?.startsWith('blob:')) { return; } - const nextUri = typeof nextImage === 'string' ? nextImage : nextImage?.uri; + const nextUri = getOdometerImageUri(nextImage); if (currentUri === nextUri) { return; } URL.revokeObjectURL(currentUri); } +export {getOdometerImageUri}; export default revokeOdometerImageUri; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 4bef73052c8f8..86160903bd523 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -1,13 +1,14 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import type {FileObject} from '@src/types/utils/Attachment'; import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { - const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + const source1 = getOdometerImageUri(image1) ?? null; + const source2 = getOdometerImageUri(image2) ?? null; if (!source1 || !source2) { return null; diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 51bfac1c8a1d5..88ec343efc9c6 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,3 +1,4 @@ +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import type {FileObject} from '@src/types/utils/Attachment'; import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; @@ -6,8 +7,8 @@ import calculateStitchLayout from './stitchLayout'; let previousBlobUrl: string | null = null; function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { - const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); - const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + const source1 = getOdometerImageUri(image1) ?? null; + const source2 = getOdometerImageUri(image2) ?? null; if (!source1 || !source2) { return Promise.resolve(null); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f6905af2d05ee..08c12102001c6 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -55,6 +55,7 @@ import Log from '@libs/Log'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import { @@ -317,6 +318,10 @@ function IOURequestStepConfirmation({ const [isConfirming, setIsConfirming] = useState(false); const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); const [stitchError, setStitchError] = useState(''); + const lastStitchedImages = useRef<{ + startImage: FileObject | string | undefined; + endImage: FileObject | string | undefined; + } | null>(null); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -423,6 +428,18 @@ function IOURequestStepConfirmation({ return; } + // Skip stitching when source images haven't changed (compare by URI not reference + // because Onyx may create new object instances when restoring a backup transaction) + const startUri = getOdometerImageUri(odometerStartImage); + const endUri = getOdometerImageUri(odometerEndImage); + if ( + lastStitchedImages.current !== null && + getOdometerImageUri(lastStitchedImages.current.startImage) === startUri && + getOdometerImageUri(lastStitchedImages.current.endImage) === endUri + ) { + return; + } + if (!odometerStartImage || !odometerEndImage) { const singleImage = odometerStartImage ?? odometerEndImage; @@ -430,10 +447,10 @@ function IOURequestStepConfirmation({ return; } - const getImageUri = (img: typeof singleImage): string => (typeof img === 'string' ? img : (img.uri ?? '')); const getImageName = (img: typeof singleImage): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img.name ?? '')); - setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action)); + setMoneyRequestReceipt(currentTransactionID, getOdometerImageUri(singleImage) ?? '', getImageName(singleImage), shouldUseTransactionDraft(action)); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; return; } @@ -447,6 +464,7 @@ function IOURequestStepConfirmation({ return; } setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; }) .catch((error: unknown) => { if (ignore) { From 0c2aadef48d36d238597b4b9a660e9237ed30f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 19 Mar 2026 22:13:02 +0100 Subject: [PATCH 15/23] fix: preserve odometer image blob URLs during crop/rotate in edit mode --- .../routes/TransactionReceiptModalContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 0da2c941126e7..13e4a98816e8a 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -304,7 +304,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const rotatedFilename = file.name ?? receiptFilename; if (isOdometerImage) { - setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction); + setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction, !isEditingConfirmation); } else if (isDraftTransaction) { setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, fileType); } else { @@ -391,7 +391,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const croppedFilename = file.name ?? receiptFilename; if (isOdometerImage) { - setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction); + setMoneyRequestOdometerImage(transaction.transactionID, imageType, file, isDraftTransaction, !isEditingConfirmation); } else if (isDraftTransaction) { setMoneyRequestReceipt(transaction.transactionID, imageUriResult, croppedFilename, isDraftTransaction, fileType); } else { From a8bb8d731dc4e38d5deb58737651da5946093634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 01:17:23 +0100 Subject: [PATCH 16/23] fix: detect expired odometer blob URLs and restart flow on web --- .../useRestartOnOdometerImagesFailure.ts | 88 +++++++++++++++++++ src/hooks/useRestartOnReceiptFailure.ts | 4 +- src/libs/actions/IOU/index.ts | 6 +- .../fileDownload/validateReceiptFile/index.ts | 4 +- .../step/IOURequestStepConfirmation.tsx | 75 ++++++++++++---- .../step/IOURequestStepDistanceOdometer.tsx | 3 + .../request/step/IOURequestStepScan/index.tsx | 4 +- .../routes/TransactionReceiptModalContent.tsx | 8 ++ 8 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useRestartOnOdometerImagesFailure.ts diff --git a/src/hooks/useRestartOnOdometerImagesFailure.ts b/src/hooks/useRestartOnOdometerImagesFailure.ts new file mode 100644 index 0000000000000..020d7f2d2a1c1 --- /dev/null +++ b/src/hooks/useRestartOnOdometerImagesFailure.ts @@ -0,0 +1,88 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {checkIfLocalFileIsAccessible, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading, setMoneyRequestReceipt} from '@libs/actions/IOU'; +import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; +import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getRequestType} from '@libs/TransactionUtils'; +import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; +import type {Transaction} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import useOnyx from './useOnyx'; + +// When the component mounts, if there are odometer images or a stitched receipt, see if the files can be read from the disk. +// If not, redirect the user to the starting step of the flow. +// This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs +// and if the browser is refreshed, then the images cease to exist. +// The best way for the user to recover from this is to start over from the start of the request process. +const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry, reportID: string, iouType: IOUType, action: IOUAction) => { + const [draftTransactionIDs, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + useEffect(() => { + if (!transaction || action !== CONST.IOU.ACTION.CREATE || isLoadingOnyxValue(draftTransactionsMetadata)) { + return; + } + + const startImage = transaction.comment?.odometerStartImage; + const endImage = transaction.comment?.odometerEndImage; + const stitchedUri = transaction.receipt?.source?.toString(); + + const urlsToCheck = [ + { + filename: typeof startImage === 'object' ? startImage?.name : undefined, + path: getOdometerImageUri(startImage), + type: typeof startImage === 'object' ? startImage?.type : undefined, + }, + { + filename: typeof endImage === 'object' ? endImage?.name : undefined, + path: getOdometerImageUri(endImage), + type: typeof endImage === 'object' ? endImage?.type : undefined, + }, + { + filename: transaction.receipt?.filename, + path: stitchedUri, + type: undefined, + }, + ].filter(({path}) => !!path && path.startsWith('blob:')); + + if (urlsToCheck.length === 0) { + return; + } + + let canBeRead = true; + + Promise.all( + urlsToCheck.map(({filename, path, type}) => + checkIfLocalFileIsAccessible( + filename, + path, + type, + () => {}, + () => { + canBeRead = false; + }, + ), + ), + )?.then(() => { + const requestType = getRequestType(transaction); + if (canBeRead || requestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + return; + } + + setMoneyRequestReceipt(transaction.transactionID, '', '', true); + setMoneyRequestOdometerReading(transaction.transactionID, null, null, true); + removeMoneyRequestOdometerImage(transaction.transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true); + removeMoneyRequestOdometerImage(transaction.transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END, true); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + navigateToStartMoneyRequestStep(requestType, iouType, transaction.transactionID, reportID); + }); + + // We want this hook to run on mounting only + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftTransactionsMetadata]); +}; + +export default useRestartOnOdometerImagesFailure; diff --git a/src/hooks/useRestartOnReceiptFailure.ts b/src/hooks/useRestartOnReceiptFailure.ts index 73f36e0e334df..a3a8186d95e47 100644 --- a/src/hooks/useRestartOnReceiptFailure.ts +++ b/src/hooks/useRestartOnReceiptFailure.ts @@ -1,6 +1,6 @@ import {useEffect} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {checkIfScanFileCanBeRead, setMoneyRequestReceipt} from '@libs/actions/IOU'; +import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@libs/actions/IOU'; import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; import {isLocalFile as isLocalFileUtil} from '@libs/fileDownload/FileUtils'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; @@ -40,7 +40,7 @@ const useRestartOnReceiptFailure = (transaction: OnyxEntry, reportI setMoneyRequestReceipt(transaction.transactionID, '', '', true); }; - checkIfScanFileCanBeRead(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => { + checkIfLocalFileIsAccessible(itemReceiptFilename, itemReceiptPath, itemReceiptType, () => {}, onFailure)?.then(() => { const requestType = getRequestType(transaction); if (isScanFilesCanBeRead || requestType !== CONST.IOU.REQUEST_TYPE.SCAN) { return; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 0eff54b840cce..06f0127190be4 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1652,7 +1652,7 @@ function setMoneyRequestDistance(transactionID: string, distanceAsFloat: number, /** * Set the odometer readings for a transaction */ -function setMoneyRequestOdometerReading(transactionID: string, startReading: number, endReading: number, isDraft: boolean) { +function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { odometerStart: startReading, @@ -11754,7 +11754,7 @@ function navigateToStartStepIfScanFileCannotBeRead( readFileAsync(receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); } -function checkIfScanFileCanBeRead( +function checkIfLocalFileIsAccessible( receiptFilename: string | undefined, receiptPath: ReceiptSource | undefined, receiptType: string | undefined, @@ -13136,7 +13136,7 @@ export { getIOURequestPolicyID, getReportOriginalCreationTimestamp, initMoneyRequest, - checkIfScanFileCanBeRead, + checkIfLocalFileIsAccessible, dismissModalAndOpenReportInInboxTab, navigateToStartStepIfScanFileCannotBeRead, completePaymentOnboarding, diff --git a/src/libs/fileDownload/validateReceiptFile/index.ts b/src/libs/fileDownload/validateReceiptFile/index.ts index e0bb020a441bf..5bb725d828af9 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.ts @@ -1,4 +1,4 @@ -import {checkIfScanFileCanBeRead} from '@libs/actions/IOU'; +import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; /** @@ -12,7 +12,7 @@ function validateReceiptFile( onSuccess: (file: File) => void, onFailure: () => void, ): Promise | undefined { - return checkIfScanFileCanBeRead(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); + return checkIfLocalFileIsAccessible(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } export default validateReceiptFile; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 08c12102001c6..d023eaf0f0367 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -32,6 +32,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {completeTestDriveTask} from '@libs/actions/Task'; @@ -88,6 +89,7 @@ import { } from '@libs/TransactionUtils'; import type {GpsPoint} from '@userActions/IOU'; import { + checkIfLocalFileIsAccessible, createDistanceRequest as createDistanceRequestIOUActions, getIOURequestPolicyID, requestMoney as requestMoneyIOUActions, @@ -420,6 +422,8 @@ function IOURequestStepConfirmation({ } }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, action); + const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; @@ -458,32 +462,71 @@ function IOURequestStepConfirmation({ setIsStitchingReceipt(true); setStitchError(''); - stitchOdometerImages(odometerStartImage, odometerEndImage) - .then((stitchedImage) => { - if (ignore || !stitchedImage) { - return; - } - setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - }) - .catch((error: unknown) => { + const runStitch = () => { + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { + return; + } + setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + }) + .catch((error: unknown) => { + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); + setStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => { + if (ignore) { + return; + } + setIsStitchingReceipt(false); + }); + }; + + // Pre-flight: verify blob URLs haven't expired before attempting to stitch (edge case guard) + const getOdometerFilename = (img: typeof odometerStartImage): string => (typeof img === 'object' ? img?.name : undefined) ?? 'odometer-image.jpg'; + const localImages = [ + {uri: startUri, image: odometerStartImage}, + {uri: endUri, image: odometerEndImage}, + ].filter((item): item is {uri: string; image: typeof odometerStartImage} => !!item.uri && item.uri.startsWith('blob:')); + if (localImages.length > 0) { + let hasExpiredImages = false; + Promise.all( + localImages.map(({uri, image}) => + checkIfLocalFileIsAccessible( + getOdometerFilename(image), + uri, + typeof image === 'object' ? image?.type : undefined, + () => {}, + () => { + hasExpiredImages = true; + }, + ), + ), + ).then(() => { if (ignore) { return; } - Log.warn('stitchOdometerImages failed', {error}); - setStitchError(translate('iou.error.stitchOdometerImagesFailed')); - }) - .finally(() => { - if (ignore) { + if (hasExpiredImages) { + setIsStitchingReceipt(false); + setMoneyRequestReceipt(currentTransactionID, '', '', shouldUseTransactionDraft(action)); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + navigateToStartMoneyRequestStep(requestType, iouType, currentTransactionID, reportID); return; } - setIsStitchingReceipt(false); + runStitch(); }); + } else { + runStitch(); + } return () => { ignore = true; }; - }, [isOdometerDistanceRequest, isFocused, currentTransactionID, odometerStartImage, odometerEndImage, action, translate]); + }, [isOdometerDistanceRequest, isFocused, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, draftTransactionIDs, requestType, iouType, reportID]); const defaultBillable = !!policy?.defaultBillable; useEffect(() => { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 2cef42d980234..43a941d349656 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -21,6 +21,7 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportAttributes from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -148,6 +149,8 @@ function IOURequestStepDistanceOdometer({ setShouldEnableDiscardConfirmation(!isEditingConfirmation && !isEditing); }, [isEditing, isEditingConfirmation]); + useRestartOnOdometerImagesFailure(transaction, reportID, iouType, action); + // Get odometer images from transaction (only for display, not for initialization) const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 1e291b658d86c..74f522e87816d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -12,7 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; -import {checkIfScanFileCanBeRead, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {checkIfLocalFileIsAccessible, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; import {removeDraftTransactionsByIDs, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -114,7 +114,7 @@ function IOURequestStepScan({ isAllScanFilesCanBeRead = false; }; - return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + return checkIfLocalFileIsAccessible(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); }), ).then(() => { if (isAllScanFilesCanBeRead) { diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 13e4a98816e8a..31c3b9f9a4ab5 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useThemeStyles from '@hooks/useThemeStyles'; import { detachReceipt, @@ -94,6 +95,13 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre return transactionMain; }, [isDraftTransaction, mergeTransaction, mergeTransactionID, transactionDraft, transactionMain]); + useRestartOnOdometerImagesFailure( + isDraftTransaction && isOdometerDistanceRequest(transaction) ? transaction : undefined, + reportID, + iouTypeParam ?? CONST.IOU.TYPE.SUBMIT, + action ?? CONST.IOU.ACTION.CREATE, + ); + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); const receiptURIs = getThumbnailAndImageURIs(transaction); const isLocalFile = receiptURIs.isLocalFile; From 4dc43f55469a506a4bf7a773cdba2f1a8f1a6871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 02:03:55 +0100 Subject: [PATCH 17/23] fix: use getMimeTypeFromUri for odometer image MIME type on native --- .../IOURequestStepOdometerImage/index.native.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 83a1f45890b43..ccf65ed87d1cd 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -23,7 +23,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import {getMimeTypeFromUri, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import getPlatform from '@libs/getPlatform'; import type Platform from '@libs/getPlatform/types'; @@ -187,7 +187,13 @@ function IOURequestStepOdometerImage({ return; } const imageUri = (file as {uri?: string}).uri ?? ''; - setMoneyRequestOdometerImage(transactionID, imageType, imageUri, isTransactionDraft, isEditingConfirmation !== 'true'); + setMoneyRequestOdometerImage( + transactionID, + imageType, + {uri: imageUri, name: imageUri.split('/').pop() ?? '', type: getMimeTypeFromUri(imageUri) ?? 'image/jpeg'}, + isTransactionDraft, + isEditingConfirmation !== 'true', + ); navigateBack(); }; @@ -247,7 +253,7 @@ function IOURequestStepOdometerImage({ { uri: source, name: filename, - type: (file as FileObject | undefined)?.type ?? 'image/jpeg', + type: (file as FileObject | undefined)?.type ?? getMimeTypeFromUri(source) ?? 'image/jpeg', size: (file as FileObject | undefined)?.size, }, isTransactionDraft, From 33628dbdef6e11c2e2c9ca03b25d40c9a3355314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 02:13:46 +0100 Subject: [PATCH 18/23] fix: resolve receipt uri, name, and type fallbacks for odometer stitching --- .../request/step/IOURequestStepConfirmation.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d023eaf0f0367..d18d49ddd69f8 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -40,7 +40,7 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import {isLocalFile as isLocalFileFileUtils, getMimeTypeFromUri} from '@libs/fileDownload/FileUtils'; import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -468,7 +468,18 @@ function IOURequestStepConfirmation({ if (ignore || !stitchedImage) { return; } - setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); + const uri = stitchedImage?.uri ?? startUri ?? endUri ?? ''; + const name = + stitchedImage?.name ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ?? + ''; + const type = + stitchedImage?.type ?? + (typeof odometerStartImage !== 'string' ? odometerStartImage?.type : getMimeTypeFromUri(odometerStartImage)) ?? + (typeof odometerEndImage !== 'string' ? odometerEndImage?.type : getMimeTypeFromUri(odometerEndImage)) ?? + 'image/jpeg'; + setMoneyRequestReceipt(currentTransactionID, uri, name, shouldUseTransactionDraft(action), type); lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; }) .catch((error: unknown) => { From 01ffd36aa7dcc20d43a67c2b752205de9f358e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 02:19:39 +0100 Subject: [PATCH 19/23] chore: prettier run --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d18d49ddd69f8..256e89ad0ffef 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -40,7 +40,7 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {isLocalFile as isLocalFileFileUtils, getMimeTypeFromUri} from '@libs/fileDownload/FileUtils'; +import {getMimeTypeFromUri, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; From 19b146407cacd0b964d7f333d9a78dab3d72ad54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 02:56:18 +0100 Subject: [PATCH 20/23] fix: replace deprecated StyleSheet.absoluteFillObject with StyleSheet.absoluteFill --- .../request/step/IOURequestStepOdometerImage/index.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index ccf65ed87d1cd..29c8c71db982f 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -371,7 +371,7 @@ function IOURequestStepOdometerImage({ From bbe2f10dfe8cfee151dd18c894febcaac5d6b209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 03:06:00 +0100 Subject: [PATCH 21/23] fix: add required reasonAttributes prop to ActivityIndicator in MoneyRequestConfirmationListFooter --- src/components/MoneyRequestConfirmationListFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 9421ff7724c35..4aab8702774dc 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1097,7 +1097,7 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestImage, receiptContainerStyle, isLoadingReceipt && [styles.justifyContentCenter, styles.alignItemsCenter]]} onLayout={isCompactMode ? handleCompactReceiptContainerLayout : undefined} > - {isLoadingReceipt && } + {isLoadingReceipt && } {!isLoadingReceipt && (isLocalFile && Str.isPDF(receiptFilename) ? ( Date: Fri, 20 Mar 2026 03:16:30 +0100 Subject: [PATCH 22/23] fix: add blob URL loss detection to odometer image step --- src/hooks/useRestartOnOdometerImagesFailure.ts | 3 ++- .../iou/request/step/IOURequestStepOdometerImage/index.tsx | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useRestartOnOdometerImagesFailure.ts b/src/hooks/useRestartOnOdometerImagesFailure.ts index 020d7f2d2a1c1..889fd7d4ac686 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure.ts @@ -1,4 +1,5 @@ import {useEffect} from 'react'; +import {Platform} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {checkIfLocalFileIsAccessible, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading, setMoneyRequestReceipt} from '@libs/actions/IOU'; import {removeDraftTransactionsByIDs} from '@libs/actions/TransactionEdit'; @@ -22,7 +23,7 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry, const [draftTransactionIDs, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); useEffect(() => { - if (!transaction || action !== CONST.IOU.ACTION.CREATE || isLoadingOnyxValue(draftTransactionsMetadata)) { + if (Platform.OS !== 'web' || !transaction || action !== CONST.IOU.ACTION.CREATE || isLoadingOnyxValue(draftTransactionsMetadata)) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index d41fd3bd45e1e..04a85d0b97c72 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -17,6 +17,7 @@ import useFilesValidation from '@hooks/useFilesValidation'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isMobile, isMobileWebKit} from '@libs/Browser'; @@ -43,6 +44,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; function IOURequestStepOdometerImage({ + transaction, route: { params: {action, iouType, transactionID, reportID, backToReport, imageType, isEditingConfirmation}, }, @@ -54,6 +56,8 @@ function IOURequestStepOdometerImage({ const actionValue: IOUAction = action ?? CONST.IOU.ACTION.CREATE; const iouTypeValue: IOUType = iouType ?? CONST.IOU.TYPE.REQUEST; const isTransactionDraft = shouldUseTransactionDraft(actionValue, iouTypeValue); + + useRestartOnOdometerImagesFailure(transaction, reportID, iouTypeValue, actionValue); const dropBlobUrlsRef = useRef([]); const shouldRevokeOnUnmountRef = useRef(true); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout because drag and drop is not supported on mobile. From 3c7e9eb991a8fcf73b8bd2f36365898630f31d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 20 Mar 2026 04:00:09 +0100 Subject: [PATCH 23/23] fix: import useIsFocused from @react-navigation/native instead of core --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 256e89ad0ffef..fa9147d926a29 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,4 +1,4 @@ -import {useIsFocused} from '@react-navigation/core'; +import {useIsFocused} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';