UNPKG

stream-chat-react

Version:

React components to create chat conversations or livestream style chat

272 lines (271 loc) 16.7 kB
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Virtuoso } from 'react-virtuoso'; import { GiphyPreviewMessage as DefaultGiphyPreviewMessage } from './GiphyPreviewMessage'; import { useLastReadData } from './hooks'; import { useGiphyPreview, useMessageSetKey, useNewMessageNotification, usePrependedMessagesCount, useScrollToBottomOnNewMessage, useShouldForceScrollToBottom, useUnreadMessagesNotificationVirtualized, } from './hooks/VirtualizedMessageList'; import { useMarkRead } from './hooks/useMarkRead'; import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import { getGroupStyles, getLastReceived, processMessages } from './utils'; import { MessageSimple } from '../Message'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import { calculateFirstItemIndex, calculateItemIndex, EmptyPlaceholder, Header, Item, makeItemsRenderedHandler, messageRenderer, } from './VirtualizedMessageListComponents'; import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from '../MessageList'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; import { DialogManagerProvider } from '../../context'; import { useChannelActionContext } from '../../context/ChannelActionContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { VirtualizedMessageListContextProvider } from '../../context/VirtualizedMessageListContext'; import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE } from '../../constants/limits'; import { useStableId } from '../UtilityComponents/useStableId'; import { useLastDeliveredData } from './hooks/useLastDeliveredData'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; function captureResizeObserverExceededError(e) { if (e.message === 'ResizeObserver loop completed with undelivered notifications.' || e.message === 'ResizeObserver loop limit exceeded') { e.stopImmediatePropagation(); } } function useCaptureResizeObserverExceededError() { useEffect(() => { window.addEventListener('error', captureResizeObserverExceededError); return () => { window.removeEventListener('error', captureResizeObserverExceededError); }; }, []); } function fractionalItemSize(element) { return element.getBoundingClientRect().height; } function findMessageIndex(messages, id) { return messages.findIndex((message) => message.id === id); } function calculateInitialTopMostItemIndex(messages, highlightedMessageId) { if (highlightedMessageId) { const index = findMessageIndex(messages, highlightedMessageId); if (index !== -1) { return { align: 'center', index }; } } return messages.length - 1; } const VirtualizedMessageListWithContext = (props) => { const { additionalMessageInputProps, additionalVirtuosoProps = {}, channel, channelUnreadUiState, closeReactionSelectorOnClick, customMessageActions, customMessageRenderer, defaultItemHeight, disableDateSeparator = true, formatDate, groupStyles, hasMoreNewer, head, hideDeletedMessages = false, hideNewMessageSeparator = false, highlightedMessageId, jumpToLatestMessage, loadingMore, loadMore, loadMoreNewer, maxTimeBetweenGroupedMessages, Message: MessageUIComponentFromProps, messageActions, messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, messages, notifications, openThread, // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component overscan = 0, reactionDetailsSort, renderText, returnAllReadData = false, reviewProcessedMessage, scrollSeekPlaceHolder, scrollToLatestMessageOnFocus = false, separateGiphyPreview = false, shouldGroupByUser = false, showUnreadNotificationAlways, sortReactionDetails, sortReactions, stickToBottomScrollBehavior = 'smooth', suppressAutoscroll, 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, GiphyPreviewMessage = DefaultGiphyPreviewMessage, MessageListMainPanel = DefaultMessageListMainPanel, MessageListNotifications = DefaultMessageListNotifications, MessageNotification = DefaultMessageNotification, MessageSystem = DefaultMessageSystem, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, VirtualMessage: MessageUIComponentFromContext = MessageSimple, } = useComponentContext('VirtualizedMessageList'); const MessageUIComponent = MessageUIComponentFromProps || MessageUIComponentFromContext; const { client, customClasses } = useChatContext('VirtualizedMessageList'); const virtuoso = useRef(null); const lastRead = useMemo(() => channel.lastRead?.(), [channel]); const { show: showUnreadMessagesNotification, toggleShowUnreadMessagesNotification } = useUnreadMessagesNotificationVirtualized({ lastRead: channelUnreadUiState?.last_read, showAlways: !!showUnreadNotificationAlways, unreadCount: channelUnreadUiState?.unread_messages ?? 0, }); const { giphyPreviewMessage, setGiphyPreviewMessage } = useGiphyPreview(separateGiphyPreview); const processedMessages = useMemo(() => { if (typeof messages === 'undefined') { return []; } if (disableDateSeparator && !hideDeletedMessages && hideNewMessageSeparator && !separateGiphyPreview) { return messages; } return processMessages({ enableDateSeparator: !disableDateSeparator, hideDeletedMessages, hideNewMessageSeparator, lastRead, messages, reviewProcessedMessage, setGiphyPreviewMessage, userId: client.userID || '', }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ disableDateSeparator, hideDeletedMessages, hideNewMessageSeparator, lastRead, messages, messages?.length, client.userID, ]); const lastOwnMessage = useLastOwnMessage({ messages, ownUserId: client.user?.id }); // get the mapping of own messages to array of users who read them const ownMessagesReadByOthers = useLastReadData({ channel, lastOwnMessage, messages: messages || [], returnAllReadData, }); const ownMessagesDeliveredToOthers = useLastDeliveredData({ channel, lastOwnMessage, messages: messages || [], returnAllReadData, }); const lastReceivedMessageId = useMemo(() => getLastReceived(processedMessages), [processedMessages]); const groupStylesFn = groupStyles || getGroupStyles; const messageGroupStyles = useMemo(() => processedMessages.reduce((acc, message, i) => { const style = groupStylesFn(message, processedMessages[i - 1], processedMessages[i + 1], !shouldGroupByUser, maxTimeBetweenGroupedMessages); if (style && message.id) acc[message.id] = style; return acc; }, {}), // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage // eslint-disable-next-line react-hooks/exhaustive-deps [ maxTimeBetweenGroupedMessages, processedMessages.length, shouldGroupByUser, groupStylesFn, ]); const { atBottom, isMessageListScrolledToBottom, newMessagesNotification, setIsMessageListScrolledToBottom, setNewMessagesNotification, } = useNewMessageNotification(processedMessages, client.userID, hasMoreNewer); useMarkRead({ isMessageListScrolledToBottom, messageListIsThread: !!threadList, wasMarkedUnread: !!channelUnreadUiState?.first_unread_message_id, }); const scrollToBottom = useCallback(async () => { if (hasMoreNewer) { await jumpToLatestMessage(); return; } if (virtuoso.current) { virtuoso.current.scrollToIndex(processedMessages.length - 1); } setNewMessagesNotification(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ virtuoso, processedMessages, setNewMessagesNotification, // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage processedMessages.length, hasMoreNewer, jumpToLatestMessage, ]); useScrollToBottomOnNewMessage({ messages, scrollToBottom, scrollToLatestMessageOnFocus, }); const numItemsPrepended = usePrependedMessagesCount(processedMessages, !disableDateSeparator); const { messageSetKey } = useMessageSetKey({ messages }); const shouldForceScrollToBottom = useShouldForceScrollToBottom(processedMessages, client.userID); const handleItemsRendered = useMemo(() => makeItemsRenderedHandler([toggleShowUnreadMessagesNotification], processedMessages), [processedMessages, toggleShowUnreadMessagesNotification]); const followOutput = (isAtBottom) => { if (hasMoreNewer || suppressAutoscroll) { return false; } if (shouldForceScrollToBottom()) { return isAtBottom ? stickToBottomScrollBehavior : 'auto'; } // a message from another user has been received - don't scroll to bottom unless already there return isAtBottom ? stickToBottomScrollBehavior : false; }; const computeItemKey = useCallback((index, _, { numItemsPrepended, processedMessages }) => processedMessages[calculateItemIndex(index, numItemsPrepended)].id, []); const atBottomStateChange = (isAtBottom) => { atBottom.current = isAtBottom; setIsMessageListScrolledToBottom(isAtBottom); if (isAtBottom) { loadMoreNewer?.(messageLimit); setNewMessagesNotification?.(false); } }; const atTopStateChange = (isAtTop) => { if (isAtTop) { loadMore?.(messageLimit); } }; useEffect(() => { let scrollTimeout; if (highlightedMessageId) { const index = findMessageIndex(processedMessages, highlightedMessageId); if (index !== -1) { scrollTimeout = setTimeout(() => { virtuoso.current?.scrollToIndex({ align: 'center', index }); }, 0); } } return () => { clearTimeout(scrollTimeout); }; }, [highlightedMessageId, processedMessages]); const id = useStableId(); if (!processedMessages) return null; const dialogManagerId = threadList ? `virtualized-message-list-dialog-manager-thread-${id}` : `virtualized-message-list-dialog-manager-${id}`; return (React.createElement(VirtualizedMessageListContextProvider, { value: { scrollToBottom } }, React.createElement(MessageListMainPanel, null, React.createElement(DialogManagerProvider, { id: dialogManagerId }, !threadList && showUnreadMessagesNotification && (React.createElement(UnreadMessagesNotification, { unreadCount: channelUnreadUiState?.unread_messages })), React.createElement("div", { className: customClasses?.virtualizedMessageList || 'str-chat__virtual-list' }, React.createElement(Virtuoso, { atBottomStateChange: atBottomStateChange, atBottomThreshold: 100, atTopStateChange: atTopStateChange, atTopThreshold: 100, className: 'str-chat__message-list-scroll', components: { EmptyPlaceholder, Header, Item, ...virtuosoComponentsFromProps, }, computeItemKey: computeItemKey, context: { additionalMessageInputProps, closeReactionSelectorOnClick, customClasses, customMessageActions, customMessageRenderer, DateSeparator, firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, formatDate, head, lastOwnMessage, lastReadDate: channelUnreadUiState?.last_read, lastReadMessageId: channelUnreadUiState?.last_read_message_id, lastReceivedMessageId, loadingMore, Message: MessageUIComponent, messageActions, messageGroupStyles, MessageSystem, numItemsPrepended, openThread, ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages, reactionDetailsSort, renderText, returnAllReadData, shouldGroupByUser, sortReactionDetails, sortReactions, threadList, unreadMessageCount: channelUnreadUiState?.unread_messages, UnreadMessagesSeparator, virtuosoRef: virtuoso, }, firstItemIndex: calculateFirstItemIndex(numItemsPrepended), followOutput: followOutput, increaseViewportBy: { bottom: 200, top: 0 }, initialTopMostItemIndex: calculateInitialTopMostItemIndex(processedMessages, highlightedMessageId), itemContent: messageRenderer, itemSize: fractionalItemSize, itemsRendered: handleItemsRendered, key: messageSetKey, overscan: overscan, ref: virtuoso, style: { overflowX: 'hidden' }, totalCount: processedMessages.length, ...overridingVirtuosoProps, ...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {}), ...(defaultItemHeight ? { defaultItemHeight } : {}) }))), TypingIndicator && React.createElement(TypingIndicator, null)), React.createElement(MessageListNotifications, { hasNewMessages: newMessagesNotification, isMessageListScrolledToBottom: isMessageListScrolledToBottom, isNotAtLatestMessageSet: hasMoreNewer, MessageNotification: MessageNotification, notifications: notifications, scrollToBottom: scrollToBottom, threadList: threadList, unreadCount: threadList ? undefined : channelUnreadUiState?.unread_messages }), giphyPreviewMessage && React.createElement(GiphyPreviewMessage, { message: giphyPreviewMessage }))); }; /** * The VirtualizedMessageList component renders a list of messages in a virtualized list. * It is a consumer of the React contexts set in [Channel](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Channel/Channel.tsx). */ export function VirtualizedMessageList(props) { const { jumpToLatestMessage, loadMore, loadMoreNewer } = useChannelActionContext('VirtualizedMessageList'); const { channel, channelUnreadUiState, hasMore, hasMoreNewer, highlightedMessageId, loadingMore, loadingMoreNewer, messages: contextMessages, notifications, read, suppressAutoscroll, } = useChannelStateContext('VirtualizedMessageList'); const messages = props.messages || contextMessages; return (React.createElement(VirtualizedMessageListWithContext, { channel: channel, channelUnreadUiState: props.channelUnreadUiState ?? channelUnreadUiState, hasMore: !!hasMore, hasMoreNewer: !!hasMoreNewer, highlightedMessageId: highlightedMessageId, jumpToLatestMessage: jumpToLatestMessage, loadingMore: !!loadingMore, loadingMoreNewer: !!loadingMoreNewer, loadMore: loadMore, loadMoreNewer: loadMoreNewer, messages: messages, notifications: notifications, read: read, suppressAutoscroll: suppressAutoscroll, ...props })); }