Skip to content

Commit

Permalink
Merge pull request #44807 from wildan-m/wildan/fix/38180-change-big-i…
Browse files Browse the repository at this point in the history
…mage-with-preview

Change high resolution image with preview
  • Loading branch information
Julesssss authored Jul 15, 2024
2 parents 39463bd + fa37c50 commit 75e3e2f
Show file tree
Hide file tree
Showing 18 changed files with 182 additions and 28 deletions.
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,8 @@ const CONST = {
NOTE: 'n',
},

IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000,

IMAGE_OBJECT_POSITION: {
TOP: 'top',
INITIAL: 'initial',
Expand Down
1 change: 1 addition & 0 deletions src/components/AttachmentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ function AttachmentModal({
fallbackSource={fallbackSource}
isUsedInAttachmentModal
transactionID={transaction?.transactionID}
isUploaded={!isEmptyObject(report)}
/>
</AttachmentCarouselPagerContext.Provider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr
<View style={[styles.imageModalImageCenterContainer]}>
<AttachmentView
source={item.source}
previewSource={item.previewSource}
file={item.file}
isAuthTokenRequired={item.isAuthTokenRequired}
onPress={onPress}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ type AttachmentCarouselPagerItems = {
/** The source of the image is used to identify each attachment/page in the pager */
source: AttachmentSource;

/** URL to preview-sized attachment that is also used for the thumbnail */
previewSource?: AttachmentSource;

/** The index of the pager item determines the order of the images in the pager */
index: number;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ function AttachmentCarouselPager(
}, [activePage, initialPage]);

/** The `pagerItems` object that passed down to the context. Later used to detect current page, whether it's a single image gallery etc. */
const pagerItems = useMemo(() => items.map((item, index) => ({source: item.source, index, isActive: index === activePageIndex})), [activePageIndex, items]);
const pagerItems = useMemo(
() => items.map((item, index) => ({source: item.source, previewSource: item.previewSource, index, isActive: index === activePageIndex})),
[activePageIndex, items],
);

const extractItemKey = useCallback(
(item: Attachment, index: number) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ function extractAttachments(
if (name === 'img' && attribs.src) {
const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src);
const previewSource = tryResolveUrlFromApiRoot(attribs.src);
if (uniqueSources.has(source)) {
return;
}

uniqueSources.add(source);
let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);

const width = (attribs['data-expensify-width'] && parseInt(attribs['data-expensify-width'], 10)) || undefined;
const height = (attribs['data-expensify-height'] && parseInt(attribs['data-expensify-height'], 10)) || undefined;

// Public image URLs might lack a file extension in the source URL, without an extension our
// AttachmentView fails to recognize them as images and renders fallback content instead.
// We apply this small hack to add an image extension and ensure AttachmentView renders the image.
Expand All @@ -72,8 +76,9 @@ function extractAttachments(
attachments.unshift({
reportActionID: attribs['data-id'],
source,
previewSource,
isAuthTokenRequired: !!expensifySource,
file: {name: fileName},
file: {name: fileName, width, height},
isReceipt: false,
hasBeenFlagged: attribs['data-flagged'] === 'true',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Tooltip from '@components/Tooltip';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import type IconAsset from '@src/types/utils/IconAsset';

type DefaultAttachmentViewProps = {
/** The name of the file */
Expand All @@ -21,9 +22,11 @@ type DefaultAttachmentViewProps = {

/** Additional styles for the container */
containerStyles?: StyleProp<ViewStyle>;

icon?: IconAsset;
};

function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles}: DefaultAttachmentViewProps) {
function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon}: DefaultAttachmentViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand All @@ -33,7 +36,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa
<View style={styles.mr2}>
<Icon
fill={theme.icon}
src={Expensicons.Paperclip}
src={icon ?? Expensicons.Paperclip}
/>
</View>

Expand Down
32 changes: 32 additions & 0 deletions src/components/Attachments/AttachmentView/HighResolutionInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';

function HighResolutionInfo({isUploaded}: {isUploaded: boolean}) {
const theme = useTheme();
const styles = useThemeStyles();
const stylesUtils = useStyleUtils();
const {translate} = useLocalize();

return (
<View style={[styles.flexRow, styles.alignItemsCenter, styles.gap2, styles.justifyContentCenter, stylesUtils.getHighResolutionInfoWrapperStyle(isUploaded)]}>
<Icon
src={Expensicons.Info}
height={variables.iconSizeExtraSmall}
width={variables.iconSizeExtraSmall}
fill={theme.icon}
additionalStyles={styles.p1}
/>
<Text style={[styles.textLabelSupporting]}>{isUploaded ? translate('attachmentPicker.attachmentImageResized') : translate('attachmentPicker.attachmentImageTooLarge')}</Text>
</View>
);
}

export default HighResolutionInfo;
87 changes: 64 additions & 23 deletions src/components/Attachments/AttachmentView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import DistanceEReceipt from '@components/DistanceEReceipt';
import EReceipt from '@components/EReceipt';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import ScrollView from '@components/ScrollView';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useLocalize from '@hooks/useLocalize';
Expand All @@ -17,6 +18,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import type {ColorValue} from '@styles/utils/types';
import variables from '@styles/variables';
Expand All @@ -26,6 +28,7 @@ import AttachmentViewImage from './AttachmentViewImage';
import AttachmentViewPdf from './AttachmentViewPdf';
import AttachmentViewVideo from './AttachmentViewVideo';
import DefaultAttachmentView from './DefaultAttachmentView';
import HighResolutionInfo from './HighResolutionInfo';

type AttachmentViewOnyxProps = {
transaction: OnyxEntry<Transaction>;
Expand Down Expand Up @@ -70,10 +73,14 @@ type AttachmentViewProps = AttachmentViewOnyxProps &

/** Whether the attachment is used as a chat attachment */
isUsedAsChatAttachment?: boolean;

/* Flag indicating whether the attachment has been uploaded. */
isUploaded?: boolean;
};

function AttachmentView({
source,
previewSource,
file,
isAuthTokenRequired,
onPress,
Expand All @@ -92,13 +99,15 @@ function AttachmentView({
isHovered,
duration,
isUsedAsChatAttachment,
isUploaded = true,
}: AttachmentViewProps) {
const {translate} = useLocalize();
const {updateCurrentlyPlayingURL} = usePlaybackContext();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [loadComplete, setLoadComplete] = useState(false);
const [isHighResolution, setIsHighResolution] = useState<boolean>(false);
const [hasPDFFailedToLoad, setHasPDFFailedToLoad] = useState(false);
const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file?.name && Str.isVideo(file.name));

Expand All @@ -113,6 +122,12 @@ function AttachmentView({

useNetwork({onReconnect: () => setImageError(false)});

useEffect(() => {
FileUtils.getFileResolution(file).then((resolution) => {
setIsHighResolution(FileUtils.isHighResolutionImage(resolution));
});
}, [file]);

// Handles case where source is a component (ex: SVG) or a number
// Number may represent a SVG or an image
if (typeof source === 'function' || (maybeIcon && typeof source === 'number')) {
Expand Down Expand Up @@ -196,35 +211,61 @@ function AttachmentView({
// For this check we use both source and file.name since temporary file source is a blob
// both PDFs and images will appear as images when pasted into the text field.
// We also check for numeric source since this is how static images (used for preview) are represented in RN.
const isImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source));
if (isImage || (file?.name && Str.isImage(file.name))) {
if (imageError) {
// AttachmentViewImage can't handle icon fallbacks, so we need to handle it here
if (typeof fallbackSource === 'number' || typeof fallbackSource === 'function') {
const isSourceImage = typeof source === 'number' || (typeof source === 'string' && Str.isImage(source));
const isFileNameImage = file?.name && Str.isImage(file.name);
const isFileImage = isSourceImage || isFileNameImage;

if (isFileImage) {
if (imageError && (typeof fallbackSource === 'number' || typeof fallbackSource === 'function')) {
return (
<Icon
src={fallbackSource}
height={variables.defaultAvatarPreviewSize}
width={variables.defaultAvatarPreviewSize}
additionalStyles={[styles.alignItemsCenter, styles.justifyContentCenter, styles.flex1]}
fill={theme.border}
/>
);
}
let imageSource = imageError && fallbackSource ? (fallbackSource as string) : (source as string);

if (isHighResolution) {
if (!isUploaded) {
return (
<Icon
src={fallbackSource}
height={variables.defaultAvatarPreviewSize}
width={variables.defaultAvatarPreviewSize}
additionalStyles={[styles.alignItemsCenter, styles.justifyContentCenter, styles.flex1]}
fill={theme.border}
/>
<>
<View style={styles.imageModalImageCenterContainer}>
<DefaultAttachmentView
icon={Expensicons.Gallery}
fileName={file?.name}
shouldShowDownloadIcon={shouldShowDownloadIcon}
shouldShowLoadingSpinnerIcon={shouldShowLoadingSpinnerIcon}
containerStyles={containerStyles}
/>
</View>
<HighResolutionInfo isUploaded={isUploaded} />
</>
);
}
imageSource = previewSource?.toString() ?? imageSource;
}

return (
<AttachmentViewImage
url={imageError && fallbackSource ? (fallbackSource as string) : (source as string)}
file={file}
isAuthTokenRequired={isAuthTokenRequired}
loadComplete={loadComplete}
isImage={isImage}
onPress={onPress}
onError={() => {
setImageError(true);
}}
/>
<>
<View style={styles.imageModalImageCenterContainer}>
<AttachmentViewImage
url={imageSource}
file={file}
isAuthTokenRequired={isAuthTokenRequired}
loadComplete={loadComplete}
isImage={isFileImage}
onPress={onPress}
onError={() => {
setImageError(true);
}}
/>
</View>
{isHighResolution && <HighResolutionInfo isUploaded={isUploaded} />}
</>
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/components/Attachments/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type Attachment = {
/** URL to full-sized attachment, SVG function, or numeric static image on native platforms */
source: AttachmentSource;

/** URL to preview-sized attachment that is also used for the thumbnail */
previewSource?: AttachmentSource;

/** File object can be an instance of File or Object */
file?: FileObject;

Expand Down
2 changes: 1 addition & 1 deletion src/components/Lightbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
};
}

const foundPage = attachmentCarouselPagerContext.pagerItems.findIndex((item) => item.source === uri);
const foundPage = attachmentCarouselPagerContext.pagerItems.findIndex((item) => item.source === uri || item.previewSource === uri);
return {
...attachmentCarouselPagerContext,
isUsedInCarousel: !!attachmentCarouselPagerContext.pagerRef,
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ export default {
notAllowedExtension: 'This file type is not allowed. Please try a different file type.',
folderNotAllowedMessage: 'Uploading a folder is not allowed. Please try a different file.',
protectedPDFNotSupported: 'Password-protected PDF is not supported',
attachmentImageResized: 'This image has been resized for previewing. Download for full resolution.',
attachmentImageTooLarge: 'This image is too large to preview before uploading.',
},
connectionComplete: {
title: 'Connection complete',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ export default {
notAllowedExtension: 'Este tipo de archivo no es compatible',
folderNotAllowedMessage: 'Subir una carpeta no está permitido. Prueba con otro archivo.',
protectedPDFNotSupported: 'Los PDFs con contraseña no son compatibles',
attachmentImageResized: 'Se ha cambiado el tamaño de esta imagen para obtener una vista previa. Descargar para resolución completa.',
attachmentImageTooLarge: 'Esta imagen es demasiado grande para obtener una vista previa antes de subirla.',
},
avatarCropModal: {
title: 'Editar foto',
Expand Down
27 changes: 27 additions & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import DateUtils from '@libs/DateUtils';
import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import CONST from '@src/CONST';
import getImageResolution from './getImageResolution';
import type {ReadFileAsync, SplitExtensionFromFileName} from './types';

/**
Expand Down Expand Up @@ -261,6 +262,29 @@ function isLocalFile(receiptUri?: string | number): boolean {
return typeof receiptUri === 'number' || receiptUri?.startsWith('blob:') || receiptUri?.startsWith('file:') || receiptUri?.startsWith('/');
}

function getFileResolution(targetFile: FileObject | undefined): Promise<{width: number; height: number} | null> {
if (!targetFile) {
return Promise.resolve(null);
}

// If the file already has width and height, return them directly
if ('width' in targetFile && 'height' in targetFile) {
return Promise.resolve({width: targetFile.width ?? 0, height: targetFile.height ?? 0});
}

// Otherwise, attempt to get the image resolution
return getImageResolution(targetFile)
.then(({width, height}) => ({width, height}))
.catch((error: Error) => {
Log.hmmm('Failed to get image resolution:', error);
return null;
});
}

function isHighResolutionImage(resolution: {width: number; height: number} | null): boolean {
return resolution !== null && (resolution.width > CONST.IMAGE_HIGH_RESOLUTION_THRESHOLD || resolution.height > CONST.IMAGE_HIGH_RESOLUTION_THRESHOLD);
}

export {
showGeneralErrorAlert,
showSuccessAlert,
Expand All @@ -275,4 +299,7 @@ export {
base64ToFile,
isLocalFile,
validateImageForCorruption,
isImage,
getFileResolution,
isHighResolutionImage,
};
11 changes: 11 additions & 0 deletions src/styles/utils/getHighResolutionInfoWrapperStyle/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import spacing from '@styles/utils/spacing';
import type GetHighResolutionInfoWrapperStyle from './types';

const getHighResolutionInfoWrapperStyle: GetHighResolutionInfoWrapperStyle = (isUploaded) => ({
...spacing.ph8,
...spacing.pt5,
...(isUploaded ? spacing.pb5 : spacing.mbn1),
});

export default getHighResolutionInfoWrapperStyle;
11 changes: 11 additions & 0 deletions src/styles/utils/getHighResolutionInfoWrapperStyle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import spacing from '@styles/utils/spacing';
import type GetHighResolutionInfoWrapperStyle from './types';

const getHighResolutionInfoWrapperStyle: GetHighResolutionInfoWrapperStyle = (isUploaded) => ({
...spacing.ph5,
...spacing.pt5,
...(isUploaded ? spacing.pb5 : spacing.mbn1),
});

export default getHighResolutionInfoWrapperStyle;
Loading

0 comments on commit 75e3e2f

Please sign in to comment.