import React, { useState } from 'react'; import { Image, ImageProps, PixelRatio, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; import { MessageContextValue, useMessageContext, } from '../../contexts/messageContext/MessageContext'; import { MessagesContextValue, useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; import { OverlayContextValue, useOverlayContext, } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { getUrlWithoutParams, makeImageCompatibleUrl } from '../../utils/utils'; import type { MessageType } from '../../components/MessageList/hooks/useMessageList'; import type { DefaultAttachmentType, DefaultChannelType, DefaultCommandType, DefaultEventType, DefaultMessageType, DefaultReactionType, DefaultUserType, UnknownType, } from '../../types/types'; const GalleryImage: React.FC< Omit & { height: number | string; uri: string; } > = (props) => { const { height, uri, ...rest } = props; const [error, setError] = useState(false); return ( setError(true)} source={{ uri: uri.includes('&h=%2A') ? error ? uri : uri.replace('h=%2A', `h=${PixelRatio.getPixelSizeForLayoutSize(Number(height))}`) : uri, }} testID='image-attachment-single' /> ); }; const MemoizedGalleryImage = React.memo( GalleryImage, (prevProps, nextProps) => prevProps.height === nextProps.height && getUrlWithoutParams(prevProps.uri) === getUrlWithoutParams(nextProps.uri), ) as typeof GalleryImage; const styles = StyleSheet.create({ flex: { flex: 1 }, galleryContainer: { borderTopLeftRadius: 13, borderTopRightRadius: 13, flexDirection: 'row', flexWrap: 'wrap', overflow: 'hidden', }, imageContainer: { flex: 1, padding: 1 }, moreImagesContainer: { alignItems: 'center', justifyContent: 'center', margin: 1, }, moreImagesText: { color: '#FFFFFF', fontSize: 26, fontWeight: '700' }, }); export type GalleryPropsWithContext< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Pick & Pick< MessageContextValue, | 'alignment' | 'groupStyles' | 'images' | 'onLongPress' | 'onPress' | 'onPressIn' | 'preventPress' | 'threadList' > & Pick< MessagesContextValue, 'additionalTouchableProps' | 'legacyImageViewerSwipeBehaviour' > & Pick & { /** * `message` prop has been introduced here as part of `legacyImageViewerSwipeBehaviour` prop. * https://github.com/GetStream/stream-chat-react-native/commit/d5eac6193047916f140efe8e396a671675c9a63f * messageId and messageText may seem redundant now, but to avoid breaking change as part * of minor release, we are keeping those props. * * Also `message` type should ideally be imported from MessageContextValue and not be explicitely mentioned * here, but due to some circular dependencies within the SDK, it causes "exccesive deep nesting" issue with * typescript within Channel component. We should take it as a mini-project and resolve all these circular imports. * * TODO[major]: remove messageId and messageText * TODO: Fix circular dependencies of imports */ hasThreadReplies?: boolean; message?: MessageType; messageId?: string; messageText?: string; }; const GalleryWithContext = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: GalleryPropsWithContext, ) => { const { additionalTouchableProps, alignment, groupStyles, hasThreadReplies, images, legacyImageViewerSwipeBehaviour, message, messageId, messageText: messageTextProp, onLongPress, onPress, onPressIn, preventPress, setBlurType, setImage, setImages, setOverlay, threadList, } = props; const { theme: { colors: { overlay }, imageGallery: { blurType }, messageSimple: { gallery: { galleryContainer, galleryItemColumn, halfSize, image, imageContainer, moreImagesContainer, moreImagesText, size, width, }, }, }, } = useTheme(); if (!images?.length) return null; // [[{ height: number; url: string; }], [{ height: number; url: string; }, { height: number; url: string; }]] const galleryImages = images.slice(0, 4).reduce((returnArray, currentImage, index) => { const attachmentUrl = currentImage.image_url || currentImage.thumb_url; if (attachmentUrl) { const url = makeImageCompatibleUrl(attachmentUrl); if (images.length <= 2) { returnArray[0] = [...(returnArray[0] || []), { height: size || 200, url }]; } else if (images.length === 3) { if (index === 0) { returnArray[0] = [{ height: size || 200, url }]; } else { returnArray[1] = [...(returnArray[1] || []), { height: halfSize || 100, url }]; } } else { returnArray[index % 2] = [ ...(returnArray[index % 2] || []), { height: halfSize || 100, url }, ]; } } return returnArray; }, [] as { height: number | string; url: string }[][]); const groupStyle = `${alignment}_${groupStyles?.[0]?.toLowerCase?.()}`; const messageText = messageTextProp || message?.text; return ( {galleryImages.map((column, colIndex) => ( {column.map(({ height, url }, rowIndex) => { const defaultOnPress = () => { // Added if-else to keep the logic readable, instead of DRY. // if - legacyImageViewerSwipeBehaviour is disabled // else - legacyImageViewerSwipeBehaviour is enabled if (!legacyImageViewerSwipeBehaviour && message) { setImages([message]); setImage({ messageId: messageId || message.id, url }); setBlurType(blurType); setOverlay('gallery'); } else if (legacyImageViewerSwipeBehaviour) { setImage({ messageId: messageId || message?.id, url }); setBlurType(blurType); setOverlay('gallery'); } }; return ( { if (onLongPress) { onLongPress({ emitter: 'gallery', event, }); } }} onPress={(event) => { if (onPress) { onPress({ defaultHandler: defaultOnPress, emitter: 'gallery', event, }); } }} onPressIn={(event) => { if (onPressIn) { onPressIn({ defaultHandler: defaultOnPress, emitter: 'gallery', event, }); } }} style={[ styles.imageContainer, { height, }, imageContainer, ]} testID='image-multiple' {...additionalTouchableProps} > {colIndex === 1 && rowIndex === 1 && images.length > 3 ? ( {`+${images.length - 3}`} ) : null} ); })} ))} ); }; const areEqual = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( prevProps: GalleryPropsWithContext, nextProps: GalleryPropsWithContext, ) => { const { groupStyles: prevGroupStyles, hasThreadReplies: prevHasThreadReplies, images: prevImages, messageText: prevMessageText, } = prevProps; const { groupStyles: nextGroupStyles, hasThreadReplies: nextHasThreadReplies, images: nextImages, messageText: nextMessageText, } = nextProps; const messageTextEqual = prevMessageText === nextMessageText; if (!messageTextEqual) return false; const groupStylesEqual = prevGroupStyles.length === nextGroupStyles.length && prevGroupStyles[0] === nextGroupStyles[0]; if (!groupStylesEqual) return false; const hasThreadRepliesEqual = prevHasThreadReplies === nextHasThreadReplies; if (!hasThreadRepliesEqual) return false; const imagesEqual = prevImages.length === nextImages.length && prevImages.every( (image, index) => getUrlWithoutParams(image.image_url) === getUrlWithoutParams(nextImages[index].image_url) && getUrlWithoutParams(image.thumb_url) === getUrlWithoutParams(nextImages[index].thumb_url), ); if (!imagesEqual) return false; return true; }; const MemoizedGallery = React.memo(GalleryWithContext, areEqual) as typeof GalleryWithContext; export type GalleryProps< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Partial>; /** * UI component for card in attachments. */ export const Gallery = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: GalleryProps, ) => { const { additionalTouchableProps: propAdditionalTouchableProps, alignment: propAlignment, groupStyles: propGroupStyles, hasThreadReplies, images: propImages, messageId, messageText, onLongPress: propOnLongPress, onPress: propOnPress, onPressIn: propOnPressIn, preventPress: propPreventPress, setBlurType: propSetBlurType, setImage: propSetImage, setOverlay: propSetOverlay, threadList: propThreadList, } = props; const { setImage: contextSetImage, setImages } = useImageGalleryContext(); const { alignment: contextAlignment, groupStyles: contextGroupStyles, images: contextImages, message, onLongPress: contextOnLongPress, onPress: contextOnPress, onPressIn: contextOnPressIn, preventPress: contextPreventPress, threadList: contextThreadList, } = useMessageContext(); const { additionalTouchableProps: contextAdditionalTouchableProps, legacyImageViewerSwipeBehaviour, } = useMessagesContext(); const { setBlurType: contextSetBlurType, setOverlay: contextSetOverlay } = useOverlayContext(); const images = propImages || contextImages; if (!images.length) return null; const additionalTouchableProps = propAdditionalTouchableProps || contextAdditionalTouchableProps; const alignment = propAlignment || contextAlignment; const groupStyles = propGroupStyles || contextGroupStyles; const onLongPress = propOnLongPress || contextOnLongPress; const onPressIn = propOnPressIn || contextOnPressIn; const onPress = propOnPress || contextOnPress; const preventPress = typeof propPreventPress === 'boolean' ? propPreventPress : contextPreventPress; const setBlurType = propSetBlurType || contextSetBlurType; const setImage = propSetImage || contextSetImage; const setOverlay = propSetOverlay || contextSetOverlay; const threadList = propThreadList || contextThreadList; return ( ); }; Gallery.displayName = 'Gallery{messageSimple{gallery}}';