// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-clipboard/clipboard'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Platform, type StyleProp, Text, type TextStyle, TouchableWithoutFeedback, View} from 'react-native'; import Animated from 'react-native-reanimated'; import {SvgUri} from 'react-native-svg'; import parseUrl from 'url-parse'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import ProgressiveImage from '@components/progressive_image'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {GalleryInit} from '@context/gallery'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; import {useGalleryItem} from '@hooks/gallery'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; import {lookupMimeType} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images'; import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown'; import {urlSafeBase64Encode} from '@utils/security'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url'; import {onOpenLinkError} from '@utils/url/links'; import type {GalleryItemType} from '@typings/screens/gallery'; type MarkdownImageProps = { disabled?: boolean; errorTextStyle: StyleProp; imagesMetadata: Record; isReplyPost?: boolean; linkDestination?: string; layoutHeight?: number; layoutWidth?: number; location?: string; postId: string; source: string; sourceSize?: {width?: number; height?: number}; theme: Theme; } const ANDROID_MAX_HEIGHT = 4096; const ANDROID_MAX_WIDTH = 4096; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ bottomSheet: { flex: 1, }, brokenImageIcon: { width: 24, height: 24, }, container: { marginVertical: 5, top: 5, }, svg: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.06), borderRadius: 8, flex: 1, }, })); const MarkdownImage = ({ disabled, errorTextStyle, imagesMetadata, isReplyPost = false, layoutWidth, layoutHeight, linkDestination, location, postId, source, sourceSize, theme, }: MarkdownImageProps) => { const intl = useIntl(); const isTablet = useIsTablet(); const style = getStyleSheet(theme); const managedConfig = useManagedConfig(); const sourceKey = removeImageProxyForKey(source); // Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents const genericFileRef = useRef(null); if (genericFileRef.current === null) { genericFileRef.current = `uid-${urlSafeBase64Encode(sourceKey)}`; } const genericFileId = genericFileRef.current; const metadata = secureGetFromRecord(imagesMetadata, sourceKey) || Object.values(imagesMetadata || {})[0]; const [failed, setFailed] = useState(() => isGifTooLarge(metadata)); const serverUrl = useServerUrl(); const galleryIdentifier = `${postId}-${genericFileId}-${location}`; const fileInfo = useMemo(() => { const uri = source.startsWith('/') ? serverUrl + source : source; const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth, layoutHeight); const decodedLink = safeDecodeURIComponent(uri); let filename = parseUrl(decodedLink.substr(decodedLink.lastIndexOf('/'))).pathname.replace('/', ''); let extension = metadata?.format || filename.split('.').pop(); if (extension === filename) { const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); filename = `${filename}${ext}`; extension = ext; } return { id: genericFileId, name: filename, extension, has_preview_image: true, mime_type: lookupMimeType(filename), post_id: postId, uri, width: originalSize.width, height: originalSize.height, } as FileInfo; }, [source, serverUrl, isReplyPost, isTablet, sourceSize, metadata, layoutWidth, layoutHeight, genericFileId, postId]); const handlePreviewImage = useCallback(() => { const item: GalleryItemType = { ...fileToGalleryItem(fileInfo, undefined, undefined, 0, fileInfo.id), mime_type: lookupMimeType(fileInfo.name), type: 'image', }; openGalleryAtIndex(galleryIdentifier, 0, [item]); }, [fileInfo, galleryIdentifier]); const {ref, onGestureEvent, styles} = useGalleryItem( galleryIdentifier, 0, handlePreviewImage, ); const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet)); const progressiveImageStyle = useMemo(() => ({width, height}), [width, height]); const handleLinkPress = useCallback(() => { if (linkDestination) { const url = normalizeProtocol(linkDestination); tryOpenURL(url, () => onOpenLinkError(intl)); } }, [intl, linkDestination]); const handleLinkLongPress = useCallback(() => { if (managedConfig?.copyAndPasteProtection !== 'true') { const renderContent = () => { return ( { dismissBottomSheet(); Clipboard.setString(linkDestination || source); }} testID='at_mention.bottom_sheet.copy_url' text={intl.formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'})} /> { dismissBottomSheet(); }} testID='at_mention.bottom_sheet.cancel' text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})} /> ); }; bottomSheet({ closeButtonId: 'close-mardown-image', renderContent, snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT)], title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}), theme, }); } }, [managedConfig?.copyAndPasteProtection, intl, theme, style.bottomSheet, linkDestination, source]); const handleOnError = useCallback(() => { setFailed(true); }, []); if (failed) { return ( ); } let image; if (height && width) { if (Platform.OS === 'android' && (height > ANDROID_MAX_HEIGHT || width > ANDROID_MAX_WIDTH)) { // Android has a cap on the max image size that can be displayed return ( {' '} ); } else if (fileInfo.extension === 'svg') { image = ( ); } else { image = ( ); } } if (image && linkDestination && !disabled) { image = ( ); } return ( {image} ); }; export default MarkdownImage;