stream-chat-react
Version:
React components to create chat conversations or livestream style chat
272 lines (271 loc) • 16.7 kB
JavaScript
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 }));
}