diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index fd124f72a4..793b7a00e7 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -13,10 +13,11 @@ import Dayjs from 'dayjs'; import { SafeAreaView } from 'react-native-safe-area-context'; import { DateHeader, - Photo, useImageGalleryContext, useOverlayContext, useTheme, + ImageGalleryState, + useStateStore, } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -24,7 +25,6 @@ import { usePaginatedAttachments } from '../hooks/usePaginatedAttachments'; import { Picture } from '../icons/Picture'; import type { RouteProp } from '@react-navigation/native'; -import type { Attachment } from 'stream-chat'; import type { StackNavigatorParamList } from '../types'; @@ -61,16 +61,17 @@ export type ChannelImagesScreenProps = { route: ChannelImagesScreenRouteProp; }; +const selector = (state: ImageGalleryState) => ({ + assets: state.assets, +}); + export const ChannelImagesScreen: React.FC = ({ route: { params: { channel }, }, }) => { - const { - messages: images, - setMessages: setImages, - setSelectedMessage: setImage, - } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets } = useStateStore(imageGalleryStateStore.state, selector); const { setOverlay } = useOverlayContext(); const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'image'); const { @@ -79,8 +80,6 @@ export const ChannelImagesScreen: React.FC = ({ }, } = useTheme(); - const channelImages = useRef(images); - const [stickyHeaderDate, setStickyHeaderDate] = useState( Dayjs(messages?.[0]?.created_at).format('MMM YYYY'), ); @@ -106,30 +105,6 @@ export const ChannelImagesScreen: React.FC = ({ } }); - /** - * Photos array created from all currently available - * photo attachments - */ - const photos = messages.reduce((acc: Photo[], cur) => { - const attachmentImages = - (cur.attachments as Attachment[])?.filter( - (attachment) => - attachment.type === 'image' && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ) || []; - - const attachmentPhotos = attachmentImages.map((attachmentImage) => ({ - created_at: cur.created_at, - id: `photoId-${cur.id}-${attachmentImage.image_url || attachmentImage.thumb_url}`, - messageId: cur.id, - uri: attachmentImage.image_url || (attachmentImage.thumb_url as string), - })); - - return [...acc, ...attachmentPhotos]; - }, []); - const messagesWithImages = messages .map((message) => ({ ...message, groupStyles: [], readBy: false })) .filter((message) => { @@ -145,24 +120,11 @@ export const ChannelImagesScreen: React.FC = ({ return false; }); - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = messagesWithImages - .map((message) => - (message.attachments as Attachment[]) - .map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - useEffect(() => { - setImages(messagesWithImages); - const channelImagesCurrent = channelImages.current; - return () => setImages(channelImagesCurrent); + imageGalleryStateStore.openImageGallery({ messages: messagesWithImages }); + return () => imageGalleryStateStore.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageString, setImages]); + }, [imageGalleryStateStore, messagesWithImages.length]); return ( @@ -170,7 +132,7 @@ export const ChannelImagesScreen: React.FC = ({ `${item.id}-${index}`} ListEmptyComponent={EmptyListComponent} numColumns={3} @@ -180,9 +142,9 @@ export const ChannelImagesScreen: React.FC = ({ renderItem={({ item }) => ( { - setImage({ - messageId: item.messageId, - url: item.uri, + imageGalleryStateStore.openImageGallery({ + messages: messagesWithImages, + selectedAttachmentUrl: item.uri, }); setOverlay('gallery'); }} @@ -202,7 +164,7 @@ export const ChannelImagesScreen: React.FC = ({ viewAreaCoveragePercentThreshold: 50, }} /> - {photos && photos.length ? ( + {assets.length > 0 ? ( diff --git a/package/package.json b/package/package.json index 0f4028fa85..45b1551cc6 100644 --- a/package/package.json +++ b/package/package.json @@ -68,7 +68,7 @@ ] }, "dependencies": { - "@gorhom/bottom-sheet": "^5.1.8", + "@gorhom/bottom-sheet": "^5.2.8", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 7da851ded1..e5ad796bf8 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -36,10 +36,7 @@ import { isVideoPlayerAvailable } from '../../native'; import { FileTypes } from '../../types/types'; import { getUrlWithoutParams } from '../../utils/utils'; -export type GalleryPropsWithContext = Pick< - ImageGalleryContextValue, - 'setSelectedMessage' | 'setMessages' -> & +export type GalleryPropsWithContext = Pick & Pick< MessageContextValue, | 'alignment' @@ -51,11 +48,11 @@ export type GalleryPropsWithContext = Pick< | 'onPressIn' | 'preventPress' | 'threadList' + | 'message' > & Pick< MessagesContextValue, | 'additionalPressableProps' - | 'legacyImageViewerSwipeBehaviour' | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' @@ -65,19 +62,6 @@ export type GalleryPropsWithContext = Pick< Pick & { channelId: string | undefined; hasThreadReplies?: boolean; - /** - * `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 "excessive deep nesting" issue with - * typescript within Channel component. We should take it as a mini-project and resolve all these circular imports. - * - * TODO: Fix circular dependencies of imports - */ - message?: LocalMessage; }; const GalleryWithContext = (props: GalleryPropsWithContext) => { @@ -86,19 +70,17 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { alignment, groupStyles, hasThreadReplies, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, images, - legacyImageViewerSwipeBehaviour, message, onLongPress, onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, threadList, videos, VideoThumbnail, @@ -204,13 +186,13 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { additionalPressableProps={additionalPressableProps} borderRadius={borderRadius} colIndex={colIndex} + imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} ImageReloadIndicator={ImageReloadIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} - legacyImageViewerSwipeBehaviour={legacyImageViewerSwipeBehaviour} message={message} numOfColumns={numOfColumns} numOfRows={numOfRows} @@ -219,9 +201,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { onPressIn={onPressIn} preventPress={preventPress} rowIndex={rowIndex} - setMessages={setMessages} setOverlay={setOverlay} - setSelectedMessage={setSelectedMessage} thumbnail={thumbnail} VideoThumbnail={VideoThumbnail} /> @@ -252,13 +232,12 @@ type GalleryThumbnailProps = { } & Pick< MessagesContextValue, | 'additionalPressableProps' - | 'legacyImageViewerSwipeBehaviour' | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' | 'ImageReloadIndicator' > & - Pick & + Pick & Pick & Pick; @@ -266,12 +245,12 @@ const GalleryThumbnail = ({ additionalPressableProps, borderRadius, colIndex, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, imagesAndVideos, invertedDirections, - legacyImageViewerSwipeBehaviour, message, numOfColumns, numOfRows, @@ -280,9 +259,7 @@ const GalleryThumbnail = ({ onPressIn, preventPress, rowIndex, - setMessages, setOverlay, - setSelectedMessage, thumbnail, VideoThumbnail, }: GalleryThumbnailProps) => { @@ -304,17 +281,14 @@ const GalleryThumbnail = ({ const { t } = useTranslationContext(); const openImageViewer = () => { - if (!legacyImageViewerSwipeBehaviour && message) { - // Added if-else to keep the logic readable, instead of DRY. - // if - legacyImageViewerSwipeBehaviour is disabled - // else - legacyImageViewerSwipeBehaviour is enabled - setMessages([message]); - setSelectedMessage({ messageId: message.id, url: thumbnail.url }); - setOverlay('gallery'); - } else if (legacyImageViewerSwipeBehaviour) { - setSelectedMessage({ messageId: message?.id, url: thumbnail.url }); - setOverlay('gallery'); + if (!message) { + return; } + imageGalleryStateStore.openImageGallery({ + messages: [message], + selectedAttachmentUrl: thumbnail.url, + }); + setOverlay('gallery'); }; const defaultOnPress = () => { @@ -585,13 +559,12 @@ export const Gallery = (props: GalleryProps) => { onPressIn: propOnPressIn, preventPress: propPreventPress, setOverlay: propSetOverlay, - setSelectedMessage: propSetSelectedMessage, threadList: propThreadList, videos: propVideos, VideoThumbnail: PropVideoThumbnail, } = props; - const { setMessages, setSelectedMessage: contextSetSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); const { alignment: contextAlignment, groupStyles: contextGroupStyles, @@ -609,7 +582,6 @@ export const Gallery = (props: GalleryProps) => { ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, ImageReloadIndicator: ContextImageReloadIndicator, - legacyImageViewerSwipeBehaviour, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -631,7 +603,6 @@ export const Gallery = (props: GalleryProps) => { const onPress = propOnPress || contextOnPress; const preventPress = typeof propPreventPress === 'boolean' ? propPreventPress : contextPreventPress; - const setSelectedMessage = propSetSelectedMessage || contextSetSelectedMessage; const setOverlay = propSetOverlay || contextSetOverlay; const threadList = propThreadList || contextThreadList; const VideoThumbnail = PropVideoThumbnail || ContextVideoThumnbnail; @@ -649,20 +620,18 @@ export const Gallery = (props: GalleryProps) => { channelId: message?.cid, groupStyles, hasThreadReplies: hasThreadReplies || !!message?.reply_count, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, images, - legacyImageViewerSwipeBehaviour, message, myMessageTheme, onLongPress, onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, threadList, videos, VideoThumbnail, diff --git a/package/src/components/Attachment/Giphy.tsx b/package/src/components/Attachment/Giphy.tsx index 8b71abb187..39a5788857 100644 --- a/package/src/components/Attachment/Giphy.tsx +++ b/package/src/components/Attachment/Giphy.tsx @@ -132,10 +132,7 @@ const styles = StyleSheet.create({ }, }); -export type GiphyPropsWithContext = Pick< - ImageGalleryContextValue, - 'setSelectedMessage' | 'setMessages' -> & +export type GiphyPropsWithContext = Pick & Pick< MessageContextValue, | 'handleAction' @@ -164,6 +161,7 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { giphyVersion, handleAction, ImageComponent = Image, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, isMyMessage, @@ -172,9 +170,7 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, } = props; const { actions, giphy: giphyData, image_url, thumb_url, title, type } = attachment; @@ -209,8 +205,10 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { const giphyDimensions: { height?: number; width?: number } = {}; const defaultOnPress = () => { - setMessages([message]); - setSelectedMessage({ messageId: message.id, url: uri }); + if (!uri) { + return; + } + imageGalleryStateStore.openImageGallery({ messages: [message], selectedAttachmentUrl: uri }); setOverlay('gallery'); }; @@ -452,7 +450,7 @@ export const Giphy = (props: GiphyProps) => { useMessageContext(); const { ImageComponent } = useChatContext(); const { additionalPressableProps, giphyVersion } = useMessagesContext(); - const { setMessages, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); const { setOverlay } = useOverlayContext(); const { @@ -470,6 +468,7 @@ export const Giphy = (props: GiphyProps) => { giphyVersion, handleAction, ImageComponent, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, isMyMessage, @@ -478,9 +477,7 @@ export const Giphy = (props: GiphyProps) => { onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, }} {...props} /> diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index bc4dc9af2f..9d11e70eb3 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -50,8 +50,7 @@ import { CreatePollIcon as DefaultCreatePollIcon } from '../../components/Poll/c import { AttachmentPickerContextValue, AttachmentPickerProvider, - MessageContextValue, -} from '../../contexts'; +} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { AudioPlayerContextProps, AudioPlayerProvider, @@ -61,6 +60,7 @@ import type { UseChannelStateValue } from '../../contexts/channelsStateContext/u import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; +import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { InputMessageInputContextValue, MessageInputProvider, @@ -348,7 +348,6 @@ export type ChannelPropsWithContext = Pick & | 'InlineDateSeparator' | 'InlineUnreadIndicator' | 'isAttachmentEqual' - | 'legacyImageViewerSwipeBehaviour' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'ImageReloadIndicator' @@ -582,7 +581,7 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorText, numberOfAttachmentImagesToLoadPerCall = 60, numberOfAttachmentPickerImageColumns = 3, - + giphyVersion = 'fixed_height', bottomInset = 0, CameraSelectorIcon = DefaultCameraSelectorIcon, FileSelectorIcon = DefaultFileSelectorIcon, @@ -627,7 +626,6 @@ const ChannelWithContext = (props: PropsWithChildren) = Gallery = GalleryDefault, getMessageGroupStyle, Giphy = GiphyDefault, - giphyVersion = 'fixed_height', handleAttachButtonPress, handleBan, handleCopy, @@ -667,7 +665,6 @@ const ChannelWithContext = (props: PropsWithChildren) = keyboardBehavior, KeyboardCompatibleView = KeyboardCompatibleViewDefault, keyboardVerticalOffset, - legacyImageViewerSwipeBehaviour = false, LoadingErrorIndicator = LoadingErrorIndicatorDefault, LoadingIndicator = LoadingIndicatorDefault, loadingMore: loadingMoreProp, @@ -1953,7 +1950,6 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 0d469486b2..8e61a7dc60 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -52,7 +52,6 @@ export const useCreateMessagesContext = ({ InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, @@ -171,7 +170,6 @@ export const useCreateMessagesContext = ({ InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index ccd2193b93..739b618eb8 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,21 +1,18 @@ -import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Image, ImageStyle, Keyboard, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, - runOnJS, - runOnUI, - SharedValue, + useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; -import { BottomSheetModal as BottomSheetModalOriginal } from '@gorhom/bottom-sheet'; -import type { UserResponse } from 'stream-chat'; +import BottomSheet from '@gorhom/bottom-sheet'; import { AnimatedGalleryImage } from './components/AnimatedGalleryImage'; import { AnimatedGalleryVideo } from './components/AnimatedGalleryVideo'; @@ -36,20 +33,19 @@ import { import { useImageGalleryGestures } from './hooks/useImageGalleryGestures'; -import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; -import { useImageGalleryContext } from '../../contexts/imageGalleryContext/ImageGalleryContext'; -import { OverlayProviderProps } from '../../contexts/overlayContext/OverlayContext'; +import { + ImageGalleryProviderProps, + useImageGalleryContext, +} from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + OverlayContextValue, + useOverlayContext, +} from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks'; import { useViewport } from '../../hooks/useViewport'; -import { isVideoPlayerAvailable, VideoType } from '../../native'; +import { ImageGalleryState } from '../../state-store/image-gallery-state-store'; import { FileTypes } from '../../types/types'; -import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; -import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; -import { getGiphyMimeType } from '../Attachment/utils/getGiphyMimeType'; -import { - BottomSheetModal, - BottomSheetModalProvider, -} from '../BottomSheetCompatibility/BottomSheetModal'; const MARGIN = 32; @@ -104,36 +100,40 @@ export type ImageGalleryCustomComponents = { }; }; -type Props = ImageGalleryCustomComponents & { - overlayOpacity: SharedValue; -} & Pick< - OverlayProviderProps, - | 'giphyVersion' - | 'imageGalleryGridSnapPoints' - | 'imageGalleryGridHandleHeight' - | 'numberOfImageGalleryGridColumns' - | 'autoPlayVideo' - >; - -export const ImageGallery = (props: Props) => { +const imageGallerySelector = (state: ImageGalleryState) => ({ + assets: state.assets, + currentIndex: state.currentIndex, +}); + +type ImageGalleryWithContextProps = Pick< + ImageGalleryProviderProps, + | 'imageGalleryCustomComponents' + | 'imageGalleryGridSnapPoints' + | 'imageGalleryGridHandleHeight' + | 'numberOfImageGalleryGridColumns' +> & + Pick; + +export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => { const { - autoPlayVideo = false, - giphyVersion = 'fixed_height', - imageGalleryCustomComponents, - imageGalleryGridHandleHeight = 40, + imageGalleryGridHandleHeight, imageGalleryGridSnapPoints, + imageGalleryCustomComponents, numberOfImageGalleryGridColumns, overlayOpacity, } = props; - const { resizableCDNHosts } = useChatConfigContext(); const { theme: { colors: { white_snow }, imageGallery: { backgroundColor, pager, slide }, }, } = useTheme(); - const [gridPhotos, setGridPhotos] = useState([]); - const { messages, selectedMessage, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets, currentIndex } = useStateStore( + imageGalleryStateStore.state, + imageGallerySelector, + ); + const { videoPlayerPool } = imageGalleryStateStore; const { vh, vw } = useViewport(); @@ -144,7 +144,7 @@ export const ImageGallery = (props: Props) => { const halfScreenHeight = fullWindowHeight / 2; const quarterScreenHeight = fullWindowHeight / 4; const snapPoints = React.useMemo( - () => [(fullWindowHeight * 3) / 4, fullWindowHeight - imageGalleryGridHandleHeight], + () => [(fullWindowHeight * 3) / 4, fullWindowHeight - (imageGalleryGridHandleHeight ?? 0)], // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -152,7 +152,7 @@ export const ImageGallery = (props: Props) => { /** * BottomSheetModal ref */ - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); /** * BottomSheetModal state @@ -165,13 +165,13 @@ export const ImageGallery = (props: Props) => { * set to none for fast opening */ const screenTranslateY = useSharedValue(fullWindowHeight); - const showScreen = () => { + const showScreen = useCallback(() => { 'worklet'; screenTranslateY.value = withTiming(0, { duration: 250, easing: Easing.out(Easing.ease), }); - }; + }, [screenTranslateY]); /** * Run the fade animation on visible change @@ -179,8 +179,7 @@ export const ImageGallery = (props: Props) => { useEffect(() => { Keyboard.dismiss(); showScreen(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [showScreen]); /** * Image height from URL or default to full screen height @@ -199,128 +198,25 @@ export const ImageGallery = (props: Props) => { const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); const scale = useSharedValue(1); - const translationX = useSharedValue(0); + const translationX = useSharedValue(-(fullWindowWidth + MARGIN) * currentIndex); - /** - * Photos array created from all currently available - * photo attachments - */ - - const photos = useMemo( - () => - messages.reduce((acc: Photo[], cur) => { - const attachmentImages = - cur.attachments?.filter( - (attachment) => - (attachment.type === FileTypes.Giphy && - (attachment.giphy?.[giphyVersion]?.url || - attachment.thumb_url || - attachment.image_url)) || - (attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - getUrlOfImageAttachment(attachment)) || - (isVideoPlayerAvailable() && attachment.type === FileTypes.Video), - ) || []; - - const attachmentPhotos = attachmentImages.map((a) => { - const imageUrl = getUrlOfImageAttachment(a) as string; - const giphyURL = a.giphy?.[giphyVersion]?.url || a.thumb_url || a.image_url; - const isInitiallyPaused = !autoPlayVideo; - - return { - channelId: cur.cid, - created_at: cur.created_at, - duration: 0, - id: `photoId-${cur.id}-${imageUrl}`, - messageId: cur.id, - mime_type: a.type === 'giphy' ? getGiphyMimeType(giphyURL ?? '') : a.mime_type, - original_height: a.original_height, - original_width: a.original_width, - paused: isInitiallyPaused, - progress: 0, - thumb_url: a.thumb_url, - type: a.type, - uri: - a.type === 'giphy' - ? giphyURL - : getResizedImageUrl({ - height: fullWindowHeight, - resizableCDNHosts, - url: imageUrl, - width: fullWindowWidth, - }), - user: cur.user, - user_id: cur.user_id, - }; - }); - - return [...attachmentPhotos, ...acc] as Photo[]; - }, []), - [autoPlayVideo, fullWindowHeight, fullWindowWidth, giphyVersion, messages, resizableCDNHosts], + useAnimatedReaction( + () => currentIndex, + (index) => { + translationX.value = -(fullWindowWidth + MARGIN) * index; + }, + [currentIndex, fullWindowWidth], ); - /** - * The URL for the images may differ because of dimensions passed as - * part of the query. - */ - const stripQueryFromUrl = (url: string) => url.split('?')[0]; - - const photoSelectedIndex = useMemo(() => { - const idx = photos.findIndex( - (photo) => - photo.messageId === selectedMessage?.messageId && - stripQueryFromUrl(photo.uri) === stripQueryFromUrl(selectedMessage?.url || ''), - ); - - return idx === -1 ? 0 : idx; - }, [photos, selectedMessage]); - - /** - * JS and UI index values, the JS follows the UI but is needed - * for rendering the virtualized image list - */ - const [selectedIndex, setSelectedIndex] = useState(photoSelectedIndex); - const index = useSharedValue(photoSelectedIndex); - - const [imageGalleryAttachments, setImageGalleryAttachments] = useState(photos); - - /** - * Photos length needs to be kept as a const here so if the length - * changes it causes the pan gesture handler function to refresh. This - * does not work if the calculation for the length of the array is left - * inside the gesture handler as it will have an array as a dependency - */ - const photoLength = photos.length; - - /** - * Set selected photo when changed via pressing in the message list - */ - useEffect(() => { - const updatePosition = (newIndex: number) => { - 'worklet'; - - if (newIndex > -1) { - index.value = newIndex; - translationX.value = -(fullWindowWidth + MARGIN) * newIndex; - runOnJS(setSelectedIndex)(newIndex); - } - }; - - runOnUI(updatePosition)(photoSelectedIndex); - }, [fullWindowWidth, index, photoSelectedIndex, translationX]); - /** * Image heights are not provided and therefore need to be calculated. * We start by allowing the image to be the full height then reduce it * to the proper scaled height based on the width being restricted to the * screen width when the dimensions are received. */ - const uriForCurrentImage = imageGalleryAttachments[selectedIndex]?.uri; - useEffect(() => { let currentImageHeight = fullWindowHeight; - const photo = imageGalleryAttachments[index.value]; + const photo = assets[currentIndex]; const height = photo?.original_height; const width = photo?.original_width; @@ -338,7 +234,16 @@ export const ImageGallery = (props: Props) => { setCurrentImageHeight(currentImageHeight); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uriForCurrentImage]); + }, [currentIndex]); + + // If you change the current index, pause the active video player. + useEffect(() => { + const activePlayer = videoPlayerPool.getActivePlayer(); + + if (activePlayer) { + activePlayer.pause(); + } + }, [currentIndex, videoPlayerPool]); const { doubleTap, pan, pinch, singleTap } = useImageGalleryGestures({ currentImageHeight, @@ -347,12 +252,9 @@ export const ImageGallery = (props: Props) => { headerFooterVisible, offsetScale, overlayOpacity, - photoLength, scale, screenHeight: fullWindowHeight, screenWidth: fullWindowWidth, - selectedIndex, - setSelectedIndex, translateX, translateY, translationX, @@ -422,7 +324,6 @@ export const ImageGallery = (props: Props) => { const closeGridView = () => { if (bottomSheetModalRef.current?.close) { bottomSheetModalRef.current.close(); - setGridPhotos([]); } }; @@ -430,79 +331,8 @@ export const ImageGallery = (props: Props) => { * Function to open BottomSheetModal with image grid */ const openGridView = () => { - if (bottomSheetModalRef.current?.present) { - bottomSheetModalRef.current.present(); - setGridPhotos(imageGalleryAttachments); - } - }; - - const handleEnd = () => { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, true); - handleProgress(imageGalleryAttachments[selectedIndex].id, 1, true); - }; - - const videoRef = useRef(null); - - const handleLoad = (index: string, duration: number) => { - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - duration: imageGalleryAttachment.id === index ? duration : imageGalleryAttachment.duration, - })), - ); - }; - - const handleProgress = (index: string, progress: number, hasEnd?: boolean) => { - setImageGalleryAttachments((prevImageGalleryAttachments) => - prevImageGalleryAttachments.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - progress: - imageGalleryAttachment.id === index - ? hasEnd - ? 1 - : progress - : imageGalleryAttachment.progress, - })), - ); - }; - - const handlePlayPause = (index: string, pausedStatus?: boolean) => { - if (pausedStatus === false) { - // If the status is false we set the audio with the index as playing and the others as paused. - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - paused: imageGalleryAttachment.id === index ? false : true, - })), - ); - - if (videoRef.current?.play) { - videoRef.current.play(); - } - } else { - // If the status is true we simply set all the audio's paused state as true. - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - paused: true, - })), - ); - - if (videoRef.current?.pause) { - videoRef.current.pause(); - } - } - }; - - const onPlayPause = (status?: boolean) => { - if (status === undefined) { - if (imageGalleryAttachments[selectedIndex].paused) { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, false); - } else { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, true); - } - } else { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, status); + if (bottomSheetModalRef.current?.snapToIndex) { + bottomSheetModalRef.current.snapToIndex(0); } }; @@ -526,24 +356,16 @@ export const ImageGallery = (props: Props) => { - {imageGalleryAttachments.map((photo, i) => + {assets.map((photo, i) => photo.type === FileTypes.Video ? ( i} - repeat={true} + photo={photo} scale={scale} screenHeight={fullWindowHeight} - selected={selectedIndex === i} - shouldRender={Math.abs(selectedIndex - i) < 4} - source={{ uri: photo.uri }} style={[ { height: fullWindowHeight * 8, @@ -554,20 +376,17 @@ export const ImageGallery = (props: Props) => { ]} translateX={translateX} translateY={translateY} - videoRef={videoRef as RefObject} /> ) : ( i} scale={scale} screenHeight={fullWindowHeight} - selected={selectedIndex === i} - shouldRender={Math.abs(selectedIndex - i) < 4} + screenWidth={fullWindowWidth} style={[ { height: fullWindowHeight * 8, @@ -586,59 +405,66 @@ export const ImageGallery = (props: Props) => { - {imageGalleryAttachments[selectedIndex] && ( - } - visible={headerFooterVisible} - {...imageGalleryCustomComponents?.footer} - /> - )} + - - setCurrentBottomSheetIndex(index)} - ref={bottomSheetModalRef} - snapPoints={imageGalleryGridSnapPoints || snapPoints} - > - - - + setCurrentBottomSheetIndex(index)} + ref={bottomSheetModalRef} + snapPoints={imageGalleryGridSnapPoints || snapPoints} + > + + ); }; +export type ImageGalleryProps = Partial; + +export const ImageGallery = (props: ImageGalleryProps) => { + const { + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + } = useImageGalleryContext(); + const { overlayOpacity } = useOverlayContext(); + return ( + + ); +}; + /** * Clamping worklet to clamp the scaling */ @@ -654,22 +480,4 @@ const styles = StyleSheet.create({ }, }); -export type Photo = { - id: string; - uri: string; - channelId?: string; - created_at?: string | Date; - duration?: number; - messageId?: string; - mime_type?: string; - original_height?: number; - original_width?: number; - paused?: boolean; - progress?: number; - thumb_url?: string; - type?: string; - user?: UserResponse | null; - user_id?: string; -}; - ImageGallery.displayName = 'ImageGallery{imageGallery}'; diff --git a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx b/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx deleted file mode 100644 index 139f90ec76..0000000000 --- a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react'; - -import type { SharedValue } from 'react-native-reanimated'; - -import { act, fireEvent, render, screen } from '@testing-library/react-native'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import { AnimatedGalleryVideo, AnimatedGalleryVideoType } from '../components/AnimatedGalleryVideo'; - -const getComponent = (props: Partial) => ( - - - -); - -describe('ImageGallery', () => { - it('render image gallery component with video rendered', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1); - }); - - it('render empty view when shouldRender is false', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: false, - translateX: { value: 1 } as SharedValue, - }), - ); - - expect(screen.getByLabelText('Empty View Image Gallery')).not.toBeUndefined(); - }); - - it('trigger onEnd and onProgress events handlers of Video component', () => { - const handleEndMock = jest.fn(); - const handleProgressMock = jest.fn(); - - render( - getComponent({ - handleEnd: handleEndMock, - handleProgress: handleProgressMock, - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - - act(() => { - fireEvent(videoComponent, 'onEnd'); - fireEvent(videoComponent, 'onProgress', { currentTime: 10, seekableDuration: 60 }); - }); - - expect(handleEndMock).toHaveBeenCalled(); - expect(handleProgressMock).toHaveBeenCalled(); - }); - - it('trigger onLoadStart event handler of Video component', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onLoadStart'); - }); - expect(spinnerComponent?.props.style[1].opacity).toBe(1); - }); - - it('trigger onLoad event handler of Video component', () => { - const handleLoadMock = jest.fn(); - - render( - getComponent({ - handleLoad: handleLoadMock, - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onLoad', { duration: 10 }); - }); - - expect(handleLoadMock).toHaveBeenCalled(); - expect(spinnerComponent?.props.style[1].opacity).toBe(0); - }); - - it('trigger onBuffer event handler of Video component', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onBuffer', { - isBuffering: false, - }); - }); - - expect(spinnerComponent?.props.style[1].opacity).toBe(0); - - act(() => { - fireEvent(videoComponent, 'onBuffer', { - isBuffering: true, - }); - }); - - expect(spinnerComponent?.props.style[1].opacity).toBe(1); - }); -}); diff --git a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx index e72cd70e3b..02a477da4a 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { SharedValue } from 'react-native-reanimated'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -13,8 +13,6 @@ import { ImageGalleryContextValue, } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { generateGiphyAttachment, generateImageAttachment, @@ -22,7 +20,8 @@ import { } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { ImageGallery } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryProps } from '../ImageGallery'; dayjs.extend(duration); @@ -39,30 +38,48 @@ jest.mock('../../../native.ts', () => { }; }); -const getComponent = (props: Partial) => ( - - - - } /> - - - -); +const ImageGalleryComponent = (props: ImageGalleryProps & { message: LocalMessage }) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const { attachments } = props.message; + imageGalleryStateStore.openImageGallery({ + messages: [props.message], + selectedAttachmentUrl: attachments?.[0]?.asset_url || attachments?.[0]?.image_url || '', + }); + + return ( + }}> + + + + + ); +}; describe('ImageGallery', () => { it('render image gallery component', async () => { render( - getComponent({ - messages: [ + , ); await waitFor(() => { @@ -70,154 +87,4 @@ describe('ImageGallery', () => { expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1); }); }); - - it('handle handleLoad function when video item present and payload duration is available', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10 * 1000, - ); - }); - - const videoDurationComponent = screen.getByLabelText('Video Duration'); - - await waitFor(() => { - expect(videoDurationComponent.children[0]).toBe('00:10'); - }); - }); - - it('handle handleLoad function when video item present and payload duration is undefined', async () => { - render( - getComponent({ - messages: [ - generateMessage({ - attachments: [generateVideoAttachment({ type: 'video' })], - }), - ] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent(videoItemComponent, 'handleLoad', { - duration: undefined, - }); - }); - - const videoDurationComponent = screen.getByLabelText('Video Duration'); - await waitFor(() => { - expect(videoDurationComponent.children[0]).toBe('00:00'); - }); - }); - - it('handle handleProgress function when video item present and payload is well defined', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10, - ); - fireEvent( - videoItemComponent, - 'handleProgress', - `photoId-${message.id}-${attachment.asset_url}`, - 0.3 * 1000, - ); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - - await waitFor(() => { - expect(progressDurationComponent.children[0]).toBe('00:03'); - }); - }); - - it('handle handleProgress function when video item present and payload is not defined', async () => { - render( - getComponent({ - messages: [ - generateMessage({ - attachments: [generateVideoAttachment({ type: 'video' })], - }), - ] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent(videoItemComponent, 'handleLoad', { - duration: 10 * 1000, - }); - fireEvent(videoItemComponent, 'handleProgress', { - currentTime: undefined, - seekableDuration: undefined, - }); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - - await waitFor(() => { - expect(progressDurationComponent.children[0]).toBe('00:00'); - }); - }); - - it('handle handleEnd function when video item present', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10 * 1000, - ); - fireEvent(videoItemComponent, 'handleEnd'); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - await waitFor(() => { - expect(screen.queryAllByLabelText('Play Icon').length).toBeGreaterThan(0); - expect(progressDurationComponent.children[0]).toBe('00:10'); - }); - }); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx index ea553a0eaf..54131fd622 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx @@ -1,13 +1,12 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; -import { LocalMessage } from 'stream-chat'; +import { Attachment, LocalMessage } from 'stream-chat'; -import { Chat } from '../../../components/Chat/Chat'; import { ImageGalleryContext, ImageGalleryContextValue, @@ -18,9 +17,9 @@ import { generateVideoAttachment, } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; import { NativeHandlers } from '../../../native'; -import { ImageGallery, ImageGalleryCustomComponents } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryCustomComponents, ImageGalleryProps } from '../ImageGallery'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); @@ -38,10 +37,75 @@ jest.mock('../../../native.ts', () => { }; }); +const ImageGalleryComponentVideo = (props: ImageGalleryProps) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const attachment = generateVideoAttachment({ type: 'video' }); + imageGalleryStateStore.openImageGallery({ + messages: [ + generateMessage({ + attachments: [attachment], + }) as unknown as LocalMessage, + ], + selectedAttachmentUrl: attachment.asset_url, + }); + + return ( + }}> + + + + + ); +}; + +const ImageGalleryComponentImage = ( + props: ImageGalleryProps & { + attachment: Attachment; + }, +) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + imageGalleryStateStore.openImageGallery({ + messages: [ + generateMessage({ + attachments: [props.attachment], + }) as unknown as LocalMessage, + ], + selectedAttachmentUrl: props.attachment.image_url as string, + }); + + return ( + }}> + + + + + ); +}; + describe('ImageGalleryFooter', () => { it('render image gallery footer component with custom component footer props', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomFooterLeftElement = () => ( Left element @@ -67,35 +131,18 @@ describe('ImageGalleryFooter', () => { ); render( - - - - } - /> - - - , + , ); await waitFor(() => { @@ -107,8 +154,6 @@ describe('ImageGalleryFooter', () => { }); it('render image gallery footer component with custom component footer Grid Icon and Share Icon component', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomShareIconElement = () => ( Share Icon element @@ -122,33 +167,17 @@ describe('ImageGalleryFooter', () => { ); render( - - - - , - ShareIcon: , - }, - } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] - } - overlayOpacity={{ value: 1 } as SharedValue} - /> - - - , + , + ShareIcon: , + }, + } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] + } + overlayOpacity={{ value: 1 } as SharedValue} + />, ); await waitFor(() => { @@ -159,32 +188,13 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with local attachment and no mime_type', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest.spyOn(NativeHandlers, 'saveFile'); const shareImageMock = jest.spyOn(NativeHandlers, 'shareImage'); const deleteFileMock = jest.spyOn(NativeHandlers, 'deleteFile'); const attachment = generateImageAttachment(); - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; @@ -204,32 +214,13 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with local attachment and existing mime_type', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest.spyOn(NativeHandlers, 'saveFile'); const shareImageMock = jest.spyOn(NativeHandlers, 'shareImage'); const deleteFileMock = jest.spyOn(NativeHandlers, 'deleteFile'); const attachment = { ...generateImageAttachment(), mime_type: 'image/png' }; - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; @@ -249,7 +240,6 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with cdn attachment', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest .spyOn(NativeHandlers, 'saveFile') .mockResolvedValue('file:///local/asset/url'); @@ -262,25 +252,7 @@ describe('ImageGalleryFooter', () => { mime_type: 'image/png', }; - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx index 1ea5816c2a..36e2d8d079 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx @@ -1,46 +1,71 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; +import { SharedValue } from 'react-native-reanimated'; + import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { LocalMessage } from '../../../../../../stream-chat-js/dist/types/types'; import { - TranslationContextValue, - TranslationProvider, -} from '../../../contexts/translationContext/TranslationContext'; + ImageGalleryContext, + ImageGalleryContextValue, +} from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { generateImageAttachment, generateVideoAttachment, } from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; import { ImageGrid, ImageGridType } from '../components/ImageGrid'; -const getComponent = (props: Partial = {}) => { - const t = jest.fn((key) => key); +const ImageGalleryGridComponent = (props: Partial & { message: LocalMessage }) => { + const { message } = props; + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + imageGalleryStateStore.openImageGallery({ + messages: [message], + selectedAttachmentUrl: + message.attachments?.[0]?.asset_url || message.attachments?.[0]?.image_url || '', + }); return ( - - + }}> + - - + + ); }; describe('ImageGalleryOverlay', () => { it('should render ImageGalleryGrid', () => { - render(getComponent({ photos: [generateImageAttachment(), generateImageAttachment()] })); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateImageAttachment()], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByLabelText('Image Grid')).toHaveLength(1); }); it('should render ImageGalleryGrid individual images', () => { - render( - getComponent({ - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - }), - ); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByLabelText('Grid Image')).toHaveLength(2); }); @@ -52,27 +77,23 @@ describe('ImageGalleryOverlay', () => { ); - render( - getComponent({ - imageComponent: CustomImageComponent, - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - }), - ); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByText('Image Attachment')).toHaveLength(2); }); it('should trigger the selectAndClose when the Image item is pressed', () => { const closeGridViewMock = jest.fn(); - const setSelectedMessageMock = jest.fn(); - - render( - getComponent({ - closeGridView: closeGridViewMock, - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - setSelectedMessage: setSelectedMessageMock, - }), - ); + + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); const component = screen.getAllByLabelText('Grid Image'); @@ -81,6 +102,5 @@ describe('ImageGalleryOverlay', () => { }); expect(closeGridViewMock).toHaveBeenCalledTimes(1); - expect(setSelectedMessageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx index 4ab178ed4e..bdc8bee50f 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx @@ -6,10 +6,6 @@ import { render, screen } from '@testing-library/react-native'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import { - TranslationContextValue, - TranslationProvider, -} from '../../../contexts/translationContext/TranslationContext'; import { ImageGalleryGridHandleCustomComponentProps, ImageGridHandle, @@ -20,14 +16,10 @@ type ImageGridHandleProps = ImageGalleryGridHandleCustomComponentProps & { }; const getComponent = (props: Partial = {}) => { - const t = jest.fn((key) => key); - return ( - - - - - + + + ); }; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx index f39ffaa28f..a608ea2c00 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; @@ -7,24 +7,17 @@ import { act, render, screen, userEvent, waitFor } from '@testing-library/react- import { LocalMessage } from 'stream-chat'; -import { Chat } from '../../../components/Chat/Chat'; import { ImageGalleryContext, ImageGalleryContextValue, } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; -import { - OverlayContext, - OverlayContextValue, -} from '../../../contexts/overlayContext/OverlayContext'; +import * as overlayContext from '../../../contexts/overlayContext/OverlayContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { - generateImageAttachment, - generateVideoAttachment, -} from '../../../mock-builders/generator/attachment'; +import { generateImageAttachment } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { ImageGallery, ImageGalleryCustomComponents } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryCustomComponents, ImageGalleryProps } from '../ImageGallery'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); @@ -39,10 +32,35 @@ jest.mock('../../../native.ts', () => { }; }); +const ImageGalleryComponent = (props: ImageGalleryProps) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + const attachment = generateImageAttachment(); + imageGalleryStateStore.openImageGallery({ + messages: [generateMessage({ attachments: [attachment] }) as unknown as LocalMessage], + selectedAttachmentUrl: attachment.url, + }); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + return ( + }}> + + + + + ); +}; + describe('ImageGalleryHeader', () => { it('render image gallery header component with custom component header props', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomHeaderLeftElement = () => ( Left element @@ -62,34 +80,17 @@ describe('ImageGalleryHeader', () => { ); render( - - - - } - /> - - - , + , ); await waitFor(() => { @@ -100,8 +101,6 @@ describe('ImageGalleryHeader', () => { }); it('render image gallery header component with custom Close Icon component', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomCloseIconElement = () => ( Close Icon element @@ -109,32 +108,15 @@ describe('ImageGalleryHeader', () => { ); render( - - - - , - }, - } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] - } - overlayOpacity={{ value: 1 } as SharedValue} - /> - - - , + , + }, + } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] + } + />, ); await waitFor(() => { expect(screen.queryAllByText('Close Icon element')).toHaveLength(1); @@ -142,33 +124,16 @@ describe('ImageGalleryHeader', () => { }); it('should trigger the hideOverlay function on button onPress', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); const setOverlayMock = jest.fn(); const user = userEvent.setup(); - render( - - - - } /> - - - , - ); + jest.spyOn(overlayContext, 'useOverlayContext').mockImplementation(() => ({ + setOverlay: setOverlayMock, + })); + + render(); - act(() => { + await act(() => { user.press(screen.getByLabelText('Hide Overlay')); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx deleted file mode 100644 index 4788260271..0000000000 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; - -import { ReactTestInstance } from 'react-test-renderer'; - -import { act, render, screen, userEvent, waitFor } from '@testing-library/react-native'; - -import dayjs from 'dayjs'; -import duration from 'dayjs/plugin/duration'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import type { ImageGalleryFooterVideoControlProps } from '../components/ImageGalleryFooter'; -import { ImageGalleryVideoControl } from '../components/ImageGalleryVideoControl'; - -dayjs.extend(duration); - -const getComponent = (props: Partial) => ( - - - -); - -describe('ImageGalleryOverlay', () => { - it('should trigger the onPlayPause when play/pause button is pressed', async () => { - const onPlayPauseMock = jest.fn(); - const user = userEvent.setup(); - - render(getComponent({ onPlayPause: onPlayPauseMock })); - - const component = screen.queryByLabelText('Play Pause Button') as ReactTestInstance; - - act(() => { - user.press(component); - }); - - await waitFor(() => { - expect(component).not.toBeUndefined(); - expect(onPlayPauseMock).toHaveBeenCalled(); - }); - }); - - it('should render the play icon when paused prop is true', async () => { - render(getComponent({ paused: true })); - - const components = screen.queryAllByLabelText('Play Icon').length; - - await waitFor(() => { - expect(components).toBeGreaterThan(0); - }); - }); - - it('should calculate the videoDuration and progressDuration when they are greater than or equal to 3600', () => { - jest.spyOn(dayjs, 'duration'); - - render( - getComponent({ - duration: 3600 * 1000, - progress: 1, - }), - ); - - const videoDurationComponent = screen.queryByLabelText('Video Duration') as ReactTestInstance; - const progressDurationComponent = screen.queryByLabelText( - 'Progress Duration', - ) as ReactTestInstance; - - expect(videoDurationComponent.children[0]).toBe('01:00:00'); - expect(progressDurationComponent.children[0]).toBe('01:00:00'); - }); - - it('should calculate the videoDuration and progressDuration when they are less than 3600', () => { - jest.spyOn(dayjs, 'duration'); - - render( - getComponent({ - duration: 60 * 1000, - progress: 0.5, - }), - ); - - const videoDurationComponent = screen.queryByLabelText('Video Duration') as ReactTestInstance; - const progressDurationComponent = screen.queryByLabelText( - 'Progress Duration', - ) as ReactTestInstance; - - expect(videoDurationComponent.children[0]).toBe('01:00'); - expect(progressDurationComponent.children[0]).toBe('00:30'); - }); -}); diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx index f3aab7ea39..b6c87ab893 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx @@ -1,8 +1,16 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View } from 'react-native'; import type { ImageStyle, StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; +import { useChatConfigContext } from '../../../contexts/chatConfigContext/ChatConfigContext'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useStateStore } from '../../../hooks'; +import { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; +import { getResizedImageUrl } from '../../../utils/getResizedImageUrl'; import { useAnimatedGalleryStyle } from '../hooks/useAnimatedGalleryStyle'; const oneEighth = 1 / 8; @@ -11,17 +19,19 @@ type Props = { accessibilityLabel: string; index: number; offsetScale: SharedValue; - photo: { uri: string }; - previous: boolean; + photo: ImageGalleryAsset; scale: SharedValue; screenHeight: number; - selected: boolean; - shouldRender: boolean; + screenWidth: number; translateX: SharedValue; translateY: SharedValue; style?: StyleProp; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + export const AnimatedGalleryImage = React.memo( (props: Props) => { const { @@ -29,15 +39,29 @@ export const AnimatedGalleryImage = React.memo( index, offsetScale, photo, - previous, scale, screenHeight, - selected, - shouldRender, + screenWidth, style, translateX, translateY, } = props; + const { imageGalleryStateStore } = useImageGalleryContext(); + const { resizableCDNHosts } = useChatConfigContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); + + const uri = useMemo(() => { + return getResizedImageUrl({ + height: screenHeight, + resizableCDNHosts, + url: photo.uri, + width: screenWidth, + }); + }, [photo.uri, resizableCDNHosts, screenHeight, screenWidth]); + + const selected = currentIndex === index; + const previous = currentIndex > index; + const shouldRender = Math.abs(currentIndex - index) < 4; const animatedStyles = useAnimatedGalleryStyle({ index, @@ -63,19 +87,17 @@ export const AnimatedGalleryImage = React.memo( ); }, (prevProps, nextProps) => { if ( - prevProps.selected === nextProps.selected && - prevProps.shouldRender === nextProps.shouldRender && prevProps.photo.uri === nextProps.photo.uri && - prevProps.previous === nextProps.previous && prevProps.index === nextProps.index && - prevProps.screenHeight === nextProps.screenHeight + prevProps.screenHeight === nextProps.screenHeight && + prevProps.screenWidth === nextProps.screenWidth ) { return true; } diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx index 0fe17ca843..9941168f78 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx @@ -1,8 +1,11 @@ -import React, { useState } from 'react'; +import React, { RefObject, useEffect, useRef, useState } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; import type { StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useStateStore } from '../../../hooks'; + import { isVideoPlayerAvailable, NativeHandlers, @@ -12,29 +15,27 @@ import { VideoType, } from '../../../native'; +import { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; +import { VideoPlayerState } from '../../../state-store/video-player'; +import { ONE_SECOND_IN_MILLISECONDS } from '../../../utils/constants'; import { Spinner } from '../../UIComponents/Spinner'; import { useAnimatedGalleryStyle } from '../hooks/useAnimatedGalleryStyle'; +import { useImageGalleryVideoPlayer } from '../hooks/useImageGalleryVideoPlayer'; const oneEighth = 1 / 8; export type AnimatedGalleryVideoType = { attachmentId: string; - handleEnd: () => void; - handleLoad: (index: string, duration: number) => void; - handleProgress: (index: string, progress: number, hasEnd?: boolean) => void; index: number; offsetScale: SharedValue; - paused: boolean; - previous: boolean; scale: SharedValue; screenHeight: number; - selected: boolean; - shouldRender: boolean; - source: { uri: string }; + photo: ImageGalleryAsset; translateX: SharedValue; translateY: SharedValue; - videoRef: React.RefObject; - repeat?: boolean; style?: StyleProp; }; @@ -48,46 +49,62 @@ const styles = StyleSheet.create({ }, }); +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + +const videoPlayerSelector = (state: VideoPlayerState) => ({ + isPlaying: state.isPlaying, +}); + export const AnimatedGalleryVideo = React.memo( (props: AnimatedGalleryVideoType) => { const [opacity, setOpacity] = useState(1); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { attachmentId, - handleEnd, - handleLoad, - handleProgress, index, offsetScale, - paused, - previous, - repeat, scale, screenHeight, - selected, - shouldRender, - source, style, + photo, translateX, translateY, - videoRef, } = props; + + const videoRef = useRef(null); + + const videoPlayer = useImageGalleryVideoPlayer({ + id: attachmentId, + }); + + useEffect(() => { + if (videoRef.current) { + videoPlayer.initPlayer({ playerRef: videoRef.current }); + } + }, [videoPlayer]); + + const { isPlaying } = useStateStore(videoPlayer.state, videoPlayerSelector); + const onLoadStart = () => { setOpacity(1); }; const onLoad = (payload: VideoPayloadData) => { setOpacity(0); - // Duration is in seconds so we convert to milliseconds. - handleLoad(attachmentId, payload.duration * 1000); + + videoPlayer.duration = payload.duration * ONE_SECOND_IN_MILLISECONDS; }; const onEnd = () => { - handleEnd(); + videoPlayer.stop(); }; const onProgress = (data: VideoProgressData) => { - handleProgress(attachmentId, data.currentTime / data.seekableDuration); + videoPlayer.position = data.currentTime * ONE_SECOND_IN_MILLISECONDS; }; const onBuffer = ({ isBuffering }: { isBuffering: boolean }) => { @@ -108,13 +125,10 @@ export const AnimatedGalleryVideo = React.memo( } else { // Update your UI for the loaded state setOpacity(0); - handleLoad(attachmentId, playbackStatus.durationMillis); + videoPlayer.duration = playbackStatus.durationMillis; if (playbackStatus.isPlaying) { // Update your UI for the playing state - handleProgress( - attachmentId, - playbackStatus.positionMillis / playbackStatus.durationMillis, - ); + videoPlayer.progress = playbackStatus.positionMillis / playbackStatus.durationMillis; } if (playbackStatus.isBuffering) { @@ -124,11 +138,15 @@ export const AnimatedGalleryVideo = React.memo( if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { // The player has just finished playing and will stop. Maybe you want to play something else? - handleEnd(); + videoPlayer.stop(); } } }; + const selected = currentIndex === index; + const previous = currentIndex > index; + const shouldRender = Math.abs(currentIndex - index) < 4; + const animatedStyles = useAnimatedGalleryStyle({ index, offsetScale, @@ -164,13 +182,13 @@ export const AnimatedGalleryVideo = React.memo( onLoadStart={onLoadStart} onPlaybackStatusUpdate={onPlayBackStatusUpdate} onProgress={onProgress} - paused={paused} - repeat={repeat} + paused={!isPlaying} + repeat={true} resizeMode='contain' style={style} testID='video-player' - uri={source.uri} - videoRef={videoRef} + uri={photo.uri} + videoRef={videoRef as RefObject} /> ) : null} { if ( - prevProps.paused === nextProps.paused && - prevProps.repeat === nextProps.repeat && - prevProps.shouldRender === nextProps.shouldRender && - prevProps.source.uri === nextProps.source.uri && prevProps.screenHeight === nextProps.screenHeight && - prevProps.selected === nextProps.selected && - prevProps.previous === nextProps.previous && - prevProps.index === nextProps.index + prevProps.index === nextProps.index && + prevProps.photo === nextProps.photo ) { return true; } diff --git a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx index a9592771d1..f3ac4e53e8 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx @@ -16,19 +16,16 @@ import Animated, { import { ImageGalleryVideoControl } from './ImageGalleryVideoControl'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Grid as GridIconDefault, Share as ShareIconDefault } from '../../../icons'; -import { - isFileSystemAvailable, - isShareImageAvailable, - NativeHandlers, - VideoType, -} from '../../../native'; +import { isFileSystemAvailable, isShareImageAvailable, NativeHandlers } from '../../../native'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; import { FileTypes } from '../../../types/types'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -import type { Photo } from '../ImageGallery'; const ReanimatedSafeAreaView = Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) @@ -36,29 +33,20 @@ const ReanimatedSafeAreaView = Animated.createAnimatedComponent export type ImageGalleryFooterCustomComponent = ({ openGridView, - photo, share, shareMenuOpen, }: { openGridView: () => void; share: () => Promise; shareMenuOpen: boolean; - photo?: Photo; }) => React.ReactElement | null; export type ImageGalleryFooterVideoControlProps = { - duration: number; - onPlayPause: (status?: boolean) => void; - paused: boolean; - progress: number; - videoRef: React.RefObject; + attachmentId: string; }; export type ImageGalleryFooterVideoControlComponent = ({ - duration, - onPlayPause, - paused, - progress, + attachmentId, }: ImageGalleryFooterVideoControlProps) => React.ReactElement | null; export type ImageGalleryFooterCustomComponentProps = { @@ -72,38 +60,27 @@ export type ImageGalleryFooterCustomComponentProps = { type ImageGalleryFooterPropsWithContext = ImageGalleryFooterCustomComponentProps & { accessibilityLabel: string; - duration: number; - onPlayPause: () => void; opacity: SharedValue; openGridView: () => void; - paused: boolean; - photo: Photo; - photoLength: number; - progress: number; - selectedIndex: number; - videoRef: React.RefObject; visible: SharedValue; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + asset: state.assets[state.currentIndex], + currentIndex: state.currentIndex, +}); + export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWithContext) => { const { accessibilityLabel, centerElement, - duration, GridIcon, leftElement, - onPlayPause, opacity, openGridView, - paused, - photo, - photoLength, - progress, rightElement, - selectedIndex, ShareIcon, videoControlElement, - videoRef, visible, } = props; @@ -119,6 +96,8 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith }, } = useTheme(); const { t } = useTranslationContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { asset, currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const footerStyle = useAnimatedStyle( () => ({ @@ -141,26 +120,26 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith if (!NativeHandlers.shareImage || !NativeHandlers.deleteFile) { return; } - const extension = photo.mime_type?.split('/')[1] || 'jpg'; - const shouldDownload = photo.uri && photo.uri.includes('http'); + const extension = asset.mime_type?.split('/')[1] || 'jpg'; + const shouldDownload = asset.uri && asset.uri.includes('http'); let localFile; // If the file is already uploaded to a CDN, create a local reference to // it first; otherwise just use the local file if (shouldDownload) { setSavingInProgress(true); localFile = await NativeHandlers.saveFile({ - fileName: `${photo.user?.id || 'ChatPhoto'}-${ - photo.messageId - }-${selectedIndex}.${extension}`, - fromUrl: photo.uri, + fileName: `${asset.user?.id || 'ChatPhoto'}-${ + asset.messageId + }-${currentIndex}.${extension}`, + fromUrl: asset.uri, }); setSavingInProgress(false); } else { - localFile = photo.uri; + localFile = asset.uri; } // `image/jpeg` is added for the case where the mime_type isn't available for a file/image - await NativeHandlers.shareImage({ type: photo.mime_type || 'image/jpeg', url: localFile }); + await NativeHandlers.shareImage({ type: asset.mime_type || 'image/jpeg', url: localFile }); // Only delete the file if a local reference has been created beforehand if (shouldDownload) { await NativeHandlers.deleteFile({ uri: localFile }); @@ -172,6 +151,10 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith shareIsInProgressRef.current = false; }; + if (!asset) { + return null; + } + return ( - {photo.type === FileTypes.Video ? ( + {asset.type === FileTypes.Video ? ( videoControlElement ? ( - videoControlElement({ duration, onPlayPause, paused, progress, videoRef }) + videoControlElement({ attachmentId: asset.id }) ) : ( - + ) ) : null} {leftElement ? ( - leftElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + leftElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( )} {centerElement ? ( - centerElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + centerElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( {t('{{ index }} of {{ photoLength }}', { - index: selectedIndex + 1, - photoLength, + index: currentIndex + 1, + photoLength: imageGalleryStateStore.assets.length, })} )} {rightElement ? ( - rightElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + rightElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( @@ -265,49 +242,8 @@ const ShareButton = ({ share, ShareIcon, savingInProgress }: ShareButtonProps) = ); }; -const areEqual = ( - prevProps: ImageGalleryFooterPropsWithContext, - nextProps: ImageGalleryFooterPropsWithContext, -) => { - const { - duration: prevDuration, - paused: prevPaused, - progress: prevProgress, - selectedIndex: prevSelectedIndex, - } = prevProps; - const { - duration: nextDuration, - paused: nextPaused, - progress: nextProgress, - selectedIndex: nextSelectedIndex, - } = nextProps; - - const isDurationEqual = prevDuration === nextDuration; - if (!isDurationEqual) { - return false; - } - - const isPausedEqual = prevPaused === nextPaused; - if (!isPausedEqual) { - return false; - } - - const isProgressEqual = prevProgress === nextProgress; - if (!isProgressEqual) { - return false; - } - - const isSelectedIndexEqual = prevSelectedIndex === nextSelectedIndex; - if (!isSelectedIndexEqual) { - return false; - } - - return true; -}; - const MemoizedImageGalleryFooter = React.memo( ImageGalleryFooterWithContext, - areEqual, ) as typeof ImageGalleryFooterWithContext; export type ImageGalleryFooterProps = ImageGalleryFooterPropsWithContext; diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index d009e93ef0..e49549009b 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -9,14 +9,16 @@ import Animated, { useAnimatedStyle, } from 'react-native-reanimated'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Close } from '../../../icons'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; import { getDateString } from '../../../utils/i18n/getDateString'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -import type { Photo } from '../ImageGallery'; const ReanimatedSafeAreaView = Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) @@ -24,10 +26,8 @@ const ReanimatedSafeAreaView = Animated.createAnimatedComponent export type ImageGalleryHeaderCustomComponent = ({ hideOverlay, - photo, }: { hideOverlay: () => void; - photo?: Photo; }) => React.ReactElement | null; export type ImageGalleryHeaderCustomComponentProps = { @@ -40,12 +40,14 @@ export type ImageGalleryHeaderCustomComponentProps = { type Props = ImageGalleryHeaderCustomComponentProps & { opacity: SharedValue; visible: SharedValue; - photo?: Photo; - /* Lookup key in the language corresponding translations sheet to perform date formatting */ }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + asset: state.assets[state.currentIndex], +}); + export const ImageGalleryHeader = (props: Props) => { - const { centerElement, CloseIcon, leftElement, opacity, photo, rightElement, visible } = props; + const { centerElement, CloseIcon, leftElement, opacity, rightElement, visible } = props; const [height, setHeight] = useState(200); const { theme: { @@ -64,17 +66,19 @@ export const ImageGalleryHeader = (props: Props) => { }, } = useTheme(); const { t, tDateTimeParser } = useTranslationContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { asset } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { setOverlay } = useOverlayContext(); const date = useMemo( () => getDateString({ - date: photo?.created_at, + date: asset?.created_at, t, tDateTimeParser, timestampTranslationKey: 'timestamp/ImageGalleryHeader', }), - [photo?.created_at, t, tDateTimeParser], + [asset?.created_at, t, tDateTimeParser], ); const headerStyle = useAnimatedStyle(() => ({ @@ -88,6 +92,7 @@ export const ImageGalleryHeader = (props: Props) => { const hideOverlay = () => { setOverlay('none'); + imageGalleryStateStore.clear(); }; return ( @@ -101,7 +106,7 @@ export const ImageGalleryHeader = (props: Props) => { > {leftElement ? ( - leftElement({ hideOverlay, photo }) + leftElement({ hideOverlay }) ) : ( @@ -110,17 +115,17 @@ export const ImageGalleryHeader = (props: Props) => { )} {centerElement ? ( - centerElement({ hideOverlay, photo }) + centerElement({ hideOverlay }) ) : ( - {photo?.user?.name || photo?.user?.id || t('Unknown User')} + {asset?.user?.name || asset?.user?.id || t('Unknown User')} {date && {date}} )} {rightElement ? ( - rightElement({ hideOverlay, photo }) + rightElement({ hideOverlay }) ) : ( )} diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx index a73ddf45a8..f2732c7c05 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx @@ -5,9 +5,12 @@ import type { ImageGalleryFooterVideoControlProps } from './ImageGalleryFooter'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Pause, Play } from '../../../icons'; +import { VideoPlayerState } from '../../../state-store/video-player'; import { getDurationLabelFromDuration } from '../../../utils/utils'; import { ProgressControl } from '../../ProgressControl/ProgressControl'; +import { useImageGalleryVideoPlayer } from '../hooks/useImageGalleryVideoPlayer'; const styles = StyleSheet.create({ durationTextStyle: { @@ -40,87 +43,74 @@ const styles = StyleSheet.create({ }, }); -export const ImageGalleryVideoControl = React.memo( - (props: ImageGalleryFooterVideoControlProps) => { - const { duration, onPlayPause, paused, progress, videoRef } = props; +const videoPlayerSelector = (state: VideoPlayerState) => ({ + duration: state.duration, + isPlaying: state.isPlaying, + progress: state.progress, +}); + +export const ImageGalleryVideoControl = React.memo((props: ImageGalleryFooterVideoControlProps) => { + const { attachmentId } = props; + + const videoPlayer = useImageGalleryVideoPlayer({ + id: attachmentId, + }); - const videoDuration = getDurationLabelFromDuration(duration); + const { duration, isPlaying, progress } = useStateStore(videoPlayer.state, videoPlayerSelector); - const progressValueInSeconds = progress * duration; + const videoDuration = getDurationLabelFromDuration(duration); - const progressDuration = getDurationLabelFromDuration(progressValueInSeconds); + const progressValueInSeconds = progress * duration; - const { - theme: { - colors: { accent_blue, black, static_black, static_white }, - imageGallery: { - videoControl: { durationTextStyle, progressDurationText, roundedView, videoContainer }, - }, + const progressDuration = getDurationLabelFromDuration(progressValueInSeconds); + + const { + theme: { + colors: { accent_blue, black, static_black, static_white }, + imageGallery: { + videoControl: { durationTextStyle, progressDurationText, roundedView, videoContainer }, }, - } = useTheme(); - - const handlePlayPause = async () => { - // Note: Not particularly sure why this was ever added, but - // will keep it for now for backwards compatibility. - if (progress === 1) { - // For expo CLI, expo-av - if (videoRef.current?.setPositionAsync) { - await videoRef.current.setPositionAsync(0); - } - // For expo CLI, expo-video - if (videoRef.current?.replay) { - await videoRef.current.replay(); - } - } - onPlayPause(); - }; - - return ( - - - - {paused ? ( - - ) : ( - - )} - - - - {progressDuration} - - - - + }, + } = useTheme(); + + const handlePlayPause = () => { + videoPlayer.toggle(); + }; - - {videoDuration} - + return ( + + + + {!isPlaying ? ( + + ) : ( + + )} + + + + {progressDuration} + + + - ); - }, - (prevProps, nextProps) => { - if ( - prevProps.duration === nextProps.duration && - prevProps.paused === nextProps.paused && - prevProps.progress === nextProps.progress - ) { - return true; - } else { - return false; - } - }, -); + + + {videoDuration} + + + ); +}); ImageGalleryVideoControl.displayName = 'ImageGalleryVideoControl{imageGallery{videoControl}}'; diff --git a/package/src/components/ImageGallery/components/ImageGrid.tsx b/package/src/components/ImageGallery/components/ImageGrid.tsx index 0cf5a70172..f3982f9715 100644 --- a/package/src/components/ImageGallery/components/ImageGrid.tsx +++ b/package/src/components/ImageGallery/components/ImageGrid.tsx @@ -2,14 +2,18 @@ import React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import { VideoThumbnail } from '../../../components/Attachment/VideoThumbnail'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { useViewport } from '../../../hooks/useViewport'; +import type { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; import { FileTypes } from '../../../types/types'; import { BottomSheetFlatList } from '../../BottomSheetCompatibility/BottomSheetFlatList'; import { BottomSheetTouchableOpacity } from '../../BottomSheetCompatibility/BottomSheetTouchableOpacity'; -import type { Photo } from '../ImageGallery'; - const styles = StyleSheet.create({ avatarImage: { borderRadius: 22, @@ -34,7 +38,7 @@ const styles = StyleSheet.create({ export type ImageGalleryGridImageComponent = ({ item, }: { - item: Photo & { + item: ImageGalleryAsset & { selectAndClose: () => void; numberOfImageGalleryGridColumns?: number; }; @@ -45,7 +49,7 @@ export type ImageGalleryGridImageComponents = { imageComponent?: ImageGalleryGridImageComponent; }; -export type GridImageItem = Photo & +export type GridImageItem = ImageGalleryAsset & ImageGalleryGridImageComponents & { selectAndClose: () => void; numberOfImageGalleryGridColumns?: number; @@ -87,28 +91,17 @@ const renderItem = ({ item }: { item: GridImageItem }) => void; - photos: Photo[]; - setSelectedMessage: React.Dispatch< - React.SetStateAction< - | { - messageId?: string | undefined; - url?: string | undefined; - } - | undefined - > - >; numberOfImageGalleryGridColumns?: number; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + assets: state.assets, +}); + export const ImageGrid = (props: ImageGridType) => { - const { - avatarComponent, - closeGridView, - imageComponent, - numberOfImageGalleryGridColumns, - photos, - setSelectedMessage, - } = props; + const { avatarComponent, closeGridView, imageComponent, numberOfImageGalleryGridColumns } = props; + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { theme: { @@ -119,13 +112,13 @@ export const ImageGrid = (props: ImageGridType) => { }, } = useTheme(); - const imageGridItems = photos.map((photo) => ({ + const imageGridItems = assets.map((photo, index) => ({ ...photo, avatarComponent, imageComponent, numberOfImageGalleryGridColumns, selectAndClose: () => { - setSelectedMessage({ messageId: photo.messageId, url: photo.uri }); + imageGalleryStateStore.currentIndex = index; closeGridView(); }, })); diff --git a/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx b/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx index 00b484f0a0..41bffa5fb1 100644 --- a/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx +++ b/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx @@ -1,12 +1,44 @@ -import React from 'react'; +import React, { PropsWithChildren, useState } from 'react'; import { SharedValue, useSharedValue } from 'react-native-reanimated'; import { render, renderHook, waitFor } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { LocalMessage } from 'stream-chat'; +import { + ImageGalleryContext, + ImageGalleryContextValue, +} from '../../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; +import { generateImageAttachment } from '../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { ImageGalleryStateStore } from '../../../../state-store/image-gallery-state-store'; import { ImageGalleryHeader } from '../ImageGalleryHeader'; +const ImageGalleryComponentWrapper = ({ children }: PropsWithChildren) => { + const initialImageGalleryStateStore = new ImageGalleryStateStore(); + const attachment = generateImageAttachment(); + initialImageGalleryStateStore.openImageGallery({ + message: generateMessage({ + attachments: [attachment], + user: {}, + }) as unknown as LocalMessage, + selectedAttachmentUrl: attachment.url, + }); + + const [imageGalleryStateStore] = useState(initialImageGalleryStateStore); + + return ( + }}> + + {children} + + + ); +}; + it('doesnt fail if fromNow is not available on first render', async () => { try { let sharedValueOpacity: SharedValue; @@ -16,18 +48,14 @@ it('doesnt fail if fromNow is not available on first render', async () => { sharedValueVisible = useSharedValue(1); }); const { getAllByText } = render( - + - , + , ); await waitFor(() => { expect(getAllByText('Unknown User')).toBeTruthy(); diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index c9b8a2e1b3..733b3be696 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Platform } from 'react-native'; import { Gesture, GestureType } from 'react-native-gesture-handler'; import { @@ -14,7 +14,9 @@ import { import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; +import { useStateStore } from '../../../hooks'; import { NativeHandlers } from '../../../native'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; export enum HasPinched { FALSE = 0, @@ -29,6 +31,10 @@ export enum IsSwiping { const MARGIN = 32; +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + export const useImageGalleryGestures = ({ currentImageHeight, halfScreenHeight, @@ -36,12 +42,9 @@ export const useImageGalleryGestures = ({ headerFooterVisible, offsetScale, overlayOpacity, - photoLength, scale, screenHeight, screenWidth, - selectedIndex, - setSelectedIndex, translateX, translateY, translationX, @@ -52,12 +55,9 @@ export const useImageGalleryGestures = ({ headerFooterVisible: SharedValue; offsetScale: SharedValue; overlayOpacity: SharedValue; - photoLength: number; scale: SharedValue; screenHeight: number; screenWidth: number; - selectedIndex: number; - setSelectedIndex: React.Dispatch>; translateX: SharedValue; translateY: SharedValue; translationX: SharedValue; @@ -72,8 +72,11 @@ export const useImageGalleryGestures = ({ * it was always assumed that one started at index 0 in the * gallery. * */ - const [index, setIndex] = useState(selectedIndex); - const { setMessages, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); + + const [index, setIndex] = useState(currentIndex); + /** * Gesture handler refs */ @@ -100,20 +103,6 @@ export const useImageGalleryGestures = ({ const focalX = useSharedValue(0); const focalY = useSharedValue(0); - /** - * if a specific image index > 0 has been passed in - * while creating the hook, set the value of the index - * reference to its value. - * - * This makes it possible to seelct an image in the list, - * and scroll/pan as normal. Prior to this, - * it was always assumed that one started at index 0 in the - * gallery. - * */ - useEffect(() => { - setIndex(selectedIndex); - }, [selectedIndex]); - /** * Shared values for movement */ @@ -167,6 +156,23 @@ export const useImageGalleryGestures = ({ offsetScale.value = 1; }; + const assetsLength = imageGalleryStateStore.assets.length; + + const clearImageGallery = () => { + runOnJS(imageGalleryStateStore.clear)(); + runOnJS(setOverlay)('none'); + }; + + const moveToNextImage = () => { + runOnJS(setIndex)(index + 1); + imageGalleryStateStore.currentIndex = index + 1; + }; + + const moveToPreviousImage = () => { + runOnJS(setIndex)(index - 1); + imageGalleryStateStore.currentIndex = index - 1; + }; + /** * We use simultaneousHandlers to allow pan and pinch gesture handlers * depending on the gesture. The touch is fully handled by the pinch @@ -290,7 +296,7 @@ export const useImageGalleryGestures = ({ * As we move towards the left to move to next image, the translationX value will be negative on X axis. */ if ( - index < photoLength - 1 && + index < assetsLength - 1 && Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value < 0 && finalXPosition > halfScreenWidth && @@ -305,8 +311,7 @@ export const useImageGalleryGestures = ({ }, () => { resetMovementValues(); - runOnJS(setIndex)(index + 1); - runOnJS(setSelectedIndex)(index + 1); + runOnJS(moveToNextImage)(); }, ); @@ -333,8 +338,7 @@ export const useImageGalleryGestures = ({ }, () => { resetMovementValues(); - runOnJS(setIndex)(index - 1); - runOnJS(setSelectedIndex)(index - 1); + runOnJS(moveToPreviousImage)(); }, ); } @@ -433,9 +437,7 @@ export const useImageGalleryGestures = ({ easing: Easing.out(Easing.ease), }, () => { - runOnJS(setSelectedMessage)(undefined); - runOnJS(setMessages)([]); - runOnJS(setOverlay)('none'); + runOnJS(clearImageGallery)(); }, ); scale.value = withTiming(0.6, { diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts new file mode 100644 index 0000000000..1961497070 --- /dev/null +++ b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; + +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { VideoPlayerOptions } from '../../../state-store/video-player'; + +export type UseImageGalleryVideoPlayerProps = VideoPlayerOptions; + +/** + * Hook to get the video player instance. + * @param options - The options for the video player. + * @returns The video player instance. + */ +export const useImageGalleryVideoPlayer = (options: UseImageGalleryVideoPlayerProps) => { + const { autoPlayVideo, imageGalleryStateStore } = useImageGalleryContext(); + const videoPlayer = useMemo(() => { + return imageGalleryStateStore.videoPlayerPool.getOrAddPlayer({ + ...options, + autoPlay: autoPlayVideo, + }); + }, [autoPlayVideo, imageGalleryStateStore, options]); + + return videoPlayer; +}; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 31f31185ca..7dedf1ff8b 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -26,10 +26,6 @@ import { useChannelContext, } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; -import { - ImageGalleryContextValue, - useImageGalleryContext, -} from '../../contexts/imageGalleryContext/ImageGalleryContext'; import { MessageListItemContextValue, MessageListItemProvider, @@ -50,7 +46,6 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext' import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; -import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; let FlashList; @@ -124,7 +119,6 @@ type MessageFlashListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & - Pick & Pick & Pick< MessagesContextValue, @@ -133,7 +127,6 @@ type MessageFlashListPropsWithContext = Pick< | 'FlatList' | 'InlineDateSeparator' | 'InlineUnreadIndicator' - | 'legacyImageViewerSwipeBehaviour' | 'Message' | 'ScrollToBottomButton' | 'MessageSystem' @@ -182,7 +175,6 @@ type MessageFlashListPropsWithContext = Pick< HeaderComponent?: React.ComponentType; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; - isListActive?: boolean; /** Turn off grouping of messages by user */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; @@ -275,9 +267,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => FooterComponent = LoadingMoreRecentIndicator, HeaderComponent = InlineLoadingMoreIndicator, hideStickyDateHeader, - isListActive = false, isLiveStreaming = false, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -298,7 +288,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => selectedPicker, setChannelUnreadState, setFlatListRef, - setMessages, setSelectedPicker, setTargetedMessage, StickyHeader, @@ -734,59 +723,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ], ); - const messagesWithImages = - legacyImageViewerSwipeBehaviour && - processedMessageList.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - if (!isMessageTypeDeleted && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = - legacyImageViewerSwipeBehaviour && - messagesWithImages && - messagesWithImages - .map((message) => - message.attachments - ?.map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - - const numberOfMessagesWithImages = - legacyImageViewerSwipeBehaviour && messagesWithImages && messagesWithImages.length; - const threadExists = !!thread; - - useEffect(() => { - if ( - legacyImageViewerSwipeBehaviour && - isListActive && - ((threadList && thread) || (!threadList && !thread)) - ) { - setMessages(messagesWithImages as LocalMessage[]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - imageString, - isListActive, - legacyImageViewerSwipeBehaviour, - numberOfMessagesWithImages, - threadExists, - threadList, - ]); - /** * We are keeping full control on message pagination, and not relying on react-native for it. * The reasons being, @@ -1150,14 +1086,12 @@ export const MessageFlashList = (props: MessageFlashListProps) => { threadList, } = useChannelContext(); const { client } = useChatContext(); - const { setMessages } = useImageGalleryContext(); const { DateHeader, disableTypingIndicator, FlatList, InlineDateSeparator, InlineUnreadIndicator, - legacyImageViewerSwipeBehaviour, Message, MessageSystem, myMessageTheme, @@ -1190,7 +1124,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { InlineDateSeparator, InlineUnreadIndicator, isListActive: isChannelActive, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1210,7 +1143,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { scrollToFirstUnreadThreshold, selectedPicker, setChannelUnreadState, - setMessages, setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 45618918f2..85db665c5c 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -28,10 +28,7 @@ import { } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; -import { - ImageGalleryContextValue, - useImageGalleryContext, -} from '../../contexts/imageGalleryContext/ImageGalleryContext'; + import { MessageListItemContextValue, MessageListItemProvider, @@ -52,7 +49,6 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext' import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; -import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; // This is just to make sure that the scrolling happens in a different task queue. @@ -146,14 +142,15 @@ type MessageListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & - Pick & Pick & Pick< MessagesContextValue, | 'DateHeader' | 'disableTypingIndicator' | 'FlatList' - | 'legacyImageViewerSwipeBehaviour' + | 'InlineDateSeparator' + | 'InlineUnreadIndicator' + | 'Message' | 'ScrollToBottomButton' | 'myMessageTheme' | 'TypingIndicator' @@ -199,7 +196,6 @@ type MessageListPropsWithContext = Pick< HeaderComponent?: React.ComponentType; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; - isListActive?: boolean; /** Turn off grouping of messages by user */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; @@ -264,9 +260,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, inverted = true, - isListActive = false, isLiveStreaming = false, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -287,7 +281,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { selectedPicker, setChannelUnreadState, setFlatListRef, - setMessages, setSelectedPicker, setTargetedMessage, StickyHeader, @@ -997,59 +990,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, ); - const messagesWithImages = - legacyImageViewerSwipeBehaviour && - processedMessageList.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - if (!isMessageTypeDeleted && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = - legacyImageViewerSwipeBehaviour && - messagesWithImages && - messagesWithImages - .map((message) => - message.attachments - ?.map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - - const numberOfMessagesWithImages = - legacyImageViewerSwipeBehaviour && messagesWithImages && messagesWithImages.length; - const threadExists = !!thread; - - useEffect(() => { - if ( - legacyImageViewerSwipeBehaviour && - isListActive && - ((threadList && thread) || (!threadList && !thread)) - ) { - setMessages(messagesWithImages as LocalMessage[]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - imageString, - isListActive, - legacyImageViewerSwipeBehaviour, - numberOfMessagesWithImages, - threadExists, - threadList, - ]); - const dismissImagePicker = useStableCallback(() => { if (selectedPicker) { setSelectedPicker(undefined); @@ -1219,7 +1159,6 @@ export const MessageList = (props: MessageListProps) => { error, hideStickyDateHeader, highlightedMessageId, - isChannelActive, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1235,7 +1174,6 @@ export const MessageList = (props: MessageListProps) => { threadList, } = useChannelContext(); const { client } = useChatContext(); - const { setMessages } = useImageGalleryContext(); const { readEvents } = useOwnCapabilitiesContext(); const { DateHeader, @@ -1243,7 +1181,6 @@ export const MessageList = (props: MessageListProps) => { FlatList, InlineDateSeparator, InlineUnreadIndicator, - legacyImageViewerSwipeBehaviour, Message, MessageSystem, myMessageTheme, @@ -1274,8 +1211,6 @@ export const MessageList = (props: MessageListProps) => { highlightedMessageId, InlineDateSeparator, InlineUnreadIndicator, - isListActive: isChannelActive, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1295,7 +1230,6 @@ export const MessageList = (props: MessageListProps) => { scrollToFirstUnreadThreshold, selectedPicker, setChannelUnreadState, - setMessages, setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 6c0d40a33d..8ac29280db 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -81,6 +81,8 @@ export * from './ImageGallery/components/ImageGalleryHeader'; export * from './ImageGallery/components/ImageGalleryOverlay'; export * from './ImageGallery/components/ImageGrid'; export * from './ImageGallery/components/ImageGridHandle'; +export * from './ImageGallery/components/ImageGalleryVideoControl'; +export * from './ImageGallery/hooks/useImageGalleryVideoPlayer'; export * from './Indicators/EmptyStateIndicator'; export * from './Indicators/LoadingDot'; diff --git a/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx b/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx index 6643114a42..66768cc554 100644 --- a/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx +++ b/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx @@ -1,42 +1,57 @@ -import React, { PropsWithChildren, useContext, useState } from 'react'; +import React, { PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'; -import { LocalMessage } from 'stream-chat'; +import { Attachment } from 'stream-chat'; -import type { UnknownType } from '../../types/types'; +import { ImageGalleryCustomComponents } from '../../components/ImageGallery/ImageGallery'; +import { ImageGalleryStateStore } from '../../state-store/image-gallery-state-store'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; -type SelectedMessage = { - messageId?: string; - url?: string; +export type ImageGalleryProviderProps = ImageGalleryCustomComponents & { + autoPlayVideo?: boolean; + /** + * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default + * */ + giphyVersion?: keyof NonNullable; + imageGalleryGridHandleHeight?: number; + imageGalleryGridSnapPoints?: [string | number, string | number]; + numberOfImageGalleryGridColumns?: number; }; -export type ImageGalleryContextValue = { - messages: LocalMessage[]; - setMessages: React.Dispatch>; - setSelectedMessage: React.Dispatch>; - selectedMessage?: SelectedMessage; +export type ImageGalleryContextValue = ImageGalleryProviderProps & { + imageGalleryStateStore: ImageGalleryStateStore; }; export const ImageGalleryContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as ImageGalleryContextValue, ); -export const ImageGalleryProvider = ({ children }: PropsWithChildren) => { - const [messages, setMessages] = useState([]); - const [selectedMessage, setSelectedMessage] = useState(); +export const ImageGalleryProvider = ({ + children, + value, +}: PropsWithChildren<{ value: ImageGalleryProviderProps }>) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore(value)); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const imageGalleryContextValue = useMemo( + () => ({ + autoPlayVideo: value?.autoPlayVideo, + imageGalleryStateStore, + ...value, + }), + [imageGalleryStateStore, value], + ); return ( {children} diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index fdfa6a27d2..154c555032 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -444,7 +444,6 @@ export type MessagesContextValue = Pick boolean; - legacyImageViewerSwipeBehaviour?: boolean; /** Object specifying rules defined within simple-markdown https://github.com/Khan/simple-markdown#adding-a-simple-extension */ markdownRules?: MarkdownRules; /** diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index e094429cb8..0417048c94 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -1,10 +1,9 @@ import React, { useContext } from 'react'; -import type { Attachment } from 'stream-chat'; - -import type { ImageGalleryCustomComponents } from '../../components/ImageGallery/ImageGallery'; +import { SharedValue } from 'react-native-reanimated'; import type { Streami18n } from '../../utils/i18n/Streami18n'; +import { ImageGalleryProviderProps } from '../imageGalleryContext/ImageGalleryContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; import type { Theme } from '../themeContext/utils/theme'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -15,6 +14,7 @@ export type Overlay = 'alert' | 'gallery' | 'none'; export type OverlayContextValue = { overlay: Overlay; + overlayOpacity: SharedValue; setOverlay: React.Dispatch>; style?: DeepPartial; }; @@ -23,17 +23,9 @@ export const OverlayContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as OverlayContextValue, ); -export type OverlayProviderProps = ImageGalleryCustomComponents & { - autoPlayVideo?: boolean; - /** - * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default - * */ - giphyVersion?: keyof NonNullable; +export type OverlayProviderProps = ImageGalleryProviderProps & { /** https://github.com/GetStream/stream-chat-react-native/wiki/Internationalization-(i18n) */ i18nInstance?: Streami18n; - imageGalleryGridHandleHeight?: number; - imageGalleryGridSnapPoints?: [string | number, string | number]; - numberOfImageGalleryGridColumns?: number; value?: Partial; }; diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index e4397032fb..fc3bc408fd 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { BackHandler } from 'react-native'; @@ -7,7 +7,6 @@ import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanim import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; - import { useStreami18n } from '../../hooks/useStreami18n'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; @@ -39,15 +38,15 @@ import { */ export const OverlayProvider = (props: PropsWithChildren) => { const { - autoPlayVideo, children, - giphyVersion, i18nInstance, + value, + autoPlayVideo, + giphyVersion, imageGalleryCustomComponents, - imageGalleryGridHandleHeight = 40, + imageGalleryGridHandleHeight, imageGalleryGridSnapPoints, numberOfImageGalleryGridColumns, - value, } = props; const [overlay, setOverlay] = useState(value?.overlay || 'none'); @@ -84,27 +83,37 @@ export const OverlayProvider = (props: PropsWithChildren) const overlayContext = { overlay, + overlayOpacity, setOverlay, style: value?.style, }; + const imageGalleryProviderProps = useMemo( + () => ({ + autoPlayVideo, + giphyVersion, + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + }), + [ + autoPlayVideo, + giphyVersion, + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + ], + ); + return ( - + {children} - {overlay === 'gallery' && ( - - )} + {overlay === 'gallery' && } diff --git a/package/src/native.ts b/package/src/native.ts index cfcb627e5f..85d1faaafb 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -277,7 +277,8 @@ export type VideoType = { repeat?: boolean; replayAsync?: () => void; resizeMode?: string; - seek?: (progress: number) => void; + seek?: (seconds: number) => void; + seekBy?: (seconds: number) => void; setPositionAsync?: (position: number) => void; style?: StyleProp; play?: () => void; diff --git a/package/src/state-store/__tests__/image-gallery-state-store.test.ts b/package/src/state-store/__tests__/image-gallery-state-store.test.ts new file mode 100644 index 0000000000..e6b7c9fd9a --- /dev/null +++ b/package/src/state-store/__tests__/image-gallery-state-store.test.ts @@ -0,0 +1,918 @@ +import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; + +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../mock-builders/generator/attachment'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; +import { ImageGalleryStateStore } from '../image-gallery-state-store'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock dependencies +jest.mock('../video-player-pool', () => ({ + VideoPlayerPool: jest.fn().mockImplementation(() => ({ + clear: jest.fn(), + pool: new Map(), + state: { + getLatestValue: () => ({ activeVideoPlayer: null }), + }, + })), +})); + +jest.mock('../../native', () => ({ + isVideoPlayerAvailable: jest.fn(() => true), +})); + +const { isVideoPlayerAvailable } = jest.requireMock('../../native') as { + isVideoPlayerAvailable: jest.Mock; +}; + +const createGiphyAttachment = (overrides: Partial = {}): Attachment => ({ + giphy: { + fixed_height: { + height: 200, + url: 'https://giphy.com/test.gif', + width: 200, + }, + }, + thumb_url: 'https://giphy.com/thumb.gif', + type: 'giphy', + ...overrides, +}); + +describe('ImageGalleryStateStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + (isVideoPlayerAvailable as jest.Mock).mockReturnValue(true); + }); + + describe('constructor', () => { + it('should initialize with default options', () => { + const store = new ImageGalleryStateStore(); + + expect(store.options).toEqual({ + autoPlayVideo: false, + giphyVersion: 'fixed_height', + }); + }); + + it('should merge custom options with defaults', () => { + const store = new ImageGalleryStateStore({ + autoPlayVideo: true, + giphyVersion: 'original', + }); + + expect(store.options).toEqual({ + autoPlayVideo: true, + giphyVersion: 'original', + }); + }); + + it('should partially override options', () => { + const store = new ImageGalleryStateStore({ + autoPlayVideo: true, + }); + + expect(store.options).toEqual({ + autoPlayVideo: true, + giphyVersion: 'fixed_height', + }); + }); + + it('should initialize state with default values', () => { + const store = new ImageGalleryStateStore(); + const state = store.state.getLatestValue(); + + expect(state).toEqual({ + assets: [], + currentIndex: 0, + messages: [], + selectedAttachmentUrl: undefined, + }); + }); + + it('should create a VideoPlayerPool instance', () => { + const store = new ImageGalleryStateStore(); + + expect(VideoPlayerPool).toHaveBeenCalled(); + expect(store.videoPlayerPool).toBeDefined(); + }); + }); + + describe('messages getter and setter', () => { + it('should get messages from state', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 1 }), generateMessage({ id: 2 })]; + + store.messages = messages; + + expect(store.messages).toEqual(messages); + }); + + it('should update state when setting messages', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 1 })]; + + store.messages = messages; + + expect(store.state.getLatestValue().messages).toEqual(messages); + }); + + it('should return empty array when no messages are set', () => { + const store = new ImageGalleryStateStore(); + + expect(store.messages).toEqual([]); + }); + }); + + describe('selectedAttachmentUrl getter and setter', () => { + it('should get selectedAttachmentUrl from state', () => { + const store = new ImageGalleryStateStore(); + const url = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = url; + + expect(store.selectedAttachmentUrl).toBe(url); + }); + + it('should update state when setting selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const url = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = url; + + expect(store.state.getLatestValue().selectedAttachmentUrl).toBe(url); + }); + + it('should return undefined when no url is set', () => { + const store = new ImageGalleryStateStore(); + + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should allow setting undefined', () => { + const store = new ImageGalleryStateStore(); + store.selectedAttachmentUrl = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = undefined; + + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + }); + + describe('currentIndex setter', () => { + it('should update currentIndex in state', () => { + const store = new ImageGalleryStateStore(); + + store.currentIndex = 5; + + expect(store.state.getLatestValue().currentIndex).toBe(5); + }); + + it('should allow setting to 0', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 5; + + store.currentIndex = 0; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + }); + }); + + describe('attachmentsWithMessage getter', () => { + it('should return empty array when no messages', () => { + const store = new ImageGalleryStateStore(); + + expect(store.attachmentsWithMessage).toEqual([]); + }); + + it('should filter messages with viewable image attachments', () => { + const store = new ImageGalleryStateStore(); + const imageAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + }); + const message = generateMessage({ attachments: [imageAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(imageAttachment); + }); + + it('should filter messages with viewable video attachments', () => { + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(videoAttachment); + }); + + it('should filter messages with giphy attachments', () => { + const store = new ImageGalleryStateStore(); + const giphyAttachment = createGiphyAttachment(); + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(giphyAttachment); + }); + + it('should exclude video attachments when video player is not available', () => { + (isVideoPlayerAvailable as jest.Mock).mockReturnValue(false); + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should exclude image attachments with title_link (link previews)', () => { + const store = new ImageGalleryStateStore(); + const linkPreviewAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + title_link: 'https://example.com', + }); + const message = generateMessage({ attachments: [linkPreviewAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should exclude image attachments with og_scrape_url (OpenGraph previews)', () => { + const store = new ImageGalleryStateStore(); + const linkAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + og_scrape_url: 'https://example.com', + }); + const message = generateMessage({ attachments: [linkAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle messages with mixed viewable and non-viewable attachments', () => { + const store = new ImageGalleryStateStore(); + const viewableImage = generateImageAttachment({ image_url: 'https://example.com/image.jpg' }); + const linkPreview = generateImageAttachment({ + image_url: 'https://example.com/preview.jpg', + title_link: 'https://example.com', + }); + const message = generateMessage({ attachments: [viewableImage, linkPreview], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + }); + + it('should exclude messages with only non-viewable attachments', () => { + const store = new ImageGalleryStateStore(); + const fileAttachment: Attachment = { + asset_url: 'https://example.com/file.pdf', + type: 'file', + }; + const message = generateMessage({ attachments: [fileAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle null attachments gracefully', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: [null as unknown as Attachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle messages without attachments array', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: undefined, id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + }); + + describe('getAssetId', () => { + it('should generate unique asset id from messageId and assetUrl', () => { + const store = new ImageGalleryStateStore(); + const assetId = store.getAssetId('message-123', 'https://example.com/image.jpg'); + + expect(assetId).toBe('photoId-message-123-https://example.com/image.jpg'); + }); + + it('should handle empty messageId', () => { + const store = new ImageGalleryStateStore(); + const assetId = store.getAssetId('', 'https://example.com/image.jpg'); + + expect(assetId).toBe('photoId--https://example.com/image.jpg'); + }); + }); + + describe('assets getter', () => { + it('should return empty array when no messages', () => { + const store = new ImageGalleryStateStore(); + + expect(store.assets).toEqual([]); + }); + + it('should transform image attachments to assets', () => { + const store = new ImageGalleryStateStore(); + const imageAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + original_height: 600, + original_width: 800, + thumb_url: 'https://example.com/thumb.jpg', + }); + const user: Partial = { id: 'user-1', name: 'Test User' }; + const message = generateMessage({ + attachments: [imageAttachment], + cid: 'channel-msg-1', + id: 'msg-1', + user, + user_id: user.id, + }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + channelId: 'channel-msg-1', + messageId: 'msg-1', + mime_type: undefined, + original_height: 600, + original_width: 800, + thumb_url: 'https://example.com/thumb.jpg', + type: 'image', + uri: 'https://example.com/image.jpg', + user_id: 'user-1', + }); + }); + + it('should transform video attachments to assets', () => { + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + thumb_url: 'https://example.com/video-thumb.jpg', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + mime_type: 'video/mp4', + type: 'video', + uri: 'https://example.com/video.mp4', + }); + }); + + it('should transform giphy attachments with correct mime type', () => { + const store = new ImageGalleryStateStore(); + const giphyAttachment = createGiphyAttachment(); + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + mime_type: 'image/gif', + type: 'giphy', + uri: 'https://giphy.com/test.gif', + }); + }); + + it('should generate unique asset ids for each attachment', () => { + const store = new ImageGalleryStateStore(); + const attachment1 = generateImageAttachment({ image_url: 'https://example.com/image1.jpg' }); + const attachment2 = generateImageAttachment({ image_url: 'https://example.com/image2.jpg' }); + const message = generateMessage({ attachments: [attachment1, attachment2], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(2); + expect(assets[0].id).not.toBe(assets[1].id); + }); + + it('should use custom giphyVersion from options', () => { + const store = new ImageGalleryStateStore({ giphyVersion: 'original' }); + const giphyAttachment: Attachment = { + giphy: { + fixed_height: { height: 200, url: 'https://giphy.com/fixed.gif', width: 200 }, + original: { height: 400, url: 'https://giphy.com/original.gif', width: 400 }, + }, + type: 'giphy', + }; + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + expect(getUrlOfImageAttachment(giphyAttachment, 'original')).toBe( + 'https://giphy.com/original.gif', + ); + }); + + it('should handle messages with multiple attachments', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ + attachments: [ + generateImageAttachment({ image_url: 'https://example.com/image1.jpg' }), + generateImageAttachment({ image_url: 'https://example.com/image2.jpg' }), + ], + id: 1, + }); + const message2 = generateMessage({ + attachments: [generateVideoAttachment({ asset_url: 'https://example.com/video.mp4' })], + id: 2, + }); + + store.messages = [message1, message2]; + + expect(store.assets).toHaveLength(3); + }); + }); + + describe('appendMessages', () => { + it('should append messages to existing messages', () => { + const store = new ImageGalleryStateStore(); + const initialMessages = [generateMessage({ id: 'msg-1' })]; + const newMessages = [generateMessage({ id: 'msg-2' }), generateMessage({ id: 'msg-3' })]; + + store.messages = initialMessages; + store.appendMessages(newMessages); + + expect(store.messages).toHaveLength(3); + expect(store.messages).toEqual([...initialMessages, ...newMessages]); + }); + + it('should work with empty initial messages', () => { + const store = new ImageGalleryStateStore(); + const newMessages = [generateMessage({ id: 'msg-1' })]; + + store.appendMessages(newMessages); + + expect(store.messages).toEqual(newMessages); + }); + + it('should work with empty new messages', () => { + const store = new ImageGalleryStateStore(); + const initialMessages = [generateMessage({ id: 'msg-1' })]; + + store.messages = initialMessages; + store.appendMessages([]); + + expect(store.messages).toEqual(initialMessages); + }); + }); + + describe('removeMessages', () => { + it('should remove specified messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const message3 = generateMessage({ id: 'msg-3' }); + + store.messages = [message1, message2, message3]; + store.removeMessages([message2]); + + expect(store.messages).toHaveLength(2); + expect(store.messages).toEqual([message1, message3]); + }); + + it('should remove multiple messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const message3 = generateMessage({ id: 'msg-3' }); + + store.messages = [message1, message2, message3]; + store.removeMessages([message1, message3]); + + expect(store.messages).toHaveLength(1); + expect(store.messages).toEqual([message2]); + }); + + it('should handle removing non-existent messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const nonExistentMessage = generateMessage({ id: 'non-existent' }); + + store.messages = [message1, message2]; + store.removeMessages([nonExistentMessage]); + + expect(store.messages).toHaveLength(2); + expect(store.messages).toEqual([message1, message2]); + }); + + it('should handle empty removal array', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + + store.messages = [message1]; + store.removeMessages([]); + + expect(store.messages).toEqual([message1]); + }); + }); + + describe('openImageGallery', () => { + it('should set messages and selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + const selectedUrl = 'https://example.com/1.jpg'; + + store.openImageGallery({ messages, selectedAttachmentUrl: selectedUrl }); + + expect(store.messages).toEqual(messages); + expect(store.selectedAttachmentUrl).toBe(selectedUrl); + }); + + it('should work without selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 'msg-1' })]; + + store.openImageGallery({ messages }); + + expect(store.messages).toEqual(messages); + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should replace existing messages', () => { + const store = new ImageGalleryStateStore(); + const oldMessages = [generateMessage({ id: 'msg-1' })]; + const newMessages = [generateMessage({ id: 'msg-2' })]; + + store.messages = oldMessages; + store.openImageGallery({ messages: newMessages }); + + expect(store.messages).toEqual(newMessages); + }); + }); + + describe('subscribeToMessages', () => { + it('should update assets when messages change', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + const message = generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }); + store.messages = [message]; + + expect(store.state.getLatestValue().assets).toHaveLength(1); + + unsubscribe(); + }); + + it('should return unsubscribe function', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + + it('should recalculate assets when messages are appended', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + expect(store.state.getLatestValue().assets).toHaveLength(1); + + store.appendMessages([ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/2.jpg' })], + id: 'msg-2', + }), + ]); + expect(store.state.getLatestValue().assets).toHaveLength(2); + + unsubscribe(); + }); + }); + + describe('subscribeToSelectedAttachmentUrl', () => { + it('should update currentIndex when selectedAttachmentUrl matches an asset', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/2.jpg' })], + id: 'msg-2', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/3.jpg' })], + id: 'msg-3', + }), + ]; + + store.messages = messages; + store.selectedAttachmentUrl = 'https://example.com/2.jpg'; + + expect(store.state.getLatestValue().currentIndex).toBe(1); + + unsubscribe(); + }); + + it('should set currentIndex to 0 when selectedAttachmentUrl is not found', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/non-existent.jpg'; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should not update currentIndex when selectedAttachmentUrl is undefined', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 5; + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.selectedAttachmentUrl = undefined; + + expect(store.state.getLatestValue().currentIndex).toBe(5); + + unsubscribe(); + }); + + it('should strip query params when matching URLs', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.messages = [ + generateMessage({ + attachments: [ + generateImageAttachment({ image_url: 'https://example.com/image.jpg?size=small' }), + ], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/image.jpg?size=large'; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should return unsubscribe function', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + }); + + describe('registerSubscriptions', () => { + it('should register both message and selectedAttachmentUrl subscriptions', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + // Test that message subscription is working + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + expect(store.state.getLatestValue().assets).toHaveLength(1); + + // Test that selectedAttachmentUrl subscription is working + store.selectedAttachmentUrl = 'https://example.com/1.jpg'; + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should return unsubscribe function that cleans up all subscriptions', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + + // Verify videoPlayerPool.clear was called + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + + it('should clear videoPlayerPool when unsubscribing', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + unsubscribe(); + + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should reset state to initial values', () => { + const store = new ImageGalleryStateStore(); + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/1.jpg'; + store.currentIndex = 5; + + store.clear(); + + const state = store.state.getLatestValue(); + expect(state.assets).toEqual([]); + expect(state.currentIndex).toBe(0); + expect(state.messages).toEqual([]); + expect(state.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should clear videoPlayerPool', () => { + const store = new ImageGalleryStateStore(); + + store.clear(); + + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle message with undefined user', () => { + const store = new ImageGalleryStateStore(); + const message = { + ...generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + user: undefined, + } as LocalMessage; + + store.messages = [message]; + + expect(store.assets).toHaveLength(1); + expect(store.assets[0].user).toBeUndefined(); + }); + + it('should handle multiple stores independently', () => { + const store1 = new ImageGalleryStateStore({ autoPlayVideo: true }); + const store2 = new ImageGalleryStateStore({ autoPlayVideo: false }); + + store1.messages = [generateMessage({ id: 'msg-1' })]; + store2.messages = [generateMessage({ id: 'msg-2' }), generateMessage({ id: 'msg-3' })]; + + expect(store1.messages).toHaveLength(1); + expect(store2.messages).toHaveLength(2); + expect(store1.options.autoPlayVideo).toBe(true); + expect(store2.options.autoPlayVideo).toBe(false); + }); + + it('should handle rapid state updates', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + + for (let i = 0; i < 100; i++) { + store.messages = [ + generateMessage({ + attachments: [ + generateImageAttachment({ image_url: `https://example.com/image-${i}.jpg` }), + ], + id: `msg-${i}`, + }), + ]; + } + + expect(store.state.getLatestValue().assets).toHaveLength(1); + expect(store.messages).toHaveLength(1); + }); + + it('should handle empty attachment arrays in messages', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: [], id: 'msg-1' }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + expect(store.assets).toEqual([]); + }); + + it('should maintain order of assets based on message order', () => { + const store = new ImageGalleryStateStore(); + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/first.jpg' })], + id: 'msg-1', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/second.jpg' })], + id: 'msg-2', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/third.jpg' })], + id: 'msg-3', + }), + ]; + + store.messages = messages; + + const assets = store.assets; + expect(assets[0].uri).toBe('https://example.com/first.jpg'); + expect(assets[1].uri).toBe('https://example.com/second.jpg'); + expect(assets[2].uri).toBe('https://example.com/third.jpg'); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when messages change', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ messages: state.messages }), + ({ messages }) => callback(messages), + ); + + const newMessages = [generateMessage({ id: 'msg-1' })]; + store.messages = newMessages; + + expect(callback).toHaveBeenCalledWith(newMessages); + }); + + it('should notify subscribers when selectedAttachmentUrl changes', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ selectedAttachmentUrl: state.selectedAttachmentUrl }), + ({ selectedAttachmentUrl }) => callback(selectedAttachmentUrl), + ); + + store.selectedAttachmentUrl = 'https://example.com/image.jpg'; + + expect(callback).toHaveBeenCalledWith('https://example.com/image.jpg'); + }); + + it('should notify subscribers when currentIndex changes', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ currentIndex: state.currentIndex }), + ({ currentIndex }) => callback(currentIndex), + ); + + store.currentIndex = 3; + + expect(callback).toHaveBeenCalledWith(3); + }); + }); +}); diff --git a/package/src/state-store/__tests__/video-player-pool.test.ts b/package/src/state-store/__tests__/video-player-pool.test.ts new file mode 100644 index 0000000000..4d5defe4c9 --- /dev/null +++ b/package/src/state-store/__tests__/video-player-pool.test.ts @@ -0,0 +1,445 @@ +import { VideoPlayer, VideoPlayerOptions } from '../video-player'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock the VideoPlayer class +jest.mock('../video-player', () => ({ + VideoPlayer: jest.fn().mockImplementation((options: VideoPlayerOptions) => ({ + id: options.id, + isPlaying: false, + onRemove: jest.fn(), + pause: jest.fn(), + play: jest.fn(), + })), +})); + +const createMockPlayer = (id: string, overrides: Partial = {}): VideoPlayer => + ({ + id, + isPlaying: false, + onRemove: jest.fn(), + pause: jest.fn(), + play: jest.fn(), + ...overrides, + }) as unknown as VideoPlayer; + +describe('VideoPlayerPool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with an empty pool', () => { + const pool = new VideoPlayerPool(); + + expect(pool.pool.size).toBe(0); + expect(pool.players).toEqual([]); + }); + + it('should initialize state with null activeVideoPlayer', () => { + const pool = new VideoPlayerPool(); + const state = pool.state.getLatestValue(); + + expect(state.activeVideoPlayer).toBeNull(); + }); + }); + + describe('players getter', () => { + it('should return an empty array when pool is empty', () => { + const pool = new VideoPlayerPool(); + + expect(pool.players).toEqual([]); + }); + + it('should return all players as an array', () => { + const pool = new VideoPlayerPool(); + + pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + pool.getOrAddPlayer({ id: 'player-3' }); + + expect(pool.players).toHaveLength(3); + }); + }); + + describe('getOrAddPlayer', () => { + it('should create a new player when id does not exist', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'new-player' }; + + const player = pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledWith(options); + expect(pool.pool.has('new-player')).toBe(true); + expect(player.id).toBe('new-player'); + }); + + it('should return existing player when id already exists', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'existing-player' }; + + const firstPlayer = pool.getOrAddPlayer(options); + const secondPlayer = pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledTimes(1); + expect(firstPlayer).toBe(secondPlayer); + }); + + it('should set pool reference on new player', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'player-with-pool' }; + + const player = pool.getOrAddPlayer(options); + + expect(player.pool).toBe(pool); + }); + + it('should pass autoPlay option to VideoPlayer', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { autoPlay: true, id: 'autoplay-player' }; + + pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledWith(options); + }); + }); + + describe('setActivePlayer', () => { + it('should set the active video player', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + + expect(pool.state.getLatestValue().activeVideoPlayer).toBe(mockPlayer); + }); + + it('should allow setting active player to null', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + pool.setActivePlayer(null); + + expect(pool.state.getLatestValue().activeVideoPlayer).toBeNull(); + }); + }); + + describe('getActivePlayer', () => { + it('should return null when no active player is set', () => { + const pool = new VideoPlayerPool(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should return the current active player', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + + expect(pool.getActivePlayer()).toBe(mockPlayer); + }); + }); + + describe('removePlayer', () => { + it('should do nothing when player does not exist', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.removePlayer('non-existent-player'); + + expect(pool.pool.size).toBe(0); + }); + + it('should remove player from pool', () => { + const pool = new VideoPlayerPool(); + pool.getOrAddPlayer({ id: 'player-to-remove' }); + + expect(pool.pool.has('player-to-remove')).toBe(true); + + pool.removePlayer('player-to-remove'); + + expect(pool.pool.has('player-to-remove')).toBe(false); + }); + + it('should call onRemove on the player being removed', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-to-remove' }); + + pool.removePlayer('player-to-remove'); + + expect(player.onRemove).toHaveBeenCalled(); + }); + + it('should set active player to null if removed player was active', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.removePlayer('active-player'); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should not affect active player if removed player was not active', () => { + const pool = new VideoPlayerPool(); + const activePlayer = pool.getOrAddPlayer({ id: 'active-player' }); + pool.getOrAddPlayer({ id: 'other-player' }); + + pool.setActivePlayer(activePlayer as unknown as VideoPlayer); + pool.removePlayer('other-player'); + + expect(pool.getActivePlayer()).toBe(activePlayer); + }); + }); + + describe('deregister', () => { + it('should do nothing when player does not exist', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.deregister('non-existent-player'); + + expect(pool.pool.size).toBe(0); + }); + + it('should remove player from pool without calling onRemove', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-to-deregister' }); + + pool.deregister('player-to-deregister'); + + expect(pool.pool.has('player-to-deregister')).toBe(false); + expect(player.onRemove).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should do nothing when pool is empty', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.clear(); + + expect(pool.pool.size).toBe(0); + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should remove all players from pool', () => { + const pool = new VideoPlayerPool(); + pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + pool.getOrAddPlayer({ id: 'player-3' }); + + expect(pool.pool.size).toBe(3); + + pool.clear(); + + expect(pool.pool.size).toBe(0); + }); + + it('should call onRemove on all players', () => { + const pool = new VideoPlayerPool(); + const player1 = pool.getOrAddPlayer({ id: 'player-1' }); + const player2 = pool.getOrAddPlayer({ id: 'player-2' }); + + pool.clear(); + + expect(player1.onRemove).toHaveBeenCalled(); + expect(player2.onRemove).toHaveBeenCalled(); + }); + + it('should set active player to null', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.clear(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + }); + + describe('requestPlay', () => { + it('should set active player when player exists in pool', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + + pool.requestPlay('player-1'); + + expect(pool.getActivePlayer()).toBe(player); + }); + + it('should not change active player when player does not exist', () => { + const pool = new VideoPlayerPool(); + const existingPlayer = pool.getOrAddPlayer({ id: 'existing-player' }); + pool.setActivePlayer(existingPlayer as unknown as VideoPlayer); + + pool.requestPlay('non-existent-player'); + + expect(pool.getActivePlayer()).toBe(existingPlayer); + }); + + it('should pause current active player if it is playing and different from requested', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('current-player', { isPlaying: true }); + pool.pool.set('current-player', currentPlayer); + pool.pool.set('new-player', createMockPlayer('new-player')); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('new-player'); + + expect(currentPlayer.pause).toHaveBeenCalled(); + }); + + it('should not pause current player if it is not playing', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('current-player', { isPlaying: false }); + pool.pool.set('current-player', currentPlayer); + pool.pool.set('new-player', createMockPlayer('new-player')); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('new-player'); + + expect(currentPlayer.pause).not.toHaveBeenCalled(); + }); + + it('should not pause current player if it is the same as requested', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('same-player', { isPlaying: true }); + pool.pool.set('same-player', currentPlayer); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('same-player'); + + expect(currentPlayer.pause).not.toHaveBeenCalled(); + }); + + it('should handle case when there is no current active player', () => { + const pool = new VideoPlayerPool(); + const newPlayer = pool.getOrAddPlayer({ id: 'new-player' }); + + expect(pool.getActivePlayer()).toBeNull(); + + pool.requestPlay('new-player'); + + expect(pool.getActivePlayer()).toBe(newPlayer); + }); + }); + + describe('notifyPaused', () => { + it('should set active player to null', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.notifyPaused(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should work even when there is no active player', () => { + const pool = new VideoPlayerPool(); + + expect(pool.getActivePlayer()).toBeNull(); + + // Should not throw + pool.notifyPaused(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when active player changes', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + const callback = jest.fn(); + + pool.state.subscribeWithSelector( + (state) => ({ + activeVideoPlayer: state.activeVideoPlayer, + }), + ({ activeVideoPlayer }) => callback(activeVideoPlayer), + ); + + pool.setActivePlayer(player as unknown as VideoPlayer); + + expect(callback).toHaveBeenCalledWith(player); + }); + + it('should notify subscribers when active player is cleared', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + pool.setActivePlayer(player as unknown as VideoPlayer); + + const callback = jest.fn(); + pool.state.subscribeWithSelector( + (state) => ({ + activeVideoPlayer: state.activeVideoPlayer, + }), + ({ activeVideoPlayer }) => callback(activeVideoPlayer), + ); + + pool.notifyPaused(); + + expect(callback).toHaveBeenCalledWith(null); + }); + }); + + describe('edge cases', () => { + it('should handle multiple sequential play requests', () => { + const pool = new VideoPlayerPool(); + const player1 = createMockPlayer('player-1', { isPlaying: true }); + const player2 = createMockPlayer('player-2', { isPlaying: false }); + const player3 = createMockPlayer('player-3', { isPlaying: false }); + + pool.pool.set('player-1', player1); + pool.pool.set('player-2', player2); + pool.pool.set('player-3', player3); + + pool.setActivePlayer(player1); + pool.requestPlay('player-2'); + // After switching, player2 is now active. Update mock to reflect playing state + (player2 as { isPlaying: boolean }).isPlaying = true; + pool.requestPlay('player-3'); + + expect(player1.pause).toHaveBeenCalled(); + expect(player2.pause).toHaveBeenCalled(); + expect(pool.getActivePlayer()).toBe(player3); + }); + + it('should handle removing all players one by one', () => { + const pool = new VideoPlayerPool(); + const player1 = pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + + pool.setActivePlayer(player1 as unknown as VideoPlayer); + + pool.removePlayer('player-1'); + expect(pool.getActivePlayer()).toBeNull(); + expect(pool.pool.size).toBe(1); + + pool.removePlayer('player-2'); + expect(pool.pool.size).toBe(0); + }); + + it('should handle adding player with same id after removal', () => { + const pool = new VideoPlayerPool(); + + const originalPlayer = pool.getOrAddPlayer({ id: 'reusable-id' }); + pool.removePlayer('reusable-id'); + + const newPlayer = pool.getOrAddPlayer({ id: 'reusable-id' }); + + expect(newPlayer).not.toBe(originalPlayer); + expect(pool.pool.has('reusable-id')).toBe(true); + }); + }); +}); diff --git a/package/src/state-store/__tests__/video-player.test.ts b/package/src/state-store/__tests__/video-player.test.ts new file mode 100644 index 0000000000..cc935e1bc3 --- /dev/null +++ b/package/src/state-store/__tests__/video-player.test.ts @@ -0,0 +1,677 @@ +import { NativeHandlers } from '../../native'; +import { ONE_SECOND_IN_MILLISECONDS } from '../../utils/constants'; +import { INITIAL_VIDEO_PLAYER_STATE, VideoPlayer, VideoPlayerOptions } from '../video-player'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock the native module +jest.mock('../../native', () => ({ + NativeHandlers: { + SDK: '', + }, +})); + +const createMockPlayerRef = () => ({ + pause: jest.fn(), + play: jest.fn(), + seek: jest.fn(), + seekBy: jest.fn(), +}); + +const createMockPool = (): jest.Mocked => + ({ + notifyPaused: jest.fn(), + requestPlay: jest.fn(), + }) as unknown as jest.Mocked; + +describe('VideoPlayer', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset SDK to default (non-Expo) for most tests + (NativeHandlers as { SDK: string }).SDK = ''; + }); + + describe('INITIAL_VIDEO_PLAYER_STATE', () => { + it('should have correct initial values', () => { + expect(INITIAL_VIDEO_PLAYER_STATE).toEqual({ + duration: 0, + isPlaying: false, + position: 0, + progress: 0, + }); + }); + }); + + describe('constructor', () => { + it('should initialize with default state when no autoPlay option', () => { + const options: VideoPlayerOptions = { id: 'test-player' }; + const player = new VideoPlayer(options); + + expect(player.id).toBe('test-player'); + expect(player.isPlaying).toBe(false); + expect(player.duration).toBe(0); + expect(player.position).toBe(0); + expect(player.progress).toBe(0); + }); + + it('should initialize with isPlaying=true when autoPlay is true', () => { + const options: VideoPlayerOptions = { autoPlay: true, id: 'autoplay-player' }; + const player = new VideoPlayer(options); + + expect(player.isPlaying).toBe(true); + }); + + it('should initialize with isPlaying=false when autoPlay is false', () => { + const options: VideoPlayerOptions = { autoPlay: false, id: 'no-autoplay-player' }; + const player = new VideoPlayer(options); + + expect(player.isPlaying).toBe(false); + }); + + it('should store options', () => { + const options: VideoPlayerOptions = { autoPlay: true, id: 'options-player' }; + const player = new VideoPlayer(options); + + expect(player.options).toBe(options); + }); + + it('should initialize playerRef as null', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.playerRef).toBeNull(); + }); + + it('should detect Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo'; + const player = new VideoPlayer({ id: 'expo-player' }); + + // isExpoCLI is private, but we can test its effect through seek behavior + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.seek(5000); + + // Expo uses seekBy instead of seek + expect(mockRef.seekBy).toHaveBeenCalled(); + expect(mockRef.seek).not.toHaveBeenCalled(); + }); + + it('should detect non-Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'rn-player' }); + + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.seek(5000); + + // Non-Expo uses seek instead of seekBy + expect(mockRef.seek).toHaveBeenCalled(); + expect(mockRef.seekBy).not.toHaveBeenCalled(); + }); + }); + + describe('initPlayer', () => { + it('should set playerRef when provided', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + + player.initPlayer({ playerRef: mockRef as never }); + + expect(player.playerRef).toBe(mockRef); + }); + + it('should set playerRef to null when not provided', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + + player.initPlayer({ playerRef: mockRef as never }); + player.initPlayer({}); + + expect(player.playerRef).toBeNull(); + }); + }); + + describe('id getter', () => { + it('should return the player id', () => { + const player = new VideoPlayer({ id: 'unique-id' }); + + expect(player.id).toBe('unique-id'); + }); + }); + + describe('isPlaying getter and setter', () => { + it('should get isPlaying from state', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + + expect(player.isPlaying).toBe(true); + }); + + it('should set isPlaying in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.isPlaying = true; + + expect(player.state.getLatestValue().isPlaying).toBe(true); + + player.isPlaying = false; + + expect(player.state.getLatestValue().isPlaying).toBe(false); + }); + }); + + describe('duration getter and setter', () => { + it('should get duration from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.duration).toBe(0); + }); + + it('should set duration in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.duration = 5000; + + expect(player.state.getLatestValue().duration).toBe(5000); + expect(player.duration).toBe(5000); + }); + }); + + describe('position getter and setter', () => { + it('should get position from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.position).toBe(0); + }); + + it('should set position and calculate progress in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.position = 5000; + + expect(player.state.getLatestValue().position).toBe(5000); + expect(player.state.getLatestValue().progress).toBe(0.5); + }); + + it('should handle position when duration is 0', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.position = 5000; + + expect(player.state.getLatestValue().position).toBe(5000); + expect(player.state.getLatestValue().progress).toBe(Infinity); + }); + }); + + describe('progress getter and setter', () => { + it('should get progress from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.progress).toBe(0); + }); + + it('should set progress and calculate position in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.progress = 0.75; + + expect(player.state.getLatestValue().progress).toBe(0.75); + expect(player.state.getLatestValue().position).toBe(7500); + }); + + it('should handle progress when duration is 0', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.progress = 0.5; + + expect(player.state.getLatestValue().progress).toBe(0.5); + expect(player.state.getLatestValue().position).toBe(0); + }); + }); + + describe('pool setter', () => { + it('should set pool reference', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + + player.pool = mockPool; + + // Test pool is set by checking play() behavior + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.play(); + + expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player'); + }); + }); + + describe('play', () => { + it('should call requestPlay on pool when pool is set', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + player.pool = mockPool; + + player.play(); + + expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player'); + }); + + it('should call play on playerRef when available', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.play(); + + expect(mockRef.play).toHaveBeenCalled(); + }); + + it('should set isPlaying to true in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.play(); + + expect(player.state.getLatestValue().isPlaying).toBe(true); + }); + + it('should not throw when no pool or playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.play()).not.toThrow(); + expect(player.isPlaying).toBe(true); + }); + + it('should not call playerRef.play when play method is undefined', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { pause: jest.fn() }; // No play method + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.play()).not.toThrow(); + expect(player.isPlaying).toBe(true); + }); + }); + + describe('pause', () => { + it('should call pause on playerRef when available', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.pause(); + + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should set isPlaying to false in state', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + + player.pause(); + + expect(player.state.getLatestValue().isPlaying).toBe(false); + }); + + it('should call notifyPaused on pool when pool is set', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + player.pool = mockPool; + + player.pause(); + + expect(mockPool.notifyPaused).toHaveBeenCalled(); + }); + + it('should not throw when no pool or playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.pause()).not.toThrow(); + expect(player.isPlaying).toBe(false); + }); + + it('should not call playerRef.pause when pause method is undefined', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { play: jest.fn() }; // No pause method + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.pause()).not.toThrow(); + }); + }); + + describe('toggle', () => { + it('should call pause when currently playing', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.toggle(); + + expect(mockRef.pause).toHaveBeenCalled(); + expect(player.isPlaying).toBe(false); + }); + + it('should call play when currently paused', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.toggle(); + + expect(mockRef.play).toHaveBeenCalled(); + expect(player.isPlaying).toBe(true); + }); + + it('should toggle state correctly multiple times', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.isPlaying).toBe(false); + + player.toggle(); + expect(player.isPlaying).toBe(true); + + player.toggle(); + expect(player.isPlaying).toBe(false); + + player.toggle(); + expect(player.isPlaying).toBe(true); + }); + }); + + describe('seek', () => { + it('should update position state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.seek(5000); + + expect(player.position).toBe(5000); + expect(player.progress).toBe(0.5); + }); + + it('should call seek on playerRef for non-Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(5000); + + expect(mockRef.seek).toHaveBeenCalledWith(5000 / ONE_SECOND_IN_MILLISECONDS); + expect(mockRef.seekBy).not.toHaveBeenCalled(); + }); + + it('should call seekBy on playerRef for Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(5000); + + expect(mockRef.seekBy).toHaveBeenCalledWith(5000 / ONE_SECOND_IN_MILLISECONDS); + expect(mockRef.seek).not.toHaveBeenCalled(); + }); + + it('should not throw when playerRef has no seek methods', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = {}; + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + expect(() => player.seek(5000)).not.toThrow(); + expect(player.position).toBe(5000); + }); + + it('should not throw when no playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + expect(() => player.seek(5000)).not.toThrow(); + expect(player.position).toBe(5000); + }); + + it('should convert milliseconds to seconds correctly', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(2500); + + expect(mockRef.seek).toHaveBeenCalledWith(2.5); + }); + }); + + describe('stop', () => { + it('should seek to 0 and pause', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.position = 5000; + + player.stop(); + + expect(player.position).toBe(0); + expect(player.isPlaying).toBe(false); + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should work without playerRef', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + + player.stop(); + + expect(player.position).toBe(0); + expect(player.isPlaying).toBe(false); + }); + }); + + describe('onRemove', () => { + it('should pause playerRef if available', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should set playerRef to null', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + expect(player.playerRef).toBeNull(); + }); + + it('should reset state to initial values', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + + player.onRemove(); + + expect(player.state.getLatestValue()).toEqual(INITIAL_VIDEO_PLAYER_STATE); + }); + + it('should not throw when no playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.onRemove()).not.toThrow(); + }); + + it('should not throw when playerRef has no pause method', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { play: jest.fn() }; + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.onRemove()).not.toThrow(); + expect(player.playerRef).toBeNull(); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when isPlaying changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + + player.state.subscribeWithSelector( + (state) => ({ isPlaying: state.isPlaying }), + ({ isPlaying }) => callback(isPlaying), + ); + + player.play(); + + expect(callback).toHaveBeenCalledWith(true); + }); + + it('should notify subscribers when duration changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + + player.state.subscribeWithSelector( + (state) => ({ duration: state.duration }), + ({ duration }) => callback(duration), + ); + + player.duration = 5000; + + expect(callback).toHaveBeenCalledWith(5000); + }); + + it('should notify subscribers when position changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + player.duration = 10000; + + player.state.subscribeWithSelector( + (state) => ({ position: state.position }), + ({ position }) => callback(position), + ); + + player.position = 2500; + + expect(callback).toHaveBeenCalledWith(2500); + }); + + it('should notify subscribers when progress changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + player.duration = 10000; + + player.state.subscribeWithSelector( + (state) => ({ progress: state.progress }), + ({ progress }) => callback(progress), + ); + + player.progress = 0.5; + + expect(callback).toHaveBeenCalledWith(0.5); + }); + }); + + describe('edge cases', () => { + it('should handle play/pause cycle correctly', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.play(); + expect(player.isPlaying).toBe(true); + expect(mockRef.play).toHaveBeenCalledTimes(1); + + player.pause(); + expect(player.isPlaying).toBe(false); + expect(mockRef.pause).toHaveBeenCalledTimes(1); + + player.play(); + expect(player.isPlaying).toBe(true); + expect(mockRef.play).toHaveBeenCalledTimes(2); + }); + + it('should handle seek during playback', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(7500); + + expect(player.position).toBe(7500); + expect(player.progress).toBe(0.75); + expect(player.isPlaying).toBe(true); // Should remain playing + }); + + it('should handle zero duration gracefully', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.position = 1000; + expect(player.progress).toBe(Infinity); + + player.progress = 0.5; + expect(player.position).toBe(0); // 0.5 * 0 = 0 + }); + + it('should handle negative position values', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.position = -1000; + + expect(player.position).toBe(-1000); + expect(player.progress).toBe(-0.1); + }); + + it('should handle progress values greater than 1', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.progress = 1.5; + + expect(player.progress).toBe(1.5); + expect(player.position).toBe(15000); + }); + + it('should handle multiple state updates in sequence', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.duration = 10000; + player.position = 2500; + player.isPlaying = true; + player.progress = 0.75; + + const state = player.state.getLatestValue(); + expect(state.duration).toBe(10000); + expect(state.progress).toBe(0.75); + expect(state.position).toBe(7500); // progress setter updates position + expect(state.isPlaying).toBe(true); + }); + + it('should maintain state consistency after onRemove', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + const state = player.state.getLatestValue(); + expect(state).toEqual(INITIAL_VIDEO_PLAYER_STATE); + expect(player.playerRef).toBeNull(); + + // Should be able to reinitialize + const newMockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: newMockRef as never }); + expect(player.playerRef).toBe(newMockRef); + }); + }); +}); diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts new file mode 100644 index 0000000000..1ba675d4f4 --- /dev/null +++ b/package/src/state-store/image-gallery-state-store.ts @@ -0,0 +1,225 @@ +import { Attachment, LocalMessage, StateStore, Unsubscribe, UserResponse } from 'stream-chat'; + +import { VideoPlayerPool } from './video-player-pool'; + +import { getGiphyMimeType } from '../components/Attachment/utils/getGiphyMimeType'; +import { isVideoPlayerAvailable } from '../native'; +import { FileTypes } from '../types/types'; +import { getUrlOfImageAttachment } from '../utils/getUrlOfImageAttachment'; + +export type ImageGalleryAsset = { + id: string; + uri: string; + channelId?: string; + created_at?: string | Date; + messageId?: string; + mime_type?: string; + original_height?: number; + original_width?: number; + thumb_url?: string; + type?: string; + user?: UserResponse | null; + user_id?: string; +}; + +const isViewableImageAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Image && !attachment.title_link && !attachment.og_scrape_url; +}; + +const isViewableVideoAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Video && isVideoPlayerAvailable(); +}; + +const isViewableGiphyAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Giphy; +}; + +const stripQueryFromUrl = (url: string) => url.split('?')[0]; + +export type ImageGalleryState = { + assets: ImageGalleryAsset[]; + messages: LocalMessage[]; + selectedAttachmentUrl?: string; + currentIndex: number; +}; + +const INITIAL_STATE: ImageGalleryState = { + assets: [], + currentIndex: 0, + messages: [], + selectedAttachmentUrl: undefined, +}; + +export type ImageGalleryOptions = { + autoPlayVideo?: boolean; + giphyVersion?: keyof NonNullable; +}; + +const INITIAL_IMAGE_GALLERY_OPTIONS: ImageGalleryOptions = { + autoPlayVideo: false, + giphyVersion: 'fixed_height', +}; + +export class ImageGalleryStateStore { + state: StateStore; + options: ImageGalleryOptions; + videoPlayerPool: VideoPlayerPool; + + constructor(options: Partial = {}) { + this.options = { ...INITIAL_IMAGE_GALLERY_OPTIONS, ...options }; + this.state = new StateStore(INITIAL_STATE); + this.videoPlayerPool = new VideoPlayerPool(); + } + + // Getters + get messages() { + return this.state.getLatestValue().messages; + } + + get selectedAttachmentUrl() { + return this.state.getLatestValue().selectedAttachmentUrl; + } + + get attachmentsWithMessage() { + const messages = this.messages; + + const attachmentsWithMessage = messages + .map((message) => ({ + attachments: message.attachments ?? [], + message, + })) + .filter(({ attachments }) => + attachments.some((attachment) => { + if (!attachment) { + return false; + } + return ( + isViewableImageAttachment(attachment) || + isViewableVideoAttachment(attachment) || + isViewableGiphyAttachment(attachment) + ); + }), + ); + + return attachmentsWithMessage; + } + + getAssetId(messageId: string, assetUrl: string) { + return `photoId-${messageId}-${assetUrl}`; + } + + get assets() { + const attachmentsWithMessage = this.attachmentsWithMessage; + const { giphyVersion = 'fixed_height' } = this.options; + + return attachmentsWithMessage.flatMap(({ message, attachments }) => { + return attachments.map((attachment) => { + const assetUrl = getUrlOfImageAttachment(attachment, giphyVersion) as string; + const assetId = this.getAssetId(message?.id ?? '', assetUrl); + const giphyURL = + attachment.giphy?.[giphyVersion]?.url || attachment.thumb_url || attachment.image_url; + const giphyMimeType = getGiphyMimeType(giphyURL ?? ''); + + return { + channelId: message?.cid, + created_at: message?.created_at, + id: assetId, + messageId: message?.id, + mime_type: attachment.type === 'giphy' ? giphyMimeType : attachment.mime_type, + original_height: attachment.original_height, + original_width: attachment.original_width, + thumb_url: attachment.thumb_url, + type: attachment.type, + uri: assetUrl, + user: message?.user, + user_id: message?.user_id, + }; + }); + }); + } + + // Setters + set messages(messages: LocalMessage[]) { + this.state.partialNext({ messages }); + } + + set selectedAttachmentUrl(selectedAttachmentUrl: string | undefined) { + this.state.partialNext({ selectedAttachmentUrl }); + } + + set currentIndex(currentIndex: number) { + this.state.partialNext({ currentIndex }); + } + + // APIs for managing messages + appendMessages = (messages: LocalMessage[]) => { + this.state.partialNext({ messages: [...this.messages, ...messages] }); + }; + + removeMessages = (messages: LocalMessage[]) => { + this.state.partialNext({ + messages: this.messages.filter((message) => !messages.includes(message)), + }); + }; + + openImageGallery = ({ + messages, + selectedAttachmentUrl, + }: { + messages: LocalMessage[]; + selectedAttachmentUrl?: string; + }) => { + this.state.partialNext({ messages, selectedAttachmentUrl }); + }; + + subscribeToMessages = () => { + const unsubscribe = this.state.subscribeWithSelector( + (currentValue) => ({ + messages: currentValue.messages, + }), + () => { + const assets = this.assets; + this.state.partialNext({ assets }); + }, + ); + + return unsubscribe; + }; + + subscribeToSelectedAttachmentUrl = () => { + const unsubscribe = this.state.subscribeWithSelector( + (currentValue) => ({ + messages: currentValue.messages, + selectedAttachmentUrl: currentValue.selectedAttachmentUrl, + }), + ({ selectedAttachmentUrl }) => { + if (!selectedAttachmentUrl) { + return; + } + const index = this.assets.findIndex( + (asset) => + stripQueryFromUrl(asset.uri) === stripQueryFromUrl(selectedAttachmentUrl ?? ''), + ); + this.state.partialNext({ currentIndex: index === -1 ? 0 : index }); + }, + ); + + return unsubscribe; + }; + + registerSubscriptions = () => { + const subscriptions: Unsubscribe[] = []; + subscriptions.push(this.subscribeToMessages()); + subscriptions.push(this.subscribeToSelectedAttachmentUrl()); + + return () => { + subscriptions.forEach((subscription) => subscription()); + this.videoPlayerPool.clear(); + }; + }; + + clear = () => { + this.state.partialNext(INITIAL_STATE); + this.videoPlayerPool.clear(); + }; +} diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts index 642110a9c6..a557204a33 100644 --- a/package/src/state-store/index.ts +++ b/package/src/state-store/index.ts @@ -1,3 +1,6 @@ -export * from './audio-player'; export * from './in-app-notifications-store'; +export * from './audio-player'; export * from './audio-player-pool'; +export * from './video-player'; +export * from './video-player-pool'; +export * from './image-gallery-state-store'; diff --git a/package/src/state-store/video-player-pool.ts b/package/src/state-store/video-player-pool.ts new file mode 100644 index 0000000000..0ab04df7fd --- /dev/null +++ b/package/src/state-store/video-player-pool.ts @@ -0,0 +1,86 @@ +import { StateStore } from 'stream-chat'; + +import { VideoPlayer, VideoPlayerOptions } from './video-player'; + +export type VideoPlayerPoolState = { + activeVideoPlayer: VideoPlayer | null; +}; + +export class VideoPlayerPool { + pool: Map; + state: StateStore = new StateStore({ + activeVideoPlayer: null, + }); + + constructor() { + this.pool = new Map(); + } + + get players() { + return Array.from(this.pool.values()); + } + + getOrAddPlayer(params: VideoPlayerOptions) { + const player = this.pool.get(params.id); + if (player) { + return player; + } + const newPlayer = new VideoPlayer(params); + newPlayer.pool = this; + + this.pool.set(params.id, newPlayer); + return newPlayer; + } + + setActivePlayer(activeVideoPlayer: VideoPlayer | null) { + this.state.partialNext({ + activeVideoPlayer, + }); + } + + getActivePlayer() { + return this.state.getLatestValue().activeVideoPlayer; + } + + removePlayer(id: string) { + const player = this.pool.get(id); + if (!player) return; + player.onRemove(); + this.pool.delete(id); + + if (this.getActivePlayer()?.id === id) { + this.setActivePlayer(null); + } + } + + deregister(id: string) { + if (this.pool.has(id)) { + this.pool.delete(id); + } + } + + clear() { + for (const player of this.pool.values()) { + this.removePlayer(player.id); + } + this.setActivePlayer(null); + } + + requestPlay(id: string) { + if (this.getActivePlayer()?.id !== id) { + const currentPlayer = this.getActivePlayer(); + if (currentPlayer && currentPlayer.isPlaying) { + currentPlayer.pause(); + } + } + + const activePlayer = this.pool.get(id); + if (activePlayer) { + this.setActivePlayer(activePlayer); + } + } + + notifyPaused() { + this.setActivePlayer(null); + } +} diff --git a/package/src/state-store/video-player.ts b/package/src/state-store/video-player.ts new file mode 100644 index 0000000000..7f8eb059eb --- /dev/null +++ b/package/src/state-store/video-player.ts @@ -0,0 +1,155 @@ +import { StateStore } from 'stream-chat'; + +import { VideoPlayerPool } from './video-player-pool'; + +import { NativeHandlers, VideoType } from '../native'; +import { ONE_SECOND_IN_MILLISECONDS } from '../utils/constants'; + +export type VideoPlayerState = { + duration: number; + position: number; + progress: number; + isPlaying: boolean; +}; + +export type VideoDescriptor = { + id: string; +}; + +export type VideoPlayerOptions = VideoDescriptor & { + autoPlay?: boolean; +}; + +export const INITIAL_VIDEO_PLAYER_STATE: VideoPlayerState = { + duration: 0, + isPlaying: false, + position: 0, + progress: 0, +}; + +export class VideoPlayer { + state: StateStore; + options: VideoPlayerOptions; + playerRef: VideoType | null = null; + _pool: VideoPlayerPool | null = null; + private _id: string; + private isExpoCLI: boolean; + + constructor(options: VideoPlayerOptions) { + this.isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; + this.state = new StateStore({ + ...INITIAL_VIDEO_PLAYER_STATE, + isPlaying: options.autoPlay ?? false, + }); + this.options = options; + this._id = options.id; + } + + initPlayer = ({ playerRef }: { playerRef?: VideoType }) => { + this.playerRef = playerRef ?? null; + }; + + get id() { + return this._id; + } + + get isPlaying() { + return this.state.getLatestValue().isPlaying; + } + + get duration() { + return this.state.getLatestValue().duration; + } + + get position() { + return this.state.getLatestValue().position; + } + + get progress() { + return this.state.getLatestValue().progress; + } + + set pool(pool: VideoPlayerPool) { + this._pool = pool; + } + + set duration(duration: number) { + this.state.partialNext({ + duration, + }); + } + + set position(position: number) { + this.state.partialNext({ + position, + progress: position / this.duration, + }); + } + + set progress(progress: number) { + this.state.partialNext({ + position: progress * this.duration, + progress, + }); + } + + set isPlaying(isPlaying: boolean) { + this.state.partialNext({ + isPlaying, + }); + } + + play() { + if (this._pool) { + this._pool.requestPlay(this.id); + } + if (this.playerRef?.play) { + this.playerRef.play(); + } + this.state.partialNext({ + isPlaying: true, + }); + } + + pause() { + if (this.playerRef?.pause) { + this.playerRef.pause(); + } + this.state.partialNext({ + isPlaying: false, + }); + if (this._pool) { + this._pool.notifyPaused(); + } + } + + toggle() { + this.isPlaying ? this.pause() : this.play(); + } + + seek(position: number) { + this.position = position; + if (this.isExpoCLI) { + if (this.playerRef?.seekBy) { + this.playerRef.seekBy(position / ONE_SECOND_IN_MILLISECONDS); + } + } else { + if (this.playerRef?.seek) { + this.playerRef.seek(position / ONE_SECOND_IN_MILLISECONDS); + } + } + } + + stop() { + this.seek(0); + this.pause(); + } + + onRemove() { + if (this.playerRef?.pause) { + this.playerRef.pause(); + } + this.playerRef = null; + this.state.partialNext(INITIAL_VIDEO_PLAYER_STATE); + } +} diff --git a/package/src/utils/constants.ts b/package/src/utils/constants.ts index df34a63b25..3fc805ae85 100644 --- a/package/src/utils/constants.ts +++ b/package/src/utils/constants.ts @@ -5,3 +5,4 @@ export const defaultMentionAllAppUsersQuery = { sort: {}, }; export const POLL_OPTION_HEIGHT = 71; +export const ONE_SECOND_IN_MILLISECONDS = 1000; diff --git a/package/src/utils/getUrlOfImageAttachment.ts b/package/src/utils/getUrlOfImageAttachment.ts index 3f012ffccb..ec979d74b4 100644 --- a/package/src/utils/getUrlOfImageAttachment.ts +++ b/package/src/utils/getUrlOfImageAttachment.ts @@ -1,10 +1,19 @@ import type { Attachment } from 'stream-chat'; +import { FileTypes } from '../types/types'; + /** * Extract url of image from image attachment. * @param image Image attachment * @returns {string} */ -export function getUrlOfImageAttachment(image: Attachment) { +export function getUrlOfImageAttachment( + image: Attachment, + giphyVersion: keyof NonNullable = 'fixed_height', +) { + if (image.type === FileTypes.Giphy) { + return image.giphy?.[giphyVersion]?.url || image.thumb_url; + } + return image.image_url || image.asset_url; } diff --git a/package/yarn.lock b/package/yarn.lock index 100f186f7e..0d94a0d47f 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1362,10 +1362,10 @@ "@eslint/core" "^0.14.0" levn "^0.4.1" -"@gorhom/bottom-sheet@^5.1.8": - version "5.1.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" - integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== +"@gorhom/bottom-sheet@^5.2.8": + version "5.2.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" + integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4"