diff --git a/src/CONST.ts b/src/CONST.ts index 50df9118a74e..c34fd5a8b31a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1203,6 +1203,8 @@ const CONST = { NOTE: 'n', }, + IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, + IMAGE_OBJECT_POSITION: { TOP: 'top', INITIAL: 'initial', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 368347847890..b6ea09f32436 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -529,6 +529,7 @@ function AttachmentModal({ fallbackSource={fallbackSource} isUsedInAttachmentModal transactionID={transaction?.transactionID} + isUploaded={!isEmptyObject(report)} /> ) diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index 2ec1883fd7de..a9b5dfb7feb6 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -74,6 +74,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr 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) => diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 1e9c67cf84ac..40438d47ecc7 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -52,6 +52,7 @@ 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; } @@ -59,6 +60,9 @@ function extractAttachments( 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. @@ -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', }); diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx index e06ea3064150..ee594f66aabc 100644 --- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx @@ -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 */ @@ -21,9 +22,11 @@ type DefaultAttachmentViewProps = { /** Additional styles for the container */ containerStyles?: StyleProp; + + 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(); @@ -33,7 +36,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa diff --git a/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx b/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx new file mode 100644 index 000000000000..7ea3c83aa96f --- /dev/null +++ b/src/components/Attachments/AttachmentView/HighResolutionInfo.tsx @@ -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 ( + + + {isUploaded ? translate('attachmentPicker.attachmentImageResized') : translate('attachmentPicker.attachmentImageTooLarge')} + + ); +} + +export default HighResolutionInfo; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index a7409e57f846..39c25706bbfe 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -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'; @@ -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'; @@ -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; @@ -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, @@ -92,6 +99,7 @@ function AttachmentView({ isHovered, duration, isUsedAsChatAttachment, + isUploaded = true, }: AttachmentViewProps) { const {translate} = useLocalize(); const {updateCurrentlyPlayingURL} = usePlaybackContext(); @@ -99,6 +107,7 @@ function AttachmentView({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [loadComplete, setLoadComplete] = useState(false); + const [isHighResolution, setIsHighResolution] = useState(false); const [hasPDFFailedToLoad, setHasPDFFailedToLoad] = useState(false); const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file?.name && Str.isVideo(file.name)); @@ -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')) { @@ -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 ( + + ); + } + let imageSource = imageError && fallbackSource ? (fallbackSource as string) : (source as string); + + if (isHighResolution) { + if (!isUploaded) { return ( - + <> + + + + + ); } + imageSource = previewSource?.toString() ?? imageSource; } return ( - { - setImageError(true); - }} - /> + <> + + { + setImageError(true); + }} + /> + + {isHighResolution && } + ); } diff --git a/src/components/Attachments/types.ts b/src/components/Attachments/types.ts index 835482ca99d9..8bac4cc53af6 100644 --- a/src/components/Attachments/types.ts +++ b/src/components/Attachments/types.ts @@ -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; diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index ea10e104a59d..c5a77f9d5ec4 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -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, diff --git a/src/languages/en.ts b/src/languages/en.ts index c7b5125d02fa..d5b43aab302f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -391,6 +391,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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 075903d0f324..a62f9531ac60 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -385,6 +385,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', diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index dab7e8e33a6d..7422d8bd8d8b 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -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'; /** @@ -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, @@ -275,4 +299,7 @@ export { base64ToFile, isLocalFile, validateImageForCorruption, + isImage, + getFileResolution, + isHighResolutionImage, }; diff --git a/src/styles/utils/getHighResolutionInfoWrapperStyle/index.native.ts b/src/styles/utils/getHighResolutionInfoWrapperStyle/index.native.ts new file mode 100644 index 000000000000..743c52287486 --- /dev/null +++ b/src/styles/utils/getHighResolutionInfoWrapperStyle/index.native.ts @@ -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; diff --git a/src/styles/utils/getHighResolutionInfoWrapperStyle/index.ts b/src/styles/utils/getHighResolutionInfoWrapperStyle/index.ts new file mode 100644 index 000000000000..ff10f7c71420 --- /dev/null +++ b/src/styles/utils/getHighResolutionInfoWrapperStyle/index.ts @@ -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; diff --git a/src/styles/utils/getHighResolutionInfoWrapperStyle/types.ts b/src/styles/utils/getHighResolutionInfoWrapperStyle/types.ts new file mode 100644 index 000000000000..b7ff33d7c071 --- /dev/null +++ b/src/styles/utils/getHighResolutionInfoWrapperStyle/types.ts @@ -0,0 +1,5 @@ +import type {ViewStyle} from 'react-native'; + +type GetHighResolutionInfoWrapperStyle = (isUploaded: boolean) => ViewStyle; + +export default GetHighResolutionInfoWrapperStyle; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 09df150df0ed..c7fe153857e7 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -23,6 +23,7 @@ import createModalStyleUtils from './generators/ModalStyleUtils'; import createReportActionContextMenuStyleUtils from './generators/ReportActionContextMenuStyleUtils'; import createTooltipStyleUtils from './generators/TooltipStyleUtils'; import getContextMenuItemStyles from './getContextMenuItemStyles'; +import getHighResolutionInfoWrapperStyle from './getHighResolutionInfoWrapperStyle'; import getNavigationModalCardStyle from './getNavigationModalCardStyles'; import getSignInBgStyles from './getSignInBgStyles'; import {compactContentContainerStyles} from './optionRowStyles'; @@ -1182,6 +1183,7 @@ const staticStyleUtils = { getCharacterWidth, getAmountWidth, getBorderRadiusStyle, + getHighResolutionInfoWrapperStyle, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({