Compare commits

...

1 Commits

Author SHA1 Message Date
Robert Pintilii
dce8120162 ref(chat) Refactor MessageContainer component
Remove Abstract component
Convert web component to function component
2023-07-18 11:53:36 +03:00
4 changed files with 166 additions and 255 deletions

View File

@@ -1,53 +0,0 @@
import { Component } from 'react';
import { IMessage } from '../types';
export interface IProps {
/**
* The messages array to render.
*/
messages: IMessage[];
}
/**
* Abstract component to display a list of chat messages, grouped by sender.
*
* @augments PureComponent
*/
export default class AbstractMessageContainer<P extends IProps, S> extends Component<P, S> {
static defaultProps = {
messages: [] as IMessage[]
};
/**
* Iterates over all the messages and creates nested arrays which hold
* consecutive messages sent by the same participant.
*
* @private
* @returns {Array<Array<Object>>}
*/
_getMessagesGroupedBySender() {
const messagesCount = this.props.messages.length;
const groups: IMessage[][] = [];
let currentGrouping: IMessage[] = [];
let currentGroupParticipantId;
for (let i = 0; i < messagesCount; i++) {
const message = this.props.messages[i];
if (message.id === currentGroupParticipantId) {
currentGrouping.push(message);
} else {
currentGrouping.length && groups.push(currentGrouping);
currentGrouping = [ message ];
currentGroupParticipantId = message.id;
}
}
currentGrouping.length && groups.push(currentGrouping);
return groups;
}
}

View File

@@ -1,26 +1,26 @@
import React from 'react';
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { FlatList, Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { getMessagesGroupedBySender } from '../../functions';
import { IMessage } from '../../types';
import AbstractMessageContainer, { IProps as AbstractProps } from '../AbstractMessageContainer';
import ChatMessageGroup from './ChatMessageGroup';
import styles from './styles';
interface IProps extends AbstractProps {
interface IProps extends WithTranslation {
/**
* Function to be used to translate i18n labels.
* The messages array to render.
*/
t: Function;
messages: IMessage[];
}
/**
* Implements a container to render all the chat messages in a conference.
*/
class MessageContainer extends AbstractMessageContainer<IProps, any> {
class MessageContainer extends Component<IProps> {
/**
* Instantiates a new instance of the component.
*
@@ -40,7 +40,7 @@ class MessageContainer extends AbstractMessageContainer<IProps, any> {
* @inheritdoc
*/
render() {
const data = this._getMessagesGroupedBySender();
const data = getMessagesGroupedBySender(this.props.messages);
return (
<FlatList
@@ -97,4 +97,4 @@ class MessageContainer extends AbstractMessageContainer<IProps, any> {
}
}
export default translate(connect()(MessageContainer));
export default translate(MessageContainer);

View File

@@ -1,169 +1,74 @@
import throttle from 'lodash/throttle';
import React, { RefObject } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { scrollIntoView } from 'seamless-scroll-polyfill';
import { MESSAGE_TYPE_REMOTE } from '../../constants';
import AbstractMessageContainer, { IProps } from '../AbstractMessageContainer';
import { getMessagesGroupedBySender } from '../../functions';
import { IMessage } from '../../types';
import ChatMessageGroup from './ChatMessageGroup';
import NewMessagesButton from './NewMessagesButton';
interface IState {
interface IProps {
/**
* Whether or not message container has received new messages.
* The messages array to render.
*/
hasNewMessages: boolean;
/**
* Whether or not scroll position is at the bottom of container.
*/
isScrolledToBottom: boolean;
/**
* The id of the last read message.
*/
lastReadMessageId: string;
messages: IMessage[];
}
/**
* Displays all received chat messages, grouped by sender.
*
* @augments AbstractMessageContainer
* @param {IProps} props - Component's props.
* @returns {JSX}
*/
export default class MessageContainer extends AbstractMessageContainer<IProps, IState> {
/**
* 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: ''
};
const MessageContainer = (props: IProps) => {
const [ hasNewMessages, setHasNewMessages ] = useState(false);
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(false);
const [ lastReadMessageId, setLastReadMessageId ] = useState('');
const prevMessages = useRef(props.messages);
/**
* 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<HTMLDivElement>;
const messagesListEndRef = useRef<HTMLDivElement>(null);
/**
* A React ref to the HTML element containing all {@code ChatMessageGroup}
* instances.
*/
_messageListRef: RefObject<HTMLDivElement>;
const messageListRef = useRef<HTMLDivElement>(null);
/**
* Intersection observer used to detect intersections of messages with the bottom of the message container.
*/
_bottomListObserver: IntersectionObserver;
* Intersection observer used to detect intersections of messages with the bottom of the message container.
*/
const bottomListObserver = useRef<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);
useEffect(() => {
scrollToElement(false, null);
createBottomListObserver();
this._messageListRef = React.createRef<HTMLDivElement>();
this._messagesListEndRef = React.createRef<HTMLDivElement>();
return () => {
const target = document.querySelector('#messagesListEnd');
// 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);
}
bottomListObserver.current?.unobserve(target as Element);
};
}, []);
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const groupedMessages = this._getMessagesGroupedBySender();
const messages = groupedMessages.map((group, index) => {
const messageType = group[0]?.messageType;
useEffect(() => {
const newMessages = props.messages.length !== prevMessages.current.length;
return (
<ChatMessageGroup
className = { messageType || MESSAGE_TYPE_REMOTE }
key = { index }
messages = { group } />
);
});
return (
<div id = 'chat-conversation-container'>
<div
aria-labelledby = 'chat-header'
id = 'chatconversation'
onScroll = { this._onChatScroll }
ref = { this._messageListRef }
role = 'log'
tabIndex = { 0 }>
{ messages }
{ !this.state.isScrolledToBottom && this.state.hasNewMessages
&& <NewMessagesButton
onGoToFirstUnreadMessage = { this._onGoToFirstUnreadMessage } /> }
<div
id = 'messagesListEnd'
ref = { this._messagesListEndRef } />
</div>
</div>
);
}
/**
* 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);
if (newMessages) {
if (isScrolledToBottom) {
scrollToElement(false, null);
} else {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ hasNewMessages: true });
setHasNewMessages(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);
}
prevMessages.current = props.messages;
}, [ props.messages, isScrolledToBottom ]);
/**
* Automatically scrolls the displayed chat messages to bottom or to a specific element if it is provided.
@@ -173,8 +78,8 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
* Animation.
* @returns {void}
*/
scrollToElement(withAnimation: boolean, element: Element | null) {
const scrollTo = element ? element : this._messagesListEndRef.current;
function scrollToElement(withAnimation: boolean, element: Element | null) {
const scrollTo = element ? element : messagesListEndRef.current;
const block = element ? 'center' : 'nearest';
scrollIntoView(scrollTo as Element, {
@@ -183,43 +88,63 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
});
}
/**
* Find first unread message.
* MessageIsAfterLastSeenMessage filter elements which are not visible but are before the last read message.
*
* @returns {Element}
*/
const findFirstUnreadMessage = useCallback(() => {
const messagesNodeList = document.querySelectorAll('.chatmessage-wrapper');
// @ts-ignore
const messagesToArray = [ ...messagesNodeList ];
const previousIndex = messagesToArray.findIndex((message: Element) =>
message.id === lastReadMessageId);
if (previousIndex !== -1) {
for (let i = previousIndex; i < messagesToArray.length; i++) {
if (!isMessageVisible(messagesToArray[i])) {
return messagesToArray[i];
}
}
}
}, [ lastReadMessageId ]);
/**
* 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();
const onChatScroll = useCallback(() => {
const firstUnreadMessage = findFirstUnreadMessage();
if (firstUnreadMessage && firstUnreadMessage.id !== this.state.lastReadMessageId) {
this.setState({ lastReadMessageId: firstUnreadMessage?.id });
if (firstUnreadMessage && firstUnreadMessage.id !== lastReadMessageId) {
setLastReadMessageId(firstUnreadMessage?.id);
}
}
}, [ findFirstUnreadMessage ]);
/**
* Find the first unread message.
* Update component state and scroll to element.
*
* @private
* @returns {void}
*/
_onGoToFirstUnreadMessage() {
const firstUnreadMessage = this._findFirstUnreadMessage();
const onGoToFirstUnreadMessage = useCallback(() => {
const firstUnreadMessage = findFirstUnreadMessage();
this.setState({ lastReadMessageId: firstUnreadMessage?.id || null });
this.scrollToElement(true, firstUnreadMessage as Element);
}
setLastReadMessageId(firstUnreadMessage?.id || null);
scrollToElement(true, firstUnreadMessage as Element);
}, [ findFirstUnreadMessage ]);
/**
* Create observer to react when scroll position is at bottom or leave the bottom.
*
* @private
* @returns {void}
*/
_createBottomListObserver() {
function createBottomListObserver() {
const options = {
root: document.querySelector('#chatconversation'),
rootMargin: '35px',
@@ -229,8 +154,8 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
const target = document.querySelector('#messagesListEnd');
if (target) {
this._bottomListObserver = new IntersectionObserver(this._handleIntersectBottomList, options);
this._bottomListObserver.observe(target);
bottomListObserver.current = new IntersectionObserver(handleIntersectBottomList, options);
bottomListObserver.current.observe(target);
}
}
@@ -240,70 +165,37 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
* When entry is not intersecting update only isScrolledToBottom with false value.
*
* @param {Array} entries - List of entries.
* @private
* @returns {void}
*/
_handleIntersectBottomList(entries: IntersectionObserverEntry[]) {
function 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;
if (entry.isIntersecting && props.messages.length) {
const lastMessageIndex = props.messages.length - 1;
const lastMessage = props.messages[lastMessageIndex];
const lastReadMessage = lastMessage.messageId;
this.setState(
{
isScrolledToBottom: true,
hasNewMessages: false,
lastReadMessageId
});
setIsScrolledToBottom(true);
setHasNewMessages(false);
setLastReadMessageId(lastReadMessage);
}
if (!entry.isIntersecting) {
this.setState(
{
isScrolledToBottom: false
});
setIsScrolledToBottom(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 {
function isMessageVisible(message: Element): boolean {
const { bottom, height, top } = message.getBoundingClientRect();
if (this._messageListRef.current) {
const containerRect = this._messageListRef.current.getBoundingClientRect();
if (messageListRef.current) {
const containerRect = messageListRef.current.getBoundingClientRect();
return top <= containerRect.top
? containerRect.top - top <= height : bottom - containerRect.bottom <= height;
@@ -311,4 +203,45 @@ export default class MessageContainer extends AbstractMessageContainer<IProps, I
return false;
}
}
const _onChatScroll = useRef(throttle(onChatScroll, 300, { leading: true }));
useEffect(() => {
_onChatScroll.current = throttle(onChatScroll, 300, { leading: true });
}, [ onChatScroll ]);
const groupedMessages = useMemo(() => getMessagesGroupedBySender(props.messages), [ props.messages ]);
return (
<div id = 'chat-conversation-container'>
<div
aria-labelledby = 'chat-header'
id = 'chatconversation'
onScroll = { _onChatScroll.current }
ref = { messageListRef }
role = 'log'
tabIndex = { 0 }>
{groupedMessages.map((group, index) => {
const messageType = group[0]?.messageType;
return (
<ChatMessageGroup
className = { messageType || MESSAGE_TYPE_REMOTE }
key = { index }
messages = { group } />
);
})}
{!isScrolledToBottom && hasNewMessages
&& <NewMessagesButton
onGoToFirstUnreadMessage = { onGoToFirstUnreadMessage } />}
<div
id = 'messagesListEnd'
ref = { messagesListEndRef } />
</div>
</div>
);
};
export default MessageContainer;

View File

@@ -172,3 +172,34 @@ export function getPrivateNoticeMessage(message: IMessage) {
recipient: message.messageType === MESSAGE_TYPE_LOCAL ? message.recipient : i18next.t('chat.you')
});
}
/**
* Iterates over all the messages and creates nested arrays which hold
* consecutive messages sent by the same participant.
*
* @param {IMessage[]} messages - The messages to group.
* @returns {Array<Array<Object>>}
*/
export function getMessagesGroupedBySender(messages: IMessage[]) {
const messagesCount = messages.length;
const groups: IMessage[][] = [];
let currentGrouping: IMessage[] = [];
let currentGroupParticipantId;
for (let i = 0; i < messagesCount; i++) {
const message = messages[i];
if (message.id === currentGroupParticipantId) {
currentGrouping.push(message);
} else {
currentGrouping.length && groups.push(currentGrouping);
currentGrouping = [ message ];
currentGroupParticipantId = message.id;
}
}
currentGrouping.length && groups.push(currentGrouping);
return groups;
}