From 303ee7dceb6df295d120e17c8b167122600dd0fc Mon Sep 17 00:00:00 2001 From: Pavlo Tsimura Date: Fri, 9 Feb 2024 10:53:18 +0100 Subject: [PATCH 1/4] Add fallback icons for thumbnails --- .../HTMLRenderers/ImageRenderer.tsx | 30 +++++------ src/components/ImageWithSizeCalculation.tsx | 11 ++-- .../ReportActionItemImage.tsx | 8 ++- .../ReportActionItemImages.tsx | 1 + src/components/ThumbnailImage.tsx | 50 +++++++++++++++++-- 5 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 3e6119ff279f..e75d69d3f14d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -14,6 +14,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {User} from '@src/types/onyx'; +import * as FileUtils from "@libs/fileDownload/FileUtils"; +import * as Expensicons from "@components/Icon/Expensicons"; type ImageRendererWithOnyxProps = { /** Current user */ @@ -47,26 +49,33 @@ 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}) => ( - + {thumbnailImageComponent} )} - - ); + ; } ImageRenderer.displayName = 'ImageRenderer'; 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 19727f4a5f5f..0b4839b797c9 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -16,6 +16,8 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; +import variables from "@styles/variables"; +import * as Expensicons from "@components/Icon/Expensicons"; type ReportActionItemImageProps = { /** thumbnail URI for the image */ @@ -38,6 +40,8 @@ type ReportActionItemImageProps = { /** Filename of attachment */ filename?: string; + + isSingleImage?: boolean; }; /** @@ -46,7 +50,7 @@ type ReportActionItemImageProps = { * and optional preview modal as well. */ -function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false, filename}: ReportActionItemImageProps) { +function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, canEditReceipt = false, isLocalFile = false, filename, isSingleImage}: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const imageSource = tryResolveUrlFromApiRoot(image ?? ''); @@ -67,6 +71,8 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, tr 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 00b91bf4f862..4a7411c01d14 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.tsx +++ b/src/components/ReportActionItem/ReportActionItemImages.tsx @@ -80,6 +80,7 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report image={image} isLocalFile={isLocalFile} transaction={transaction} + isSingleImage={numberOfShownImages === 1} /> {isLastImage && remaining > 0 && ( diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 5950bae5205c..4ed4930fd217 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 useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import useTheme from "@hooks/useTheme"; +import variables from "@styles/variables"; +import useNetwork from "@hooks/useNetwork"; +import type IconAsset from "@src/types/utils/IconAsset"; +import * as Expensicons from "./Icon/Expensicons"; +import Icon from "./Icon"; import ImageWithSizeCalculation from './ImageWithSizeCalculation'; type ThumbnailImageProps = { @@ -24,6 +30,10 @@ type ThumbnailImageProps = { /** Height of the thumbnail image */ imageHeight?: number; + fallbackIcon?: IconAsset; + + fallbackIconSize?: number; + /** Should the image be resized on load or just fit container */ shouldDynamicallyResize?: boolean; }; @@ -71,19 +81,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 +121,31 @@ 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} /> From 3dc94299ad78f15d80a94e95d685da70b42bf765 Mon Sep 17 00:00:00 2001 From: Pavlo Tsimura Date: Mon, 12 Feb 2024 10:12:29 +0100 Subject: [PATCH 2/4] Add fallback icons for thumbnails --- .../ReportActionItem/ReportActionItemImage.tsx | 15 +++++++-------- .../ReportActionItem/ReportActionItemImages.tsx | 5 +---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index a074cad416cd..a51ee71e5519 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -16,10 +16,9 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; +import variables from "@styles/variables"; import * as Expensicons from "@components/Icon/Expensicons"; -type IconSize = 'small' | 'medium' | 'large'; - type ReportActionItemImageProps = { /** thumbnail URI for the image */ thumbnail?: string | ImageSourcePropType | null; @@ -27,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. */ @@ -42,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; }; /** @@ -60,7 +59,7 @@ function ReportActionItemImage({ canEditReceipt = false, isLocalFile = false, filename, - iconSize = 'large', + isSingleImage, }: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -75,7 +74,7 @@ function ReportActionItemImage({ ); @@ -86,7 +85,7 @@ function ReportActionItemImage({ style={[styles.w100, styles.h100]} isAuthTokenRequired fallbackIcon={Expensicons.Receipt} - fallbackIconSize={iconSize as IconSize} + 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 && ( From acede789240eb2477a29e09e8edc4221ea8b955e Mon Sep 17 00:00:00 2001 From: Pavlo Tsimura Date: Mon, 12 Feb 2024 11:41:24 +0100 Subject: [PATCH 3/4] Add fallback icons for thumbnails --- .../HTMLRenderers/ImageRenderer.tsx | 14 ++++---- .../ReportActionItemImage.tsx | 6 ++-- src/components/ThumbnailImage.tsx | 35 +++++++++---------- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index e75d69d3f14d..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'; @@ -14,8 +16,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {User} from '@src/types/onyx'; -import * as FileUtils from "@libs/fileDownload/FileUtils"; -import * as Expensicons from "@components/Icon/Expensicons"; type ImageRendererWithOnyxProps = { /** Current user */ @@ -73,9 +73,10 @@ function ImageRenderer({tnode}: ImageRendererProps) { /> ); - return imagePreviewModalDisabled - ? <>{thumbnailImageComponent} - : + return imagePreviewModalDisabled ? ( + <>{thumbnailImageComponent} + ) : ( + {({anchor, report, action, checkIfContextMenuActive}) => ( )} - ; + + ); } ImageRenderer.displayName = 'ImageRenderer'; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index a51ee71e5519..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,10 +15,9 @@ 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'; -import variables from "@styles/variables"; -import * as Expensicons from "@components/Icon/Expensicons"; type ReportActionItemImageProps = { /** thumbnail URI for the image */ @@ -59,7 +59,7 @@ function ReportActionItemImage({ canEditReceipt = false, isLocalFile = false, filename, - isSingleImage, + isSingleImage = true, }: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 4ed4930fd217..affb2f82c4c9 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -2,16 +2,16 @@ import lodashClamp from 'lodash/clamp'; 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 useTheme from "@hooks/useTheme"; -import variables from "@styles/variables"; -import useNetwork from "@hooks/useNetwork"; -import type IconAsset from "@src/types/utils/IconAsset"; -import * as Expensicons from "./Icon/Expensicons"; -import Icon from "./Icon"; +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 = { @@ -82,15 +82,15 @@ function calculateThumbnailImageSize(width: number, height: number, windowHeight } function ThumbnailImage({ - previewSourceURL, - style, - isAuthTokenRequired, - imageWidth = 200, - imageHeight = 200, - shouldDynamicallyResize = true, - fallbackIcon = Expensicons.Gallery, - fallbackIconSize = variables.iconSizeSuperLarge, - }: ThumbnailImageProps) { + 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(); @@ -123,10 +123,7 @@ function ThumbnailImage({ if (failedToLoad) { return ( - + Date: Mon, 12 Feb 2024 11:51:46 +0100 Subject: [PATCH 4/4] Add comments --- src/components/ThumbnailImage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index affb2f82c4c9..6f7369aaee93 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -30,8 +30,10 @@ 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 */