From 05a128eecedc5b0ae1c21fd350c440f4b44bdfe7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 23 Oct 2023 11:48:19 +0200 Subject: [PATCH 1/2] feat(VirtualizedMessageList): allow to merge custom virtuoso components with the SDK defaults --- .../DateSeparator/DateSeparator.tsx | 2 +- .../__tests__/DateSeparator.test.js | 4 + .../EventComponent/EventComponent.tsx | 2 +- .../__tests__/EventComponent.test.js | 1 + src/components/MessageList/MessageList.tsx | 8 +- .../MessageList/VirtualizedMessageList.tsx | 277 +++--------- .../__tests__/VirtualizedMessageList.test.js | 2 +- .../MessageList/hooks/MessageList/index.ts | 4 + .../{ => MessageList}/useEnrichedMessages.ts | 10 +- .../useMessageListElements.tsx | 24 +- .../useMessageListScrollManager.ts | 6 +- .../useScrollLocationLogic.tsx | 4 +- .../hooks/VirtualizedMessageList/index.ts | 7 + .../useGiphyPreview.ts | 6 +- .../useMessageSetKey.ts | 33 ++ .../useNewMessageNotification.ts | 4 +- .../usePrependMessagesCount.ts | 4 +- .../useScrollToBottomOnNewMessage.ts | 59 +++ .../useShouldForceScrollToBottom.ts | 4 +- .../useVirtualizedMessageListComponents.tsx | 200 +++++++++ .../useMessageListScrollManager.test.js | 2 +- ...seVirtualizedMessageListComponents.test.js | 422 ++++++++++++++++++ src/components/MessageList/hooks/index.ts | 14 +- 23 files changed, 849 insertions(+), 250 deletions(-) create mode 100644 src/components/MessageList/hooks/MessageList/index.ts rename src/components/MessageList/hooks/{ => MessageList}/useEnrichedMessages.ts (87%) rename src/components/MessageList/hooks/{ => MessageList}/useMessageListElements.tsx (81%) rename src/components/MessageList/hooks/{ => MessageList}/useMessageListScrollManager.ts (93%) rename src/components/MessageList/hooks/{ => MessageList}/useScrollLocationLogic.tsx (95%) create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/index.ts rename src/components/MessageList/hooks/{ => VirtualizedMessageList}/useGiphyPreview.ts (80%) create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts rename src/components/MessageList/hooks/{ => VirtualizedMessageList}/useNewMessageNotification.ts (92%) rename src/components/MessageList/hooks/{ => VirtualizedMessageList}/usePrependMessagesCount.ts (95%) create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts rename src/components/MessageList/hooks/{ => VirtualizedMessageList}/useShouldForceScrollToBottom.ts (86%) create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/useVirtualizedMessageListComponents.tsx create mode 100644 src/components/MessageList/hooks/__tests__/useVirtualizedMessageListComponents.test.js diff --git a/src/components/DateSeparator/DateSeparator.tsx b/src/components/DateSeparator/DateSeparator.tsx index 6eae90c24..a01d8c09d 100644 --- a/src/components/DateSeparator/DateSeparator.tsx +++ b/src/components/DateSeparator/DateSeparator.tsx @@ -27,7 +27,7 @@ const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { }); return ( -
+
{(position === 'right' || position === 'center') && (
)} diff --git a/src/components/DateSeparator/__tests__/DateSeparator.test.js b/src/components/DateSeparator/__tests__/DateSeparator.test.js index b6a30e26f..cd750d484 100644 --- a/src/components/DateSeparator/__tests__/DateSeparator.test.js +++ b/src/components/DateSeparator/__tests__/DateSeparator.test.js @@ -47,6 +47,7 @@ describe('DateSeparator', () => { expect(tree).toMatchInlineSnapshot(`

{ expect(tree).toMatchInlineSnapshot(`

{ expect(tree).toMatchInlineSnapshot(`
{ expect(tree).toMatchInlineSnapshot(`

+

{text}

diff --git a/src/components/EventComponent/__tests__/EventComponent.test.js b/src/components/EventComponent/__tests__/EventComponent.test.js index 7dd5a8853..5ddf99a0e 100644 --- a/src/components/EventComponent/__tests__/EventComponent.test.js +++ b/src/components/EventComponent/__tests__/EventComponent.test.js @@ -30,6 +30,7 @@ describe('EventComponent', () => { expect(tree).toMatchInlineSnapshot(`
= { customClasses: ChatProps['customClasses']; @@ -59,7 +55,7 @@ type VirtuosoContext< numItemsPrepended: number; /** Mapping of message ID of own messages to the array of users, who read the given message */ ownMessagesReadByOthers: Record[]>; - processedMessages: StreamMessage[]; + processedMessages: StreamMessage[]; }; type VirtualizedMessageListWithContextProps< @@ -120,9 +116,11 @@ const VirtualizedMessageListWithContext = < props: VirtualizedMessageListWithContextProps, ) => { const { - additionalVirtuosoProps, + additionalMessageInputProps, + additionalVirtuosoProps = {}, channel, closeReactionSelectorOnClick, + customMessageActions, customMessageRenderer, defaultItemHeight, disableDateSeparator = true, @@ -137,7 +135,8 @@ const VirtualizedMessageListWithContext = < loadingMore, loadMore, loadMoreNewer, - Message: propMessage, + Message, + messageActions, messageLimit = 100, messages, notifications, @@ -154,27 +153,26 @@ const VirtualizedMessageListWithContext = < threadList, } = props; + const { + components: virtuosoComponentsFromProps, + ...overridingVirtuosoProps + } = additionalVirtuosoProps; + // Stops errors generated from react-virtuoso to bubble up // to Sentry or other tracking tools. useCaptureResizeObserverExceededError(); const { - DateSeparator = DefaultDateSeparator, - EmptyStateIndicator = DefaultEmptyStateIndicator, GiphyPreviewMessage = DefaultGiphyPreviewMessage, - LoadingIndicator = DefaultLoadingIndicator, MessageListNotifications = DefaultMessageListNotifications, MessageNotification = DefaultMessageNotification, - MessageSystem = EventComponent, - TypingIndicator = null, - VirtualMessage: contextMessage = MessageSimple, } = useComponentContext('VirtualizedMessageList'); const { client, customClasses } = useChatContext('VirtualizedMessageList'); - const lastRead = useMemo(() => channel.lastRead?.(), [channel]); + const virtuoso = useRef(null); - const MessageUIComponent = propMessage || contextMessage; + const lastRead = useMemo(() => channel.lastRead?.(), [channel]); const { giphyPreviewMessage, setGiphyPreviewMessage } = useGiphyPreview( separateGiphyPreview, @@ -239,11 +237,9 @@ const VirtualizedMessageListWithContext = < return acc; }, {}), // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage - [processedMessages.length, shouldGroupByUser], + [processedMessages.length, shouldGroupByUser, groupStylesFn], ); - const virtuoso = useRef(null); - const { atBottom, isMessageListScrolledToBottom, @@ -273,56 +269,11 @@ const VirtualizedMessageListWithContext = < jumpToLatestMessage, ]); - const [newMessagesReceivedInBackground, setNewMessagesReceivedInBackground] = React.useState( - false, - ); - - const resetNewMessagesReceivedInBackground = useCallback(() => { - setNewMessagesReceivedInBackground(false); - }, []); - - useEffect(() => { - setNewMessagesReceivedInBackground(true); - }, [messages]); - - const scrollToBottomIfConfigured = useCallback( - (event: Event) => { - if (scrollToLatestMessageOnFocus && event.target === window) { - if (newMessagesReceivedInBackground) { - setTimeout(scrollToBottom, 100); - } - } - }, - [scrollToLatestMessageOnFocus, scrollToBottom, newMessagesReceivedInBackground], - ); - - useEffect(() => { - if (typeof window !== 'undefined') { - window.addEventListener('focus', scrollToBottomIfConfigured); - window.addEventListener('blur', resetNewMessagesReceivedInBackground); - } - - return () => { - window.removeEventListener('focus', scrollToBottomIfConfigured); - window.removeEventListener('blur', resetNewMessagesReceivedInBackground); - }; - }, [scrollToBottomIfConfigured]); + useScrollToBottomOnNewMessage({ messages, scrollToBottom, scrollToLatestMessageOnFocus }); const numItemsPrepended = usePrependedMessagesCount(processedMessages, !disableDateSeparator); - /** - * Logic to update the key of the virtuoso component when the list jumps to a new location. - */ - const [messageSetKey, setMessageSetKey] = useState(+new Date()); - const firstMessageId = useRef(); - - useEffect(() => { - const continuousSet = messages?.find((message) => message.id === firstMessageId.current); - if (!continuousSet) { - setMessageSetKey(+new Date()); - } - firstMessageId.current = messages?.[0]?.id; - }, [messages]); + const { messageSetKey } = useMessageSetKey({ messages }); const shouldForceScrollToBottom = useShouldForceScrollToBottom(processedMessages, client.userID); @@ -338,117 +289,33 @@ const VirtualizedMessageListWithContext = < return isAtBottom ? stickToBottomScrollBehavior : false; }; - const messageRenderer = useCallback( - ( - messageList: StreamMessage[], - virtuosoIndex: number, - virtuosoContext: VirtuosoContext, - ) => { - const { lastReceivedMessageId, ownMessagesReadByOthers } = virtuosoContext; - const streamMessageIndex = virtuosoIndex + numItemsPrepended - PREPEND_OFFSET; - // use custom renderer supplied by client if present and skip the rest - if (customMessageRenderer) { - return customMessageRenderer(messageList, streamMessageIndex); - } - - const message = messageList[streamMessageIndex]; - - if (message.customType === CUSTOM_MESSAGE_TYPE.date && message.date && isDate(message.date)) { - return ; - } - - if (!message) return
; // returning null or zero height breaks the virtuoso - - if (message.type === 'system') { - return ; - } + const { + messageRenderer, + virtuosoComponents, + } = useVirtualizedMessageListComponents({ + additionalMessageInputProps, + closeReactionSelectorOnClick, + components: virtuosoComponentsFromProps, + customMessageActions, + customMessageRenderer, + head, + loadingMore, + Message, + messageActions, + prependOffset: PREPEND_OFFSET, + shouldGroupByUser, + threadList, + virtuosoRef: virtuoso, + }); - const groupedByUser = - shouldGroupByUser && - streamMessageIndex > 0 && - message.user?.id === messageList[streamMessageIndex - 1].user?.id; - - const firstOfGroup = - shouldGroupByUser && message.user?.id !== messageList[streamMessageIndex - 1]?.user?.id; - - const endOfGroup = - shouldGroupByUser && message.user?.id !== messageList[streamMessageIndex + 1]?.user?.id; - - return ( - - ); - }, - [customMessageRenderer, shouldGroupByUser, numItemsPrepended], + const computeItemKey = useCallback< + ComputeItemKey> + >( + (index, _, { numItemsPrepended, processedMessages }) => + processedMessages[numItemsPrepended + index - PREPEND_OFFSET].id, + [], ); - const Item = useMemo(() => { - // using 'display: inline-block' - // traps CSS margins of the item elements, preventing incorrect item measurements - const Item: Components['Item'] = (props) => { - const context = props.context as VirtuosoContext; - - const streamMessageIndex = - props['data-item-index'] + context.numItemsPrepended - PREPEND_OFFSET; - const message = context.processedMessages[streamMessageIndex]; - const groupStyles: GroupStyle = context.messageGroupStyles[message.id] || ''; - - return ( -
- ); - }; - return Item; - }, []); - - const virtuosoComponents: Partial = useMemo(() => { - const EmptyPlaceholder: Components['EmptyPlaceholder'] = () => ( - <> - {EmptyStateIndicator && ( - - )} - - ); - - const Header: Components['Header'] = () => - loadingMore ? ( -
- -
- ) : ( - head || null - ); - - const Footer: Components['Footer'] = () => - TypingIndicator ? : <>; - - return { - EmptyPlaceholder, - Footer, - Header, - Item, - }; - }, [loadingMore, head, Item]); - const atBottomStateChange = (isAtBottom: boolean) => { atBottom.current = isAtBottom; setIsMessageListScrolledToBottom(isAtBottom); @@ -484,24 +351,20 @@ const VirtualizedMessageListWithContext = < <>
- > atBottomStateChange={atBottomStateChange} atBottomThreshold={200} className='str-chat__message-list-scroll' components={virtuosoComponents} - computeItemKey={(index) => - processedMessages[numItemsPrepended + index - PREPEND_OFFSET].id - } - context={ - { - customClasses, - lastReceivedMessageId, - messageGroupStyles, - numItemsPrepended, - ownMessagesReadByOthers, - processedMessages, - } as VirtuosoContext - } + computeItemKey={computeItemKey} + context={{ + customClasses, + lastReceivedMessageId, + messageGroupStyles, + numItemsPrepended, + ownMessagesReadByOthers, + processedMessages, + }} endReached={endReached} firstItemIndex={PREPEND_OFFSET - numItemsPrepended} followOutput={followOutput} @@ -510,9 +373,7 @@ const VirtualizedMessageListWithContext = < processedMessages, highlightedMessageId, )} - itemContent={(i, _, context) => - messageRenderer(processedMessages, i, context as VirtuosoContext) - } + itemContent={messageRenderer} itemSize={fractionalItemSize} key={messageSetKey} overscan={overscan} @@ -520,7 +381,7 @@ const VirtualizedMessageListWithContext = < startReached={startReached} style={{ overflowX: 'hidden' }} totalCount={processedMessages.length} - {...additionalVirtuosoProps} + {...overridingVirtuosoProps} {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} {...(defaultItemHeight ? { defaultItemHeight } : {})} /> @@ -549,7 +410,7 @@ export type VirtualizedMessageListProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = Partial, PropsDrilledToMessage>> & { /** Additional props to be passed the underlying [`react-virtuoso` virtualized list dependency](https://virtuoso.dev/virtuoso-api-reference/) */ - additionalVirtuosoProps?: VirtuosoProps; + additionalVirtuosoProps?: VirtuosoProps>; /** If true, picking a reaction from the `ReactionSelector` component will close the selector */ closeReactionSelectorOnClick?: boolean; /** Custom render function, if passed, certain UI props are ignored */ @@ -557,7 +418,9 @@ export type VirtualizedMessageListProps< messageList: StreamMessage[], index: number, ) => React.ReactElement; - /** If set, the default item height is used for the calculation of the total list height. Use if you expect messages with a lot of height variance */ + /** @deprecated Use additionalVirtuosoProps.defaultItemHeight instead. Will be removed with next major release - `v11.0.0`. + * If set, the default item height is used for the calculation of the total list height. Use if you expect messages with a lot of height variance + * */ defaultItemHeight?: number; /** Disables the injection of date separator components in MessageList, defaults to `true` */ disableDateSeparator?: boolean; @@ -594,11 +457,15 @@ export type VirtualizedMessageListProps< messageLimit?: number; /** Optional prop to override the messages available from [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */ messages?: StreamMessage[]; - /** The amount of extra content the list should render in addition to what's necessary to fill in the viewport */ + /** + * @deprecated Use additionalVirtuosoProps.overscan instead. Will be removed with next major release - `v11.0.0`. + * The amount of extra content the list should render in addition to what's necessary to fill in the viewport + */ overscan?: number; /** Keep track of read receipts for each message sent by the user. When disabled, only the last own message delivery / read status is rendered. */ returnAllReadData?: boolean; /** + * @deprecated Pass additionalVirtuosoProps.scrollSeekConfiguration and specify the placeholder in additionalVirtuosoProps.components.ScrollSeekPlaceholder instead. Will be removed with next major release - `v11.0.0`. * Performance improvement by showing placeholders if user scrolls fast through list. * it can be used like this: * ``` diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.js b/src/components/MessageList/__tests__/VirtualizedMessageList.test.js index 3ce12004a..a928db091 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.js +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.js @@ -13,7 +13,7 @@ import { useMockedApis, } from '../../../mock-builders'; -import { usePrependedMessagesCount } from '../hooks/usePrependMessagesCount'; +import { usePrependedMessagesCount } from '../hooks'; import { VirtualizedMessageList } from '../VirtualizedMessageList'; import { Chat } from '../../Chat'; diff --git a/src/components/MessageList/hooks/MessageList/index.ts b/src/components/MessageList/hooks/MessageList/index.ts new file mode 100644 index 000000000..7dcb62ed5 --- /dev/null +++ b/src/components/MessageList/hooks/MessageList/index.ts @@ -0,0 +1,4 @@ +export * from './useEnrichedMessages'; +export * from './useMessageListElements'; +export * from './useMessageListScrollManager'; +export * from './useScrollLocationLogic'; diff --git a/src/components/MessageList/hooks/useEnrichedMessages.ts b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts similarity index 87% rename from src/components/MessageList/hooks/useEnrichedMessages.ts rename to src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts index 7db78b448..1c5ddeb39 100644 --- a/src/components/MessageList/hooks/useEnrichedMessages.ts +++ b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts @@ -1,15 +1,15 @@ import { useMemo } from 'react'; -import { getGroupStyles, GroupStyle, insertIntro, processMessages } from '../utils'; +import { getGroupStyles, GroupStyle, insertIntro, processMessages } from '../../utils'; -import { useChatContext } from '../../../context/ChatContext'; -import { useComponentContext } from '../../../context/ComponentContext'; +import { useChatContext } from '../../../../context/ChatContext'; +import { useComponentContext } from '../../../../context/ComponentContext'; import type { Channel } from 'stream-chat'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export const useEnrichedMessages = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/MessageList/hooks/useMessageListElements.tsx b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx similarity index 81% rename from src/components/MessageList/hooks/useMessageListElements.tsx rename to src/components/MessageList/hooks/MessageList/useMessageListElements.tsx index 9cfc2a06b..f2abb5024 100644 --- a/src/components/MessageList/hooks/useMessageListElements.tsx +++ b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx @@ -1,25 +1,25 @@ /* eslint-disable no-continue */ import React, { useMemo } from 'react'; -import { useLastReadData } from './useLastReadData'; -import { getLastReceived, GroupStyle } from '../utils'; +import { useLastReadData } from '../useLastReadData'; +import { getLastReceived, GroupStyle } from '../../utils'; -import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; -import { DateSeparator as DefaultDateSeparator } from '../../DateSeparator/DateSeparator'; -import { EventComponent } from '../../EventComponent/EventComponent'; -import { Message } from '../../Message'; +import { CUSTOM_MESSAGE_TYPE } from '../../../../constants/messageTypes'; +import { DateSeparator as DefaultDateSeparator } from '../../../DateSeparator/DateSeparator'; +import { EventComponent } from '../../../EventComponent/EventComponent'; +import { Message } from '../../../Message'; -import { useChatContext } from '../../../context/ChatContext'; -import { useComponentContext } from '../../../context/ComponentContext'; -import { isDate } from '../../../context/TranslationContext'; +import { useChatContext } from '../../../../context/ChatContext'; +import { useComponentContext } from '../../../../context/ComponentContext'; +import { isDate } from '../../../../context/TranslationContext'; import type { UserResponse } from 'stream-chat'; -import type { MessageProps } from '../../Message/types'; +import type { MessageProps } from '../../../Message/types'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; type MessagePropsToOmit = | 'channel' diff --git a/src/components/MessageList/hooks/useMessageListScrollManager.ts b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts similarity index 93% rename from src/components/MessageList/hooks/useMessageListScrollManager.ts rename to src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts index fd00907ba..ac13e0f2b 100644 --- a/src/components/MessageList/hooks/useMessageListScrollManager.ts +++ b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts @@ -1,10 +1,10 @@ import { useLayoutEffect, useRef } from 'react'; -import { useChatContext } from '../../../context/ChatContext'; +import { useChatContext } from '../../../../context/ChatContext'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export type ContainerMeasures = { offsetHeight: number; diff --git a/src/components/MessageList/hooks/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx similarity index 95% rename from src/components/MessageList/hooks/useScrollLocationLogic.tsx rename to src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 85d9ca032..1058e3dbb 100644 --- a/src/components/MessageList/hooks/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -2,9 +2,9 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useMessageListScrollManager } from './useMessageListScrollManager'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export type UseScrollLocationLogicParams< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/index.ts b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts new file mode 100644 index 000000000..fbea340d0 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts @@ -0,0 +1,7 @@ +export * from './useGiphyPreview'; +export * from './useMessageSetKey'; +export * from './useNewMessageNotification'; +export * from './usePrependMessagesCount'; +export * from './useScrollToBottomOnNewMessage'; +export * from './useShouldForceScrollToBottom'; +export * from './useVirtualizedMessageListComponents'; diff --git a/src/components/MessageList/hooks/useGiphyPreview.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts similarity index 80% rename from src/components/MessageList/hooks/useGiphyPreview.ts rename to src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts index 5f6b8049d..63aede77d 100644 --- a/src/components/MessageList/hooks/useGiphyPreview.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useGiphyPreview.ts @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react'; -import { useChatContext } from '../../../context/ChatContext'; +import { useChatContext } from '../../../../context/ChatContext'; import type { EventHandler } from 'stream-chat'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export const useGiphyPreview = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts new file mode 100644 index 000000000..584ba1a18 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useMessageSetKey.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from 'react'; +import { StreamMessage } from '../../../../context'; +import { DefaultStreamChatGenerics } from '../../../../types/types'; + +type UseMessageSetKeyParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + messages?: StreamMessage[]; +}; + +export const useMessageSetKey = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + messages, +}: UseMessageSetKeyParams) => { + /** + * Logic to update the key of the virtuoso component when the list jumps to a new location. + */ + const [messageSetKey, setMessageSetKey] = useState(+new Date()); + const firstMessageId = useRef(); + + useEffect(() => { + const continuousSet = messages?.find((message) => message.id === firstMessageId.current); + if (!continuousSet) { + setMessageSetKey(+new Date()); + } + firstMessageId.current = messages?.[0]?.id; + }, [messages]); + + return { + messageSetKey, + }; +}; diff --git a/src/components/MessageList/hooks/useNewMessageNotification.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts similarity index 92% rename from src/components/MessageList/hooks/useNewMessageNotification.ts rename to src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts index 1a4f509e0..ad4fda0f3 100644 --- a/src/components/MessageList/hooks/useNewMessageNotification.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export function useNewMessageNotification< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/MessageList/hooks/usePrependMessagesCount.ts b/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts similarity index 95% rename from src/components/MessageList/hooks/usePrependMessagesCount.ts rename to src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts index 6b92eef04..2a5a3a612 100644 --- a/src/components/MessageList/hooks/usePrependMessagesCount.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/usePrependMessagesCount.ts @@ -1,8 +1,8 @@ import { useMemo, useRef } from 'react'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; const STATUSES_EXCLUDED_FROM_PREPEND = ['sending', 'failed']; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts new file mode 100644 index 000000000..98d3f63e4 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useScrollToBottomOnNewMessage.ts @@ -0,0 +1,59 @@ +import React, { useCallback, useEffect } from 'react'; +import { StreamMessage } from '../../../../context'; +import { DefaultStreamChatGenerics } from '../../../../types/types'; + +type UseScrollToBottomOnNewMessageParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + /** */ + scrollToBottom: () => void; + messages?: StreamMessage[]; + /** When `true`, the list will scroll to the latest message when the window regains focus */ + scrollToLatestMessageOnFocus?: boolean; +}; + +export const useScrollToBottomOnNewMessage = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + messages, + scrollToBottom, + scrollToLatestMessageOnFocus, +}: UseScrollToBottomOnNewMessageParams) => { + const [newMessagesReceivedInBackground, setNewMessagesReceivedInBackground] = React.useState( + false, + ); + + const resetNewMessagesReceivedInBackground = useCallback(() => { + setNewMessagesReceivedInBackground(false); + }, []); + + useEffect(() => { + setNewMessagesReceivedInBackground(true); + }, [messages]); + + const scrollToBottomIfConfigured = useCallback( + (event: Event) => { + console.log('VML (focus)', scrollToLatestMessageOnFocus); + if ( + !scrollToLatestMessageOnFocus || + !newMessagesReceivedInBackground || + event.target !== window + ) + return; + setTimeout(scrollToBottom, 100); + }, + [scrollToLatestMessageOnFocus, scrollToBottom, newMessagesReceivedInBackground], + ); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.addEventListener('focus', scrollToBottomIfConfigured); + window.addEventListener('blur', resetNewMessagesReceivedInBackground); + } + + return () => { + window.removeEventListener('focus', scrollToBottomIfConfigured); + window.removeEventListener('blur', resetNewMessagesReceivedInBackground); + }; + }, [scrollToBottomIfConfigured]); +}; diff --git a/src/components/MessageList/hooks/useShouldForceScrollToBottom.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts similarity index 86% rename from src/components/MessageList/hooks/useShouldForceScrollToBottom.ts rename to src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts index c1dd177ce..ae0aa56fd 100644 --- a/src/components/MessageList/hooks/useShouldForceScrollToBottom.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useShouldForceScrollToBottom.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react'; -import type { StreamMessage } from '../../../context/ChannelStateContext'; +import type { StreamMessage } from '../../../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { DefaultStreamChatGenerics } from '../../../../types/types'; export function useShouldForceScrollToBottom< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useVirtualizedMessageListComponents.tsx b/src/components/MessageList/hooks/VirtualizedMessageList/useVirtualizedMessageListComponents.tsx new file mode 100644 index 000000000..2e24ac6f1 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useVirtualizedMessageListComponents.tsx @@ -0,0 +1,200 @@ +import clsx from 'clsx'; +import React, { RefObject, useCallback, useMemo } from 'react'; + +import { isDate, useComponentContext } from '../../../../context'; +import { Message, MessageSimple } from '../../../Message'; +import { DateSeparator as DefaultDateSeparator } from '../../../DateSeparator'; +import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../../../EmptyStateIndicator'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../../../Loading'; +import { EventComponent } from '../../../EventComponent'; +import { CUSTOM_MESSAGE_TYPE } from '../../../../constants/messageTypes'; + +import type { Components, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import type { GroupStyle } from '../../utils'; +import type { DefaultStreamChatGenerics, UnknownType } from '../../../../types/types'; +import type { VirtualizedMessageListProps, VirtuosoContext } from '../../VirtualizedMessageList'; + +type ExtractedVirtuosoProps = 'components'; +type ExtractedVirtualizedMessageListProps = + | 'additionalMessageInputProps' + | 'closeReactionSelectorOnClick' + | 'customMessageActions' + | 'customMessageRenderer' + | 'head' + | 'loadingMore' + | 'Message' + | 'messageActions' + | 'shouldGroupByUser' + | 'threadList'; + +type UseVirtuosoComponentsParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = Pick, ExtractedVirtualizedMessageListProps> & + Pick>, ExtractedVirtuosoProps> & { + prependOffset: number; + virtuosoRef: RefObject; + }; + +export const useVirtualizedMessageListComponents = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + additionalMessageInputProps, + closeReactionSelectorOnClick, + components: virtuosoComponentsFromProps, + customMessageActions, + customMessageRenderer, + head, + loadingMore, + Message: MessageUIComponentProps, + messageActions, + prependOffset, + shouldGroupByUser, + threadList, + virtuosoRef, +}: UseVirtuosoComponentsParams) => { + const { + DateSeparator = DefaultDateSeparator, + EmptyStateIndicator = DefaultEmptyStateIndicator, + LoadingIndicator = DefaultLoadingIndicator, + MessageSystem = EventComponent, + TypingIndicator = null, + VirtualMessage: MessageUIComponentContext = MessageSimple, + } = useComponentContext('VirtualizedMessageList'); + const MessageUIComponent = MessageUIComponentProps || MessageUIComponentContext; + + const messageRenderer = useCallback( + // @ts-ignore + (virtuosoIndex: number, _, virtuosoContext: VirtuosoContext) => { + const { + lastReceivedMessageId, + numItemsPrepended, + ownMessagesReadByOthers, + processedMessages: messageList, + } = virtuosoContext; + const streamMessageIndex = virtuosoIndex + numItemsPrepended - prependOffset; + + if (customMessageRenderer) { + return customMessageRenderer(messageList, streamMessageIndex); + } + + const message = messageList[streamMessageIndex]; + + if (!message) return
; // returning null or zero height breaks the virtuoso + + if (message.customType === CUSTOM_MESSAGE_TYPE.date && message.date && isDate(message.date)) { + return ; + } + + if (message.type === 'system') { + return ; + } + + const groupedByUser = + shouldGroupByUser && + streamMessageIndex > 0 && + message.user?.id === messageList[streamMessageIndex - 1].user?.id; + const firstOfGroup = + shouldGroupByUser && message.user?.id !== messageList[streamMessageIndex - 1]?.user?.id; + + const endOfGroup = + shouldGroupByUser && message.user?.id !== messageList[streamMessageIndex + 1]?.user?.id; + + return ( + + ); + }, + [ + additionalMessageInputProps, + closeReactionSelectorOnClick, + customMessageActions, + customMessageRenderer, + messageActions, + shouldGroupByUser, + prependOffset, + ], + ); + + const virtuosoComponents: Partial< + Components> + > = useMemo(() => { + // using 'display: inline-block' + // traps CSS margins of the item elements, preventing incorrect item measurements + const Item: Components>['Item'] = ({ + context, + ...props + }) => { + if (!context) return <>; + + const streamMessageIndex = + props['data-item-index'] + context.numItemsPrepended - prependOffset; + const message = context.processedMessages[streamMessageIndex]; + const groupStyles: GroupStyle = context.messageGroupStyles[message.id] || ''; + + return ( +
+ ); + }; + + const EmptyPlaceholder: Components< + VirtuosoContext + >['EmptyPlaceholder'] = () => ( + <> + {EmptyStateIndicator && ( + + )} + + ); + + const Header: Components>['Header'] = () => + loadingMore && !!LoadingIndicator ? ( +
+ +
+ ) : ( + head || null + ); + + const Footer: Components>['Footer'] = () => + TypingIndicator ? : <>; + + return { + EmptyPlaceholder, + Footer, + Header, + Item, + ...virtuosoComponentsFromProps, + }; + }, [ + EmptyStateIndicator, + LoadingIndicator, + TypingIndicator, + loadingMore, + head, + threadList, + virtuosoComponentsFromProps, + prependOffset, + ]); + + return { messageRenderer, virtuosoComponents }; +}; diff --git a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js index ce1d511f9..482ba5c86 100644 --- a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js +++ b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { cleanup, render } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { useMessageListScrollManager } from '../useMessageListScrollManager'; +import { useMessageListScrollManager } from '../'; import { ChatProvider } from '../../../../context/ChatContext'; import { generateUser, getTestClientWithUser } from '../../../../mock-builders'; diff --git a/src/components/MessageList/hooks/__tests__/useVirtualizedMessageListComponents.test.js b/src/components/MessageList/hooks/__tests__/useVirtualizedMessageListComponents.test.js new file mode 100644 index 000000000..b88d1630f --- /dev/null +++ b/src/components/MessageList/hooks/__tests__/useVirtualizedMessageListComponents.test.js @@ -0,0 +1,422 @@ +import { + generateChannel, + generateMessage, + generateUser, + getTestClientWithUser, +} from '../../../../mock-builders'; +import { + ChannelActionProvider, + ChannelStateProvider, + ChatProvider, + ComponentProvider, +} from '../../../../context'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { useVirtualizedMessageListComponents } from '../VirtualizedMessageList'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +const prependOffset = 0; +const user1 = generateUser(); +const user2 = generateUser(); +let client; +let channel; + +const Wrapper = ({ children }) => ( + + + + {children} + + + +); + +const renderElements = (children) => render({children}); + +function renderVMLComponentsHook(params = {}) { + const { result } = renderHook(() => useVirtualizedMessageListComponents(params), { + wrapper: Wrapper, + }); + return result.current; +} + +describe('useVirtualizedMessageListComponents', () => { + beforeAll(async () => { + client = await getTestClientWithUser(); + const channelData = generateChannel(); + channel = client.channel(channelData.channel.type, channelData.channel.id, channelData); + }); + + it('should allow to execute custom item rendering logic instead of the default', () => { + const customMessageRenderer = jest.fn(); + const virtuosoIndex = 1; + const virtuosoContext = { + numItemsPrepended: 0, + processedMessages: [generateMessage()], + }; + const { messageRenderer } = renderVMLComponentsHook({ customMessageRenderer, prependOffset }); + messageRenderer(virtuosoIndex, undefined, virtuosoContext); + expect(customMessageRenderer).toHaveBeenCalledWith( + expect.arrayContaining(virtuosoContext.processedMessages), + 1, + ); + }); + describe('default item rendering logic', () => { + it('should render MessageSystem component for system messages', () => { + const virtuosoIndex = 0; + const virtuosoContext = { + numItemsPrepended: 0, + processedMessages: [generateMessage({ type: 'system' })], + }; + const { messageRenderer } = renderVMLComponentsHook({ prependOffset }); + render(messageRenderer(virtuosoIndex, undefined, virtuosoContext)); + expect(screen.getByTestId('message-system')).toBeInTheDocument(); + }); + + it('should render DateSeparator component for custom message type date', () => { + const virtuosoIndex = 0; + const virtuosoContext = { + numItemsPrepended: 0, + processedMessages: [generateMessage({ customType: 'message.date', date: new Date() })], + }; + const { messageRenderer } = renderVMLComponentsHook({ prependOffset }); + render(messageRenderer(virtuosoIndex, undefined, virtuosoContext)); + expect(screen.getByTestId('date-separator')).toBeInTheDocument(); + }); + + it('should render empty div when trying to render message at non-existent index', () => { + const virtuosoIndex = 1; + const virtuosoContext = { + numItemsPrepended: 0, + processedMessages: [generateMessage({ customType: 'message.date', date: new Date() })], + }; + const { messageRenderer } = renderVMLComponentsHook({ prependOffset }); + const { container } = render(messageRenderer(virtuosoIndex, undefined, virtuosoContext)); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); + }); + + it.each([ + ['not ', 'by default', 'not ', false], + ['', '', '', true], + ])( + 'should %sgroup messages %s and mark the first and the last group message', + (_, __, ___, shouldGroupByUser) => { + const user1MessageGroup = [ + generateMessage({ user: user1 }), + generateMessage({ user: user1 }), + generateMessage({ user: user1 }), + generateMessage({ user: user1 }), + ]; + const processedMessages = [ + generateMessage({ user: user2 }), + ...user1MessageGroup, + generateMessage({ user: user2 }), + ]; + + const renderers = processedMessages.map(() => { + const virtuosoRef = { current: {} }; + + const { messageRenderer } = renderVMLComponentsHook({ + prependOffset, + shouldGroupByUser, + virtuosoRef, + }); + return messageRenderer; + }); + + const virtuosoContext = { + numItemsPrepended: 0, + ownMessagesReadByOthers: {}, + processedMessages, + }; + const { container } = renderElements( + <> + {renderers.map((messageRenderer, virtuosoIndex) => ( +
+ {messageRenderer(virtuosoIndex, undefined, virtuosoContext)} +
+ ))} + , + ); + const messageElements = container.getElementsByClassName( + 'str-chat__message str-chat__message-simple', + ); + + const firstGroupItemClass = 'str-chat__virtual-message__wrapper--first'; + const lastGroupItemClass = 'str-chat__virtual-message__wrapper--end'; + expect( + container.getElementsByClassName('str-chat__virtual-message__wrapper--group'), + ).toHaveLength(shouldGroupByUser ? user1MessageGroup.length - 1 : 0); + + expect(container.getElementsByClassName(firstGroupItemClass)).toHaveLength( + shouldGroupByUser ? 3 : 0, + ); + expect(container.getElementsByClassName(lastGroupItemClass)).toHaveLength( + shouldGroupByUser ? 3 : 0, + ); + if (shouldGroupByUser) { + expect(messageElements[0]).toHaveClass(firstGroupItemClass); + expect(messageElements[0]).toHaveClass(lastGroupItemClass); + expect(messageElements[1]).toHaveClass(firstGroupItemClass); + expect(messageElements[processedMessages.length - 2]).toHaveClass(lastGroupItemClass); + expect(messageElements[processedMessages.length - 1]).toHaveClass(firstGroupItemClass); + expect(messageElements[processedMessages.length - 1]).toHaveClass(lastGroupItemClass); + } + }, + ); + }); + + describe('default virtuoso components', () => { + it('should render EmptyPlaceholder', () => { + const { + virtuosoComponents: { EmptyPlaceholder }, + } = renderVMLComponentsHook({ prependOffset }); + const { container } = renderElements(); + + expect(container).toMatchInlineSnapshot(` +
+
+ + + +

+ No chats here yet… +

+
+
+ `); + }); + + it('should render empty div in Footer', () => { + const { + virtuosoComponents: { Footer }, + } = renderVMLComponentsHook({ prependOffset }); + const { container } = renderElements(