import throttle from 'lodash/throttle'; import React, { RefObject } from 'react'; import { scrollIntoView } from 'seamless-scroll-polyfill'; import { MESSAGE_TYPE_REMOTE } from '../../constants'; import AbstractMessageContainer, { IProps } from '../AbstractMessageContainer'; // @ts-ignore import ChatMessageGroup from './ChatMessageGroup'; import NewMessagesButton from './NewMessagesButton'; interface IState { /** * Whether or not message container has received new messages. */ hasNewMessages: boolean; /** * Whether or not scroll position is at the bottom of container. */ isScrolledToBottom: boolean; /** * The id of the last read message. */ lastReadMessageId: string; } /** * Displays all received chat messages, grouped by sender. * * @augments AbstractMessageContainer */ export default class MessageContainer extends AbstractMessageContainer { /** * Component state used to decide when the hasNewMessages button to appear * and where to scroll when click on hasNewMessages button. */ state: IState = { hasNewMessages: false, isScrolledToBottom: true, lastReadMessageId: '' }; /** * Reference to the HTML element at the end of the list of displayed chat * messages. Used for scrolling to the end of the chat messages. */ _messagesListEndRef: RefObject; /** * A React ref to the HTML element containing all {@code ChatMessageGroup} * instances. */ _messageListRef: RefObject; /** * Intersection observer used to detect intersections of messages with the bottom of the message container. */ _bottomListObserver: IntersectionObserver; /** * Initializes a new {@code MessageContainer} instance. * * @param {IProps} props - The React {@code Component} props to initialize * the new {@code MessageContainer} instance with. */ constructor(props: IProps) { super(props); this._messageListRef = React.createRef(); this._messagesListEndRef = React.createRef(); // Bind event handlers so they are only bound once for every instance. this._handleIntersectBottomList = this._handleIntersectBottomList.bind(this); this._findFirstUnreadMessage = this._findFirstUnreadMessage.bind(this); this._isMessageVisible = this._isMessageVisible.bind(this); this._onChatScroll = throttle(this._onChatScroll.bind(this), 300, { leading: true }); this._onGoToFirstUnreadMessage = this._onGoToFirstUnreadMessage.bind(this); } /** * Implements {@code Component#render}. * * @inheritdoc */ render() { const groupedMessages = this._getMessagesGroupedBySender(); const messages = groupedMessages.map((group, index) => { const messageType = group[0]?.messageType; return ( ); }); return (
{ messages } { !this.state.isScrolledToBottom && this.state.hasNewMessages && }
); } /** * Implements {@code Component#componentDidMount}. * When Component mount scroll message container to bottom. * Create observer to react when scroll position is at bottom or leave the bottom. * * @inheritdoc */ componentDidMount() { this.scrollToElement(false, null); this._createBottomListObserver(); } /** * Implements {@code Component#componentDidUpdate}. * If the user receive a new message scroll automatically to the bottom if scroll position was at the bottom. * Otherwise update hasNewMessages from component state. * * @inheritdoc * @returns {void} */ componentDidUpdate(prevProps: IProps) { const hasNewMessages = this.props.messages.length !== prevProps.messages.length; if (hasNewMessages) { if (this.state.isScrolledToBottom) { this.scrollToElement(false, null); } else { // eslint-disable-next-line react/no-did-update-set-state this.setState({ hasNewMessages: true }); } } } /** * Implements React's {@link Component#componentWillUnmount()}. Invoked * immediately before this component is unmounted and destroyed. * * @inheritdoc */ componentWillUnmount() { const target = document.querySelector('#messagesListEnd'); this._bottomListObserver.unobserve(target as Element); } /** * Automatically scrolls the displayed chat messages to bottom or to a specific element if it is provided. * * @param {boolean} withAnimation - Whether or not to show a scrolling. * @param {TMLElement} element - Where to scroll. * Animation. * @returns {void} */ scrollToElement(withAnimation: boolean, element: Element | null) { const scrollTo = element ? element : this._messagesListEndRef.current; const block = element ? 'center' : 'nearest'; scrollIntoView(scrollTo as Element, { behavior: withAnimation ? 'smooth' : 'auto', block }); } /** * Callback invoked to listen to current scroll position and update next unread message. * The callback is invoked inside a throttle with 300 ms to decrease the number of function calls. * * @private * @returns {void} */ _onChatScroll() { const firstUnreadMessage = this._findFirstUnreadMessage(); if (firstUnreadMessage && firstUnreadMessage.id !== this.state.lastReadMessageId) { this.setState({ lastReadMessageId: firstUnreadMessage?.id }); } } /** * Find the first unread message. * Update component state and scroll to element. * * @private * @returns {void} */ _onGoToFirstUnreadMessage() { const firstUnreadMessage = this._findFirstUnreadMessage(); this.setState({ lastReadMessageId: firstUnreadMessage?.id || null }); this.scrollToElement(true, firstUnreadMessage as Element); } /** * Create observer to react when scroll position is at bottom or leave the bottom. * * @private * @returns {void} */ _createBottomListObserver() { const options = { root: document.querySelector('#chatconversation'), rootMargin: '35px', threshold: 0.5 }; const target = document.querySelector('#messagesListEnd'); if (target) { this._bottomListObserver = new IntersectionObserver(this._handleIntersectBottomList, options); this._bottomListObserver.observe(target); } } /** . * _HandleIntersectBottomList. * When entry is intersecting with bottom of container set last message as last read message. * When entry is not intersecting update only isScrolledToBottom with false value. * * @param {Array} entries - List of entries. * @private * @returns {void} */ _handleIntersectBottomList(entries: IntersectionObserverEntry[]) { entries.forEach((entry: IntersectionObserverEntry) => { if (entry.isIntersecting && this.props.messages.length) { const lastMessageIndex = this.props.messages.length - 1; const lastMessage = this.props.messages[lastMessageIndex]; const lastReadMessageId = lastMessage.messageId; this.setState( { isScrolledToBottom: true, hasNewMessages: false, lastReadMessageId }); } if (!entry.isIntersecting) { this.setState( { isScrolledToBottom: false }); } }); } /** * Find first unread message. * MessageIsAfterLastSeenMessage filter elements which are not visible but are before the last read message. * * @private * @returns {Element} */ _findFirstUnreadMessage() { const messagesNodeList = document.querySelectorAll('.chatmessage-wrapper'); // @ts-ignore const messagesToArray = [ ...messagesNodeList ]; const previousIndex = messagesToArray.findIndex((message: Element) => message.id === this.state.lastReadMessageId); if (previousIndex !== -1) { for (let i = previousIndex; i < messagesToArray.length; i++) { if (!this._isMessageVisible(messagesToArray[i])) { return messagesToArray[i]; } } } } /** * Check if a message is visible in view. * * @param {Element} message - * * @returns {boolean} */ _isMessageVisible(message: Element): boolean { const { bottom, height, top } = message.getBoundingClientRect(); if (this._messageListRef.current) { const containerRect = this._messageListRef.current.getBoundingClientRect(); return top <= containerRect.top ? containerRect.top - top <= height : bottom - containerRect.bottom <= height; } return false; } }