mattermost-mobile-upstream/app/components/block_renderer/mm_blocks_image.tsx

213 lines
7.0 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type ImageLoadEventData} from 'expo-image';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {Pressable, View} from 'react-native';
import Animated from 'react-native-reanimated';
import {SvgUri} from 'react-native-svg';
import ExpoImage from '@components/expo_image';
import ExternalImage, {isSVGImage} from '@components/external_image';
import FileIcon from '@components/files/file_icon';
import {GalleryInit} from '@context/gallery';
import {useIsTablet} from '@hooks/device';
import {useGalleryItem} from '@hooks/gallery';
import {lookupMimeType} from '@utils/file';
import {openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth, isGifTooLarge} from '@utils/images';
import {urlSafeBase64Encode} from '@utils/security';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
import {MmBlocksLayoutWidthContext, MmBlocksRenderContext} from './context';
import {
computeMmBlocksImageLayout,
refineMmBlocksImageLayoutFromIntrinsic,
type MmBlocksImageLayout,
} from './utils/image';
import type {GalleryItemType} from '@typings/screens/gallery';
export type MmBlocksImageProps = {
altText?: string;
caps: {maxWidth?: number; maxHeight?: number};
imageMetadata?: PostImage;
imageStyle?: 'default' | 'person';
imageUrl: string;
theme: Theme;
};
type MmBlocksImageContentProps = MmBlocksImageProps & {
displaySrc: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
errorFrame: {
alignItems: 'center',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
justifyContent: 'center',
},
}));
const MmBlocksImageContent = ({
altText,
caps,
displaySrc,
imageMetadata,
imageStyle = 'default',
imageUrl,
theme,
}: MmBlocksImageContentProps) => {
const layoutWidth = useContext(MmBlocksLayoutWidthContext);
const {location, postId} = useContext(MmBlocksRenderContext)!;
const galleryIdentifier = `${postId}-MmBlocksImage-${location}`;
const isTablet = useIsTablet();
const viewPortWidth = getViewPortWidth(false, isTablet);
const style = getStyleSheet(theme);
const [loadError, setLoadError] = useState(false);
const [intrinsicSize, setIntrinsicSize] = useState<{width: number; height: number} | undefined>(undefined);
const isSvg = isSVGImage(imageMetadata, imageUrl);
const layout = useMemo((): MmBlocksImageLayout => {
if (intrinsicSize && !imageMetadata?.width && !imageMetadata?.height) {
return refineMmBlocksImageLayoutFromIntrinsic(
intrinsicSize.width,
intrinsicSize.height,
caps,
layoutWidth,
viewPortWidth,
);
}
return computeMmBlocksImageLayout(caps, layoutWidth, imageMetadata, viewPortWidth);
}, [caps, imageMetadata, intrinsicSize, layoutWidth, viewPortWidth]);
const fileId = useMemo(() => `uid-mm-blocks-image-${urlSafeBase64Encode(imageUrl)}`, [imageUrl]);
const dimensionsStyle = useMemo(() => ({
width: layout.width,
height: layout.height,
}), [layout.height, layout.width]);
const imageBorderRadius = imageStyle === 'person' ? layout.width / 2 : 3;
const onPress = useCallback(() => {
const item: GalleryItemType = {
id: fileId,
postId,
uri: displaySrc,
width: layout.galleryWidth,
height: layout.galleryHeight,
name: altText || extractFilenameFromUrl(imageUrl) || 'image.png',
mime_type: lookupMimeType(imageUrl) || 'image/png',
type: 'image',
lastPictureUpdate: 0,
cacheKey: fileId,
};
openGalleryAtIndex(galleryIdentifier, 0, [item]);
}, [altText, displaySrc, fileId, galleryIdentifier, imageUrl, layout.galleryHeight, layout.galleryWidth, postId]);
const {ref, onGestureEvent, styles: galleryStyles} = useGalleryItem(
galleryIdentifier,
0,
onPress,
);
const onError = useCallback(() => {
setLoadError(true);
}, []);
const onLoad = useCallback((event: ImageLoadEventData) => {
const intrinsicWidth = event.source.width;
const intrinsicHeight = event.source.height;
if (!intrinsicWidth || !intrinsicHeight) {
return;
}
if (imageMetadata?.width && imageMetadata?.height) {
return;
}
setIntrinsicSize({width: intrinsicWidth, height: intrinsicHeight});
}, [imageMetadata?.height, imageMetadata?.width]);
if (!isValidUrl(imageUrl) || !displaySrc || loadError || (imageMetadata && isGifTooLarge(imageMetadata))) {
return (
<View style={[style.errorFrame, dimensionsStyle]}>
<FileIcon
failed={true}
testID='mm_blocks_image.error'
/>
</View>
);
}
const renderImage = () => {
if (isSvg) {
return (
<SvgUri
uri={displaySrc}
width={layout.width}
height={layout.height}
style={[dimensionsStyle, {borderRadius: imageBorderRadius}]}
onError={onError}
/>
);
}
return (
<ExpoImage
id={fileId}
ref={ref}
nativeID={`MmBlocksImage-${fileId}`}
source={{uri: displaySrc}}
style={[dimensionsStyle, {borderRadius: imageBorderRadius}]}
contentFit={imageStyle === 'person' ? 'cover' : 'contain'}
onError={onError}
onLoad={onLoad}
/>
);
};
const imageContent = (
<Pressable
onPress={onGestureEvent}
style={({pressed}) => pressed && {opacity: 0.72}}
accessibilityRole='imagebutton'
accessibilityLabel={altText}
>
{renderImage()}
</Pressable>
);
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View style={[galleryStyles, dimensionsStyle]}>
{imageContent}
</Animated.View>
</GalleryInit>
);
};
const MmBlocksImage = ({imageUrl, imageMetadata, ...contentProps}: MmBlocksImageProps) => {
return (
<ExternalImage
src={imageUrl}
imageMetadata={imageMetadata}
>
{(displaySrc: string) => (
<MmBlocksImageContent
{...contentProps}
displaySrc={displaySrc}
imageMetadata={imageMetadata}
imageUrl={imageUrl}
/>
)}
</ExternalImage>
);
};
export default MmBlocksImage;