diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
index 3e6119ff279f..fd2d80c4d79a 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
@@ -2,11 +2,13 @@ import React, {memo} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
+import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus';
import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext';
import ThumbnailImage from '@components/ThumbnailImage';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as FileUtils from '@libs/fileDownload/FileUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
@@ -47,24 +49,32 @@ function ImageRenderer({tnode}: ImageRendererProps) {
// Concierge responder attachments are uploaded to S3 without any access
// control and thus require no authToken to verify access.
//
- const isAttachmentOrReceipt = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
+ const attachmentSourceAttribute = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
+ const isAttachmentOrReceipt = Boolean(attachmentSourceAttribute);
// Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified
const previewSource = tryResolveUrlFromApiRoot(htmlAttribs.src);
- const source = tryResolveUrlFromApiRoot(isAttachmentOrReceipt ? htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] : htmlAttribs.src);
+ const source = tryResolveUrlFromApiRoot(isAttachmentOrReceipt ? attachmentSourceAttribute : htmlAttribs.src);
const imageWidth = (htmlAttribs['data-expensify-width'] && parseInt(htmlAttribs['data-expensify-width'], 10)) || undefined;
const imageHeight = (htmlAttribs['data-expensify-height'] && parseInt(htmlAttribs['data-expensify-height'], 10)) || undefined;
const imagePreviewModalDisabled = htmlAttribs['data-expensify-preview-modal-disabled'] === 'true';
- return imagePreviewModalDisabled ? (
+ const fileType = FileUtils.getFileType(attachmentSourceAttribute);
+ const fallbackIcon = fileType === CONST.ATTACHMENT_FILE_TYPE.FILE ? Expensicons.Document : Expensicons.Gallery;
+ const thumbnailImageComponent = (
+ );
+
+ return imagePreviewModalDisabled ? (
+ <>{thumbnailImageComponent}>
) : (
{({anchor, report, action, checkIfContextMenuActive}) => (
@@ -78,13 +88,7 @@ function ImageRenderer({tnode}: ImageRendererProps) {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
>
-
+ {thumbnailImageComponent}
)}
diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx
index d0559327274a..b3fc1dc91c16 100644
--- a/src/components/ImageWithSizeCalculation.tsx
+++ b/src/components/ImageWithSizeCalculation.tsx
@@ -1,5 +1,5 @@
import delay from 'lodash/delay';
-import React, {useEffect, useRef, useState} from 'react';
+import React, {useEffect, useMemo, useRef, useState} from 'react';
import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -27,6 +27,8 @@ type ImageWithSizeCalculationProps = {
/** Callback fired when the image has been measured. */
onMeasure: OnMeasure;
+ onLoadFailure?: () => void;
+
/** Whether the image requires an authToken */
isAuthTokenRequired: boolean;
};
@@ -37,14 +39,17 @@ type ImageWithSizeCalculationProps = {
* performing some calculation on a network image after fetching dimensions so
* it can be appropriately resized.
*/
-function ImageWithSizeCalculation({url, style, onMeasure, isAuthTokenRequired}: ImageWithSizeCalculationProps) {
+function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthTokenRequired}: ImageWithSizeCalculationProps) {
const styles = useThemeStyles();
const isLoadedRef = useRef(null);
const [isImageCached, setIsImageCached] = useState(true);
const [isLoading, setIsLoading] = useState(false);
+ const source = useMemo(() => ({uri: url}), [url]);
+
const onError = () => {
Log.hmmm('Unable to fetch image to calculate size', {url});
+ onLoadFailure?.();
};
const imageLoadedSuccessfully = (event: OnLoadNativeEvent) => {
@@ -73,7 +78,7 @@ function ImageWithSizeCalculation({url, style, onMeasure, isAuthTokenRequired}:
{
diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx
index ae8087439138..ecf852a2bcee 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImage.tsx
@@ -6,6 +6,7 @@ import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import AttachmentModal from '@components/AttachmentModal';
import EReceiptThumbnail from '@components/EReceiptThumbnail';
+import * as Expensicons from '@components/Icon/Expensicons';
import Image from '@components/Image';
import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus';
import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
@@ -14,11 +15,10 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as TransactionUtils from '@libs/TransactionUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {Transaction} from '@src/types/onyx';
-type IconSize = 'small' | 'medium' | 'large';
-
type ReportActionItemImageProps = {
/** thumbnail URI for the image */
thumbnail?: string | ImageSourcePropType | null;
@@ -26,7 +26,7 @@ type ReportActionItemImageProps = {
/** URI for the image or local numeric reference for the image */
image?: string | ImageSourcePropType;
- /** whether or not to enable the image preview modal */
+ /** whether to enable the image preview modal */
enablePreviewModal?: boolean;
/* The transaction associated with this image, if any. Passed for handling eReceipts. */
@@ -41,8 +41,8 @@ type ReportActionItemImageProps = {
/** Filename of attachment */
filename?: string;
- /** number of images displayed in the same parent container */
- iconSize?: IconSize;
+ /** Whether there are other images displayed in the same parent container */
+ isSingleImage?: boolean;
};
/**
@@ -59,7 +59,7 @@ function ReportActionItemImage({
canEditReceipt = false,
isLocalFile = false,
filename,
- iconSize = 'large',
+ isSingleImage = true,
}: ReportActionItemImageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -74,7 +74,7 @@ function ReportActionItemImage({
);
@@ -84,6 +84,8 @@ function ReportActionItemImage({
previewSourceURL={thumbnailSource}
style={[styles.w100, styles.h100]}
isAuthTokenRequired
+ fallbackIcon={Expensicons.Receipt}
+ fallbackIconSize={isSingleImage ? variables.iconSizeSuperLarge : variables.iconSizeExtraLarge}
shouldDynamicallyResize={false}
/>
);
diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx
index 63eff75296ab..4a7411c01d14 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImages.tsx
@@ -58,9 +58,6 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report
heightStyle = StyleUtils.getHeight(variables.reportActionImagesMultipleImageHeight);
}
- // The icon size varies depending on the number of images we are displaying.
- const iconSize = numberOfShownImages > 2 ? 'small' : 'medium';
-
const hoverStyle = isHovered ? styles.reportPreviewBoxHoverBorder : undefined;
const triangleWidth = variables.reportActionItemImagesMoreCornerTriangleWidth;
@@ -83,7 +80,7 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report
image={image}
isLocalFile={isLocalFile}
transaction={transaction}
- iconSize={iconSize}
+ isSingleImage={numberOfShownImages === 1}
/>
{isLastImage && remaining > 0 && (
diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx
index 5950bae5205c..6f7369aaee93 100644
--- a/src/components/ThumbnailImage.tsx
+++ b/src/components/ThumbnailImage.tsx
@@ -1,11 +1,17 @@
import lodashClamp from 'lodash/clamp';
-import React, {useCallback, useState} from 'react';
+import React, {useCallback, useEffect, useState} from 'react';
import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native';
import {Dimensions, View} from 'react-native';
+import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import variables from '@styles/variables';
+import type IconAsset from '@src/types/utils/IconAsset';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
import ImageWithSizeCalculation from './ImageWithSizeCalculation';
type ThumbnailImageProps = {
@@ -24,6 +30,12 @@ type ThumbnailImageProps = {
/** Height of the thumbnail image */
imageHeight?: number;
+ /** If the image fails to load – show the provided fallback icon */
+ fallbackIcon?: IconAsset;
+
+ /** The size of the fallback icon */
+ fallbackIconSize?: number;
+
/** Should the image be resized on load or just fit container */
shouldDynamicallyResize?: boolean;
};
@@ -71,19 +83,34 @@ function calculateThumbnailImageSize(width: number, height: number, windowHeight
return {thumbnailWidth: Math.max(40, thumbnailScreenWidth), thumbnailHeight: Math.max(40, thumbnailScreenHeight)};
}
-function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidth = 200, imageHeight = 200, shouldDynamicallyResize = true}: ThumbnailImageProps) {
+function ThumbnailImage({
+ previewSourceURL,
+ style,
+ isAuthTokenRequired,
+ imageWidth = 200,
+ imageHeight = 200,
+ shouldDynamicallyResize = true,
+ fallbackIcon = Expensicons.Gallery,
+ fallbackIconSize = variables.iconSizeSuperLarge,
+}: ThumbnailImageProps) {
const styles = useThemeStyles();
+ const theme = useTheme();
const StyleUtils = useStyleUtils();
+ const {isOffline} = useNetwork();
const {windowHeight} = useWindowDimensions();
const initialDimensions = calculateThumbnailImageSize(imageWidth, imageHeight, windowHeight);
const [currentImageWidth, setCurrentImageWidth] = useState(initialDimensions.thumbnailWidth);
const [currentImageHeight, setCurrentImageHeight] = useState(initialDimensions.thumbnailHeight);
+ const [failedToLoad, setFailedToLoad] = useState(false);
+
+ useEffect(() => {
+ setFailedToLoad(false);
+ }, [isOffline, previewSourceURL]);
/**
* Update the state with the computed thumbnail sizes.
* @param Params - width and height of the original image.
*/
-
const updateImageSize = useCallback(
({width, height}: UpdateImageSizeParams) => {
const {thumbnailWidth, thumbnailHeight} = calculateThumbnailImageSize(width, height, windowHeight);
@@ -96,12 +123,28 @@ function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidt
const sizeStyles = shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(currentImageWidth ?? 0, currentImageHeight)] : [styles.w100, styles.h100];
+ if (failedToLoad) {
+ return (
+
+
+
+
+
+ );
+ }
+
return (
setFailedToLoad(true)}
isAuthTokenRequired={isAuthTokenRequired}
/>