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} />