Skip to content

Commit

Permalink
Merge pull request #36214 from paultsimura/fix/34601-thumbnails-fallback
Browse files Browse the repository at this point in the history
fix: Add fallback icons for thumbnails
  • Loading branch information
aldo-expensify authored Feb 14, 2024
2 parents 2254c66 + b10d058 commit 5a14fe1
Show file tree
Hide file tree
Showing 5 changed files with 78 additions and 27 deletions.
24 changes: 14 additions & 10 deletions src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = (
<ThumbnailImage
previewSourceURL={previewSource}
style={styles.webViewStyles.tagStyles.img}
isAuthTokenRequired={isAttachmentOrReceipt}
fallbackIcon={fallbackIcon}
imageWidth={imageWidth}
imageHeight={imageHeight}
/>
);

return imagePreviewModalDisabled ? (
<>{thumbnailImageComponent}</>
) : (
<ShowContextMenuContext.Consumer>
{({anchor, report, action, checkIfContextMenuActive}) => (
Expand All @@ -78,13 +88,7 @@ function ImageRenderer({tnode}: ImageRendererProps) {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
>
<ThumbnailImage
previewSourceURL={previewSource}
style={styles.webViewStyles.tagStyles.img}
isAuthTokenRequired={isAttachmentOrReceipt}
imageWidth={imageWidth}
imageHeight={imageHeight}
/>
{thumbnailImageComponent}
</PressableWithoutFocus>
)}
</ShowContextMenuContext.Consumer>
Expand Down
11 changes: 8 additions & 3 deletions src/components/ImageWithSizeCalculation.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
};
Expand All @@ -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<boolean | null>(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) => {
Expand Down Expand Up @@ -73,7 +78,7 @@ function ImageWithSizeCalculation({url, style, onMeasure, isAuthTokenRequired}:
<View style={[styles.w100, styles.h100, style]}>
<Image
style={[styles.w100, styles.h100]}
source={{uri: url}}
source={source}
isAuthTokenRequired={isAuthTokenRequired}
resizeMode={RESIZE_MODES.cover}
onLoadStart={() => {
Expand Down
16 changes: 9 additions & 7 deletions src/components/ReportActionItem/ReportActionItemImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,19 +15,18 @@ 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;

/** 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. */
Expand All @@ -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;
};

/**
Expand All @@ -59,7 +59,7 @@ function ReportActionItemImage({
canEditReceipt = false,
isLocalFile = false,
filename,
iconSize = 'large',
isSingleImage = true,
}: ReportActionItemImageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand All @@ -74,7 +74,7 @@ function ReportActionItemImage({
<View style={[styles.w100, styles.h100]}>
<EReceiptThumbnail
transactionID={transaction.transactionID}
iconSize={iconSize as IconSize}
iconSize={isSingleImage ? 'medium' : 'small'}
/>
</View>
);
Expand All @@ -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}
/>
);
Expand Down
5 changes: 1 addition & 4 deletions src/components/ReportActionItem/ReportActionItemImages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 && (
<View style={[styles.reportActionItemImagesMoreContainer]}>
Expand Down
49 changes: 46 additions & 3 deletions src/components/ThumbnailImage.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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;
};
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
<View style={[style, styles.overflowHidden, styles.hoveredComponentBG]}>
<View style={[...sizeStyles, styles.alignItemsCenter, styles.justifyContentCenter]}>
<Icon
src={isOffline ? Expensicons.OfflineCloud : fallbackIcon}
height={fallbackIconSize}
width={fallbackIconSize}
fill={theme.border}
/>
</View>
</View>
);
}

return (
<View style={[style, styles.overflowHidden]}>
<View style={[...sizeStyles, styles.alignItemsCenter, styles.justifyContentCenter]}>
<ImageWithSizeCalculation
url={previewSourceURL}
onMeasure={updateImageSize}
onLoadFailure={() => setFailedToLoad(true)}
isAuthTokenRequired={isAuthTokenRequired}
/>
</View>
Expand Down

0 comments on commit 5a14fe1

Please sign in to comment.