From 00077302efb07362f9b7c9cbc8ae332221e63384 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 23 Apr 2026 11:19:41 +0200 Subject: [PATCH 01/11] Show thumbnail preview while receipt image loads --- src/components/ImageWithLoading.tsx | 21 +++++++++++++++++-- src/components/ReceiptImage/index.tsx | 4 ++++ .../ReportActionItemImage.tsx | 6 ++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 3a84db339adf..f44f5ac6eeb0 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,7 +1,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; @@ -24,6 +24,7 @@ type ImageWithSizeLoadingProps = { /** Invoked on mount and layout changes */ onLayout?: (event: LayoutChangeEvent) => void; + thumbnail320?: string; } & ImageProps; function ImageWithLoading({ @@ -37,12 +38,14 @@ function ImageWithLoading({ onLoad, onLayout, style, + thumbnail320, ...rest }: ImageWithSizeLoadingProps) { const styles = useThemeStyles(); const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [isThumbnailLoading, setIsThumbnailLoading] = useState(!!thumbnail320); const {isOffline} = useNetwork(); const handleError = () => { @@ -109,7 +112,21 @@ function ImageWithLoading({ loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} /> - {isLoading && !isImageCached && !isOffline && ( + {isLoading && !!thumbnail320 && ( + { + setIsThumbnailLoading(false); + }} + loadingIconSize={loadingIconSize} + loadingIndicatorStyles={loadingIndicatorStyles} + /> + )} + {isLoading && isThumbnailLoading && !isImageCached && !isOffline && ( ; + + thumbnail320?: string; }; function ReceiptImage({ @@ -161,6 +163,7 @@ function ReceiptImage({ onLoadFailure, resizeMode, style, + thumbnail320, }: ReceiptImageProps) { const styles = useThemeStyles(); const [receiptImageWidth, setReceiptImageWidth] = useState(undefined); @@ -256,6 +259,7 @@ function ReceiptImage({ onError={onLoadFailure} resizeMode={resizeMode} reasonAttributes={reasonAttributes} + thumbnail320={thumbnail320} /> ); } diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 400c0ab45e10..ec95ff85d042 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportIDForExpense} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; +import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {hasEReceipt, hasReceiptSource, isDistanceRequest, isFetchingWaypointsFromServer, isManualDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; @@ -137,10 +138,13 @@ function ReportActionItemImage({ const localSource = transaction?.receipt?.localSource; const effectiveIsLocalFile = isLocalFile || !!localSource; const effectiveThumbnail = localSource ?? thumbnail; + const receiptURIs = getThumbnailAndImageURIs(transaction, null, null); + const effectiveThumbnail320 = localSource ?? receiptURIs.thumbnail320; const effectiveImage = localSource != null && typeof image === 'string' ? localSource : image; const originalImageSource = tryResolveUrlFromApiRoot(effectiveImage ?? ''); const thumbnailSource = tryResolveUrlFromApiRoot(effectiveThumbnail ?? ''); + const thumbnail320Source = tryResolveUrlFromApiRoot(effectiveThumbnail320 ?? ''); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isPDF = filename && Str.isPDF(filename); @@ -202,6 +206,7 @@ function ReportActionItemImage({ onLoad={onLoad} shouldUseFullHeight={shouldUseFullHeight} onLoadFailure={onLoadFailure} + thumbnail320={thumbnail320Source} /> ); @@ -214,6 +219,7 @@ function ReportActionItemImage({ thumbnailContainerStyles={styles.thumbnailImageContainerHover} onLoad={onLoad} onLoadFailure={onLoadFailure} + thumbnail320={thumbnail320Source} /> ); } From ba49debfe5273f56f6bf1ed9e1ce5f482b1a17a2 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 27 Apr 2026 16:37:11 +0200 Subject: [PATCH 02/11] fixes to the implementation --- src/components/ImageWithLoading.tsx | 14 ++++++++------ src/components/ReceiptImage/index.tsx | 7 ++++--- .../ReportActionItem/ReportActionItemImage.tsx | 10 +++++----- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index f44f5ac6eeb0..360bc8e35c4d 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -24,7 +24,9 @@ type ImageWithSizeLoadingProps = { /** Invoked on mount and layout changes */ onLayout?: (event: LayoutChangeEvent) => void; - thumbnail320?: string; + + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; } & ImageProps; function ImageWithLoading({ @@ -38,14 +40,14 @@ function ImageWithLoading({ onLoad, onLayout, style, - thumbnail320, + previewUri, ...rest }: ImageWithSizeLoadingProps) { const styles = useThemeStyles(); const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); - const [isThumbnailLoading, setIsThumbnailLoading] = useState(!!thumbnail320); + const [isThumbnailLoading, setIsThumbnailLoading] = useState(true); const {isOffline} = useNetwork(); const handleError = () => { @@ -112,11 +114,11 @@ function ImageWithLoading({ loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} /> - {isLoading && !!thumbnail320 && ( + {isLoading && !!previewUri && !isImageCached && ( { @@ -126,7 +128,7 @@ function ImageWithLoading({ loadingIndicatorStyles={loadingIndicatorStyles} /> )} - {isLoading && isThumbnailLoading && !isImageCached && !isOffline && ( + {isLoading && (!previewUri || isThumbnailLoading) && !isImageCached && !isOffline && ( ; - thumbnail320?: string; + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; }; function ReceiptImage({ @@ -163,7 +164,7 @@ function ReceiptImage({ onLoadFailure, resizeMode, style, - thumbnail320, + previewUri, }: ReceiptImageProps) { const styles = useThemeStyles(); const [receiptImageWidth, setReceiptImageWidth] = useState(undefined); @@ -259,7 +260,7 @@ function ReceiptImage({ onError={onLoadFailure} resizeMode={resizeMode} reasonAttributes={reasonAttributes} - thumbnail320={thumbnail320} + previewUri={previewUri} /> ); } diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index ec95ff85d042..eb60af7fc491 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -138,13 +138,13 @@ function ReportActionItemImage({ const localSource = transaction?.receipt?.localSource; const effectiveIsLocalFile = isLocalFile || !!localSource; const effectiveThumbnail = localSource ?? thumbnail; - const receiptURIs = getThumbnailAndImageURIs(transaction, null, null); - const effectiveThumbnail320 = localSource ?? receiptURIs.thumbnail320; + const receiptURIs = transaction ? getThumbnailAndImageURIs(transaction, null, null) : undefined; + const effectivePreviewUri = localSource ? undefined : receiptURIs?.thumbnail320; const effectiveImage = localSource != null && typeof image === 'string' ? localSource : image; const originalImageSource = tryResolveUrlFromApiRoot(effectiveImage ?? ''); const thumbnailSource = tryResolveUrlFromApiRoot(effectiveThumbnail ?? ''); - const thumbnail320Source = tryResolveUrlFromApiRoot(effectiveThumbnail320 ?? ''); + const previewUriSource = tryResolveUrlFromApiRoot(effectivePreviewUri ?? ''); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isPDF = filename && Str.isPDF(filename); @@ -206,7 +206,7 @@ function ReportActionItemImage({ onLoad={onLoad} shouldUseFullHeight={shouldUseFullHeight} onLoadFailure={onLoadFailure} - thumbnail320={thumbnail320Source} + previewUri={previewUriSource} /> ); @@ -219,7 +219,7 @@ function ReportActionItemImage({ thumbnailContainerStyles={styles.thumbnailImageContainerHover} onLoad={onLoad} onLoadFailure={onLoadFailure} - thumbnail320={thumbnail320Source} + previewUri={previewUriSource} /> ); } From 47575ab423fe0f82087a8035927bd13899cb71ef Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 27 Apr 2026 16:39:11 +0200 Subject: [PATCH 03/11] fixes to the implementation --- src/components/ReportActionItem/ReportActionItemImage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index eb60af7fc491..e9f723ba774c 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -144,7 +144,7 @@ function ReportActionItemImage({ const originalImageSource = tryResolveUrlFromApiRoot(effectiveImage ?? ''); const thumbnailSource = tryResolveUrlFromApiRoot(effectiveThumbnail ?? ''); - const previewUriSource = tryResolveUrlFromApiRoot(effectivePreviewUri ?? ''); + const previewUriSource = effectivePreviewUri ? tryResolveUrlFromApiRoot(effectivePreviewUri) : undefined; const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isPDF = filename && Str.isPDF(filename); From e51a2698d42ee7dc2730fba8b1d0c32372ee040d Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 28 Apr 2026 13:49:32 +0200 Subject: [PATCH 04/11] fixes to the implementation --- src/components/ImageWithLoading.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 8ceffa15d1d1..750413082154 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -88,6 +88,21 @@ function ImageWithLoading({ style={[styles.w100, styles.h100, containerStyles]} onLayout={onLayout} > + {isLoading && !!previewUri && !isImageCached && ( + { + setIsThumbnailLoading(false); + onLoad?.(e); + }} + loadingIconSize={loadingIconSize} + loadingIndicatorStyles={loadingIndicatorStyles} + /> + )} {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} - {isLoading && !!previewUri && !isImageCached && ( - { - setIsThumbnailLoading(false); - }} - loadingIconSize={loadingIconSize} - loadingIndicatorStyles={loadingIndicatorStyles} - /> - )} {isLoading && (!previewUri || isThumbnailLoading) && !isImageCached && !isOffline && ( Date: Wed, 29 Apr 2026 10:02:53 +0200 Subject: [PATCH 05/11] eslint fixes --- src/components/ImageWithLoading.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 750413082154..995acff33ae0 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,7 +1,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; @@ -89,6 +89,7 @@ function ImageWithLoading({ onLayout={onLayout} > {isLoading && !!previewUri && !isImageCached && ( + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. Date: Wed, 29 Apr 2026 10:29:10 +0200 Subject: [PATCH 06/11] fix related to ai comment --- src/components/ImageWithLoading.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 995acff33ae0..bd8a16284ead 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -126,6 +126,7 @@ function ImageWithLoading({ isLoadedRef.current = false; setIsImageCached(false); setIsLoading(true); + setIsThumbnailLoading(true); waitForSession?.(); }} loadingIconSize={loadingIconSize} From f41dc2fea671cd1b4ae56c69041ee3bd26a8cd83 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 29 Apr 2026 11:13:41 +0200 Subject: [PATCH 07/11] fix issue on mobile --- src/components/ImageWithLoading.tsx | 6 +++--- src/components/ImageWithSizeCalculation.tsx | 5 +++++ src/components/ReceiptImage/index.tsx | 1 + src/components/ReportActionItem/ReportActionItemImage.tsx | 1 + src/components/ThumbnailImage.tsx | 5 +++++ 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index bd8a16284ead..4bf4b4878e2f 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,7 +1,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; @@ -47,7 +47,7 @@ function ImageWithLoading({ const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); - const [isThumbnailLoading, setIsThumbnailLoading] = useState(true); + const [isThumbnailLoading, setIsThumbnailLoading] = useState(!!previewUri); const {isOffline} = useNetwork(); const handleError = () => { @@ -126,7 +126,7 @@ function ImageWithLoading({ isLoadedRef.current = false; setIsImageCached(false); setIsLoading(true); - setIsThumbnailLoading(true); + setIsThumbnailLoading(!!previewUri); waitForSession?.(); }} loadingIconSize={loadingIconSize} diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index 71ed1df43cb4..95a60712c329 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -53,6 +53,9 @@ type ImageWithSizeCalculationProps = { /** Reason attributes for skeleton span telemetry */ reasonAttributes?: SkeletonSpanReasonAttributes; + + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; }; /** @@ -74,6 +77,7 @@ function ImageWithSizeCalculation({ onLoad, resizeMode, reasonAttributes, + previewUri, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -104,6 +108,7 @@ function ImageWithSizeCalculation({ loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} reasonAttributes={reasonAttributes} + previewUri={previewUri} /> ); } diff --git a/src/components/ReceiptImage/index.tsx b/src/components/ReceiptImage/index.tsx index ad383a1bd3aa..0180f9597671 100644 --- a/src/components/ReceiptImage/index.tsx +++ b/src/components/ReceiptImage/index.tsx @@ -221,6 +221,7 @@ function ReceiptImage({ return ( From 901edbb0598f2f48db761bc1fee2713180e48e92 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 29 Apr 2026 11:16:01 +0200 Subject: [PATCH 08/11] fix issue on mobile --- src/components/ReportActionItem/ReportActionItemImage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 548b0432d1c4..e9f723ba774c 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -148,7 +148,6 @@ function ReportActionItemImage({ const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isPDF = filename && Str.isPDF(filename); - console.log('morwa effectivePreviewUri', effectivePreviewUri); let propsObj: ReceiptImageProps; if (isEReceipt) { From 8fa1c73fe1be28dde1713c95de88e6ffe01548d1 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 29 Apr 2026 11:37:52 +0200 Subject: [PATCH 09/11] fix issue on mobile --- src/components/ImageWithLoading.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 4bf4b4878e2f..761e16404280 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -1,7 +1,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useRef, useState} from 'react'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import AttachmentOfflineIndicator from './AttachmentOfflineIndicator'; From 8c52e88e25b6e2d0440d4d717330a611e83dfea3 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 29 Apr 2026 13:38:26 +0200 Subject: [PATCH 10/11] fix issue on mobile --- src/components/ImageWithLoading.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 761e16404280..30ead9be1911 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -88,7 +88,7 @@ function ImageWithLoading({ style={[styles.w100, styles.h100, containerStyles]} onLayout={onLayout} > - {isLoading && !!previewUri && !isImageCached && ( + {isLoading && !!previewUri && ( // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. Date: Thu, 21 May 2026 08:13:04 +0200 Subject: [PATCH 11/11] fix eslint error --- src/components/ImageWithLoading.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index 1f4bb94b2f1a..13de39d3f027 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -91,7 +91,6 @@ function ImageWithLoading({ {isLoading && !!previewUri && ( // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop.