Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f59d792
refactor: move odometer image stitching to confirmation page
jakubkalinski0 Mar 12, 2026
4fdf7a8
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 13, 2026
d39c77b
improvement: extract stitched odometer filename prefix to constants
jakubkalinski0 Mar 13, 2026
2307c1f
fix: guard stale async state updates in odometer image stitching effect
jakubkalinski0 Mar 13, 2026
16b0064
feat: add util to detect stitched odometer receipt filenames
jakubkalinski0 Mar 13, 2026
2c73b29
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 16, 2026
0d018ec
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 17, 2026
1211833
refactor: skip stitching when only one odometer image is present
jakubkalinski0 Mar 17, 2026
e35804a
refactor: remove unused isStitchedOdometerReceiptFilename function
jakubkalinski0 Mar 17, 2026
c75f917
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 18, 2026
9e28da6
fix: remove image preview flickering by moving activity indicator to …
jakubkalinski0 Mar 18, 2026
e96e8e2
chore: prettier run
jakubkalinski0 Mar 18, 2026
0ea5970
fix: revert faulty removal of props from memo comparison
jakubkalinski0 Mar 18, 2026
31a1f0d
fix: restore odometer images when navigating back from confirmation page
jakubkalinski0 Mar 19, 2026
f3b35f2
fix: prevent auto-redirect to odometer tab after replacing image and …
jakubkalinski0 Mar 19, 2026
0b2e53b
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 19, 2026
c548b73
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 19, 2026
1d3105e
fix: clear stale odometer backup transaction on distance tab view focus
jakubkalinski0 Mar 19, 2026
04ede8d
fix: bypass AsyncStorage race condition when creating fresh odometer …
jakubkalinski0 Mar 19, 2026
b1e7baa
fix: skip odometer image re-stitching when source images unchanged
jakubkalinski0 Mar 19, 2026
0c2aade
fix: preserve odometer image blob URLs during crop/rotate in edit mode
jakubkalinski0 Mar 19, 2026
a8bb8d7
fix: detect expired odometer blob URLs and restart flow on web
jakubkalinski0 Mar 20, 2026
a29f5d9
Merge branch 'main' into jakubkalinski0/Odometer_move_odometer_image_…
jakubkalinski0 Mar 20, 2026
4dc43f5
fix: use getMimeTypeFromUri for odometer image MIME type on native
jakubkalinski0 Mar 20, 2026
33628db
fix: resolve receipt uri, name, and type fallbacks for odometer stitc…
jakubkalinski0 Mar 20, 2026
01ffd36
chore: prettier run
jakubkalinski0 Mar 20, 2026
19b1464
fix: replace deprecated StyleSheet.absoluteFillObject with StyleSheet…
jakubkalinski0 Mar 20, 2026
bbe2f10
fix: add required reasonAttributes prop to ActivityIndicator in Money…
jakubkalinski0 Mar 20, 2026
27d182d
fix: add blob URL loss detection to odometer image step
jakubkalinski0 Mar 20, 2026
3c7e9eb
fix: import useIsFocused from @react-navigation/native instead of core
jakubkalinski0 Mar 20, 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
8 changes: 7 additions & 1 deletion src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,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;

Expand Down Expand Up @@ -237,6 +240,7 @@ function MoneyRequestConfirmationList({
isDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest = false,
isLoadingReceipt = false,
isGPSDistanceRequest,
isPerDiemRequest = false,
isPolicyExpenseChat = false,
Expand Down Expand Up @@ -1315,6 +1319,7 @@ function MoneyRequestConfirmationList({
isDistanceRequest={isDistanceRequest}
isManualDistanceRequest={isManualDistanceRequest}
isOdometerDistanceRequest={isOdometerDistanceRequest}
isLoadingReceipt={isLoadingReceipt}
isGPSDistanceRequest={isGPSDistanceRequest}
isPerDiemRequest={isPerDiemRequest}
isTimeRequest={isTimeRequest}
Expand Down Expand Up @@ -1410,5 +1415,6 @@ export default memo(
prevProps.isTimeRequest === nextProps.isTimeRequest &&
prevProps.iouTimeCount === nextProps.iouTimeCount &&
prevProps.iouTimeRate === nextProps.iouTimeRate &&
prevProps.shouldHideToSection === nextProps.shouldHideToSection,
prevProps.shouldHideToSection === nextProps.shouldHideToSection &&
prevProps.isLoadingReceipt === nextProps.isLoadingReceipt,
);
159 changes: 85 additions & 74 deletions src/components/MoneyRequestConfirmationListFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -281,6 +285,7 @@ function MoneyRequestConfirmationListFooter({
isDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest = false,
isLoadingReceipt = false,
isGPSDistanceRequest,
isPerDiemRequest,
isTimeRequest,
Expand Down Expand Up @@ -1089,91 +1094,92 @@ function MoneyRequestConfirmationListFooter({

return (
<View
style={[styles.moneyRequestImage, receiptContainerStyle]}
style={[styles.moneyRequestImage, receiptContainerStyle, isLoadingReceipt && [styles.justifyContentCenter, styles.alignItemsCenter]]}
onLayout={isCompactMode ? handleCompactReceiptContainerLayout : undefined}
>
{isLocalFile && Str.isPDF(receiptFilename) ? (
<PressableWithoutFocus
onPress={() => {
if (!transactionID) {
return;
}
{isLoadingReceipt && <ActivityIndicator reasonAttributes={{context: 'MoneyRequestConfirmationListFooter'}} />}
{!isLoadingReceipt &&
(isLocalFile && Str.isPDF(receiptFilename) ? (
<PressableWithoutFocus
onPress={() => {
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}
>
<PDFThumbnail
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
previewSourceURL={resolvedReceiptImage as string}
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}
onLoadError={onPDFLoadError}
onPassword={onPDFPassword}
/>
</PressableWithoutFocus>
) : (
<PressableWithoutFocus
onPress={() => {
if (!transactionID) {
return;
}
>
<PDFThumbnail
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
previewSourceURL={resolvedReceiptImage as string}
style={styles.h100}
onLoadError={onPDFLoadError}
onPassword={onPDFPassword}
/>
</PressableWithoutFocus>
) : (
<PressableWithoutFocus
onPress={() => {
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}
>
<ReceiptImage
isThumbnail={isThumbnail}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
source={resolvedThumbnail || resolvedReceiptImage || ''}
// AuthToken is required when retrieving the image from the server
// but we don't need it to load the blob:// or file:// image when starting an expense/split
// So if we have a thumbnail, it means we're retrieving the image from the server
isAuthTokenRequired={!!receiptThumbnail && !isLocalFile}
fileExtension={fileExtension}
shouldUseThumbnailImage
shouldUseInitialObjectPosition={isDistanceRequest}
shouldUseFullHeight={isCompactMode}
onLoad={handleReceiptLoad}
resizeMode={isOdometerDistanceRequest ? 'contain' : undefined}
/>
</PressableWithoutFocus>
)}
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}
>
<ReceiptImage
isThumbnail={isThumbnail}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
source={resolvedThumbnail || resolvedReceiptImage || ''}
// AuthToken is required when retrieving the image from the server
// but we don't need it to load the blob:// or file:// image when starting an expense/split
// So if we have a thumbnail, it means we're retrieving the image from the server
isAuthTokenRequired={!!receiptThumbnail && !isLocalFile}
fileExtension={fileExtension}
shouldUseThumbnailImage
shouldUseInitialObjectPosition={isDistanceRequest}
shouldUseFullHeight={isCompactMode}
onLoad={handleReceiptLoad}
resizeMode={isOdometerDistanceRequest ? 'contain' : undefined}
/>
</PressableWithoutFocus>
))}
</View>
);
}, [
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,
Expand All @@ -1184,9 +1190,13 @@ function MoneyRequestConfirmationListFooter({
receiptThumbnail,
fileExtension,
isDistanceRequest,
isOdometerDistanceRequest,
handleReceiptLoad,
handleCompactReceiptContainerLayout,
isOdometerDistanceRequest,
transactionID,
isReceiptEditable,
reportID,
action,
iouType,
]);

const visibleFields = fields.filter((field) => field.shouldShow);
Expand Down Expand Up @@ -1270,7 +1280,7 @@ function MoneyRequestConfirmationListFooter({
)}
</View>
{(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) &&
(hasReceiptImageOrThumbnail
(hasReceiptImageOrThumbnail || isLoadingReceipt
? receiptThumbnailContent
: showReceiptEmptyState && (
<ReceiptEmptyState
Expand Down Expand Up @@ -1364,5 +1374,6 @@ export default memo(
prevProps.showMoreFields === nextProps.showMoreFields &&
prevProps.isTimeRequest === nextProps.isTimeRequest &&
prevProps.iouTimeCount === nextProps.iouTimeCount &&
prevProps.iouTimeRate === nextProps.iouTimeRate,
prevProps.iouTimeRate === nextProps.iouTimeRate &&
prevProps.isLoadingReceipt === nextProps.isLoadingReceipt,
);
89 changes: 89 additions & 0 deletions src/hooks/useRestartOnOdometerImagesFailure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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';
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<Transaction>, reportID: string, iouType: IOUType, action: IOUAction) => {
const [draftTransactionIDs, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector});

useEffect(() => {
if (Platform.OS !== 'web' || !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;
4 changes: 2 additions & 2 deletions src/hooks/useRestartOnReceiptFailure.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,7 +40,7 @@ const useRestartOnReceiptFailure = (transaction: OnyxEntry<Transaction>, 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;
Expand Down
33 changes: 33 additions & 0 deletions src/libs/OdometerImageUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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).
*
* 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 = getOdometerImageUri(image);
if (!currentUri?.startsWith('blob:')) {
return;
}
const nextUri = getOdometerImageUri(nextImage);
if (currentUri === nextUri) {
return;
}
URL.revokeObjectURL(currentUri);
}

export {getOdometerImageUri};
export default revokeOdometerImageUri;
Loading
Loading