diff --git a/src/components/ImageWithLoading.tsx b/src/components/ImageWithLoading.tsx index bb16e4cced3b..13de39d3f027 100644 --- a/src/components/ImageWithLoading.tsx +++ b/src/components/ImageWithLoading.tsx @@ -24,6 +24,9 @@ type ImageWithSizeLoadingProps = { /** Invoked on mount and layout changes */ onLayout?: (event: LayoutChangeEvent) => void; + + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; } & ImageProps; function ImageWithLoading({ @@ -37,12 +40,14 @@ function ImageWithLoading({ onLoad, onLayout, style, + previewUri, ...rest }: ImageWithSizeLoadingProps) { const styles = useThemeStyles(); const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [isThumbnailLoading, setIsThumbnailLoading] = useState(!!previewUri); const {isOffline} = useNetwork(); const handleError = () => { @@ -83,6 +88,21 @@ function ImageWithLoading({ style={[styles.w100, styles.h100, containerStyles]} onLayout={onLayout} > + {isLoading && !!previewUri && ( + // eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. + { + 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 && !isImageCached && !isOffline && ( + {isLoading && (!previewUri || isThumbnailLoading) && !isImageCached && !isOffline && ( ); } diff --git a/src/components/ReceiptImage/index.tsx b/src/components/ReceiptImage/index.tsx index fd22c83efdb1..794a174d98d3 100644 --- a/src/components/ReceiptImage/index.tsx +++ b/src/components/ReceiptImage/index.tsx @@ -131,6 +131,9 @@ type ReceiptImageProps = ( /** Any additional styles to apply */ style?: StyleProp; + + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; }; function ReceiptImage({ @@ -161,6 +164,7 @@ function ReceiptImage({ onLoadFailure, resizeMode, style, + previewUri, }: ReceiptImageProps) { const styles = useThemeStyles(); const [receiptImageWidth, setReceiptImageWidth] = useState(undefined); @@ -216,6 +220,7 @@ function ReceiptImage({ return ( ); } diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 7521fc839f78..4bfc293e236c 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -14,6 +14,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'; @@ -142,10 +143,13 @@ function ReportActionItemImage({ const localSource = transaction?.receipt?.localSource; const effectiveIsLocalFile = isLocalFile || !!localSource; const effectiveThumbnail = localSource ?? thumbnail; + 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 previewUriSource = effectivePreviewUri ? tryResolveUrlFromApiRoot(effectivePreviewUri) : undefined; const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isPDF = filename && Str.isPDF(filename); @@ -207,6 +211,7 @@ function ReportActionItemImage({ onLoad={onLoad} shouldUseFullHeight={shouldUseFullHeight} onLoadFailure={onLoadFailure} + previewUri={previewUriSource} /> ); @@ -219,6 +224,7 @@ function ReportActionItemImage({ thumbnailContainerStyles={styles.thumbnailImageContainerHover} onLoad={onLoad} onLoadFailure={onLoadFailure} + previewUri={previewUriSource} /> ); } diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 89d256aad91c..99f1181b71d4 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -83,6 +83,9 @@ type ThumbnailImageProps = { /** Reason attributes for skeleton span telemetry */ reasonAttributes?: SkeletonSpanReasonAttributes; + + /** Low-resolution URI shown as a placeholder while the full image loads */ + previewUri?: string; }; function ThumbnailImage({ @@ -106,6 +109,7 @@ function ThumbnailImage({ onLoad, resizeMode, reasonAttributes, + previewUri, }: ThumbnailImageProps) { const icons = useMemoizedLazyExpensifyIcons(['Gallery', 'OfflineCloud']); const styles = useThemeStyles(); @@ -172,6 +176,7 @@ function ThumbnailImage({ onLoad={onLoad} resizeMode={resizeMode} reasonAttributes={reasonAttributes} + previewUri={previewUri} />