Feat (chat) add new message badge (#11987)
Change scroll to bottom when receive a new message with: - scroll to bottom if scroll was at the bottom before getting a new message - keep the scroll in his original position when the scroll position was not at the bottom - scroll to bottom when open first time the chat
This commit is contained in:
parent
b9aeb19379
commit
be1752c162
|
@ -30,12 +30,18 @@
|
|||
height: calc(100% - 70px);
|
||||
}
|
||||
|
||||
#chat-conversation-container {
|
||||
// extract message input height
|
||||
height: calc(100% - 68px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#chatconversation {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
font-size: 10pt;
|
||||
// extract message input height
|
||||
height: calc(100% - 68px);
|
||||
height: 100%;
|
||||
line-height: 20px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
|
|
|
@ -97,6 +97,7 @@
|
|||
"messageAccessibleTitleMe": "me says:",
|
||||
"messageTo": "Private message to {{recipient}}",
|
||||
"messagebox": "Type a message",
|
||||
"newMessages": "new messages",
|
||||
"nickname": {
|
||||
"popover": "Choose a nickname",
|
||||
"title": "Enter a nickname to use chat",
|
||||
|
|
|
@ -112,4 +112,4 @@ export const SET_IS_POLL_TAB_FOCUSED = 'SET_IS_POLL_TAB_FOCUSED';
|
|||
* type: REMOVE_LOBBY_CHAT_PARTICIPANT
|
||||
* }
|
||||
*/
|
||||
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';
|
||||
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
// @flow
|
||||
import { Component } from 'react';
|
||||
|
||||
import { PureComponent } from 'react';
|
||||
import { IMessage } from '../reducer';
|
||||
|
||||
export type Props = {
|
||||
export interface Props {
|
||||
|
||||
/**
|
||||
* The messages array to render.
|
||||
*/
|
||||
messages: Array<Object>
|
||||
messages: IMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -15,9 +15,9 @@ export type Props = {
|
|||
*
|
||||
* @augments PureComponent
|
||||
*/
|
||||
export default class AbstractMessageContainer<P: Props> extends PureComponent<P> {
|
||||
export default class AbstractMessageContainer<P extends Props, S> extends Component<P, S> {
|
||||
static defaultProps = {
|
||||
messages: []
|
||||
messages: [] as IMessage[]
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -29,8 +29,8 @@ export default class AbstractMessageContainer<P: Props> extends PureComponent<P>
|
|||
*/
|
||||
_getMessagesGroupedBySender() {
|
||||
const messagesCount = this.props.messages.length;
|
||||
const groups = [];
|
||||
let currentGrouping = [];
|
||||
const groups: IMessage[][] = [];
|
||||
let currentGrouping: IMessage[] = [];
|
||||
let currentGroupParticipantId;
|
||||
|
||||
for (let i = 0; i < messagesCount; i++) {
|
|
@ -44,34 +44,11 @@ class Chat extends AbstractChat<Props> {
|
|||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onChatTabKeyDown = this._onChatTabKeyDown.bind(this);
|
||||
this._onChatInputResize = this._onChatInputResize.bind(this);
|
||||
this._onEscClick = this._onEscClick.bind(this);
|
||||
this._onPollsTabKeyDown = this._onPollsTabKeyDown.bind(this);
|
||||
this._onToggleChat = this._onToggleChat.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidMount}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._scrollMessageContainerToBottom(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props._messages !== prevProps._messages) {
|
||||
this._scrollMessageContainerToBottom(true);
|
||||
} else if (this.props._isOpen && !prevProps._isOpen) {
|
||||
this._scrollMessageContainerToBottom(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
|
@ -98,19 +75,6 @@ class Chat extends AbstractChat<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
_onChatInputResize: () => void;
|
||||
|
||||
/**
|
||||
* Callback invoked when {@code ChatInput} changes height. Preserves
|
||||
* displaying the latest message if it is scrolled to.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChatInputResize() {
|
||||
this._messageContainerRef.current.maybeUpdateBottomScroll();
|
||||
}
|
||||
|
||||
_onChatTabKeyDown: (KeyboardEvent) => void;
|
||||
|
||||
/**
|
||||
|
@ -172,7 +136,7 @@ class Chat extends AbstractChat<Props> {
|
|||
if (_isPollsTabFocused) {
|
||||
return (
|
||||
<>
|
||||
{_isPollsEnabled && this._renderTabs()}
|
||||
{ _isPollsEnabled && this._renderTabs() }
|
||||
<div
|
||||
aria-labelledby = 'polls-tab'
|
||||
id = 'polls-panel'
|
||||
|
@ -186,16 +150,16 @@ class Chat extends AbstractChat<Props> {
|
|||
|
||||
return (
|
||||
<>
|
||||
{_isPollsEnabled && this._renderTabs()}
|
||||
{ _isPollsEnabled && this._renderTabs() }
|
||||
<div
|
||||
aria-labelledby = 'chat-tab'
|
||||
className = { clsx('chat-panel', !_isPollsEnabled && 'chat-panel-no-tabs') }
|
||||
id = 'chat-panel'
|
||||
role = 'tabpanel'>
|
||||
<MessageContainer
|
||||
messages = { this.props._messages }
|
||||
ref = { this._messageContainerRef } />
|
||||
messages = { this.props._messages } />
|
||||
<MessageRecipient />
|
||||
|
||||
<ChatInput
|
||||
onResize = { this._onChatInputResize }
|
||||
onSend = { this._onSendMessage } />
|
||||
|
@ -222,8 +186,7 @@ class Chat extends AbstractChat<Props> {
|
|||
aria-controls = 'chat-panel'
|
||||
aria-label = { t('chat.tabs.chat') }
|
||||
aria-selected = { !_isPollsTabFocused }
|
||||
className = { `chat-tab ${
|
||||
_isPollsTabFocused ? '' : 'chat-tab-focus'
|
||||
className = { `chat-tab ${_isPollsTabFocused ? '' : 'chat-tab-focus'
|
||||
}` }
|
||||
id = 'chat-tab'
|
||||
onClick = { this._onToggleChatTab }
|
||||
|
@ -232,21 +195,20 @@ class Chat extends AbstractChat<Props> {
|
|||
tabIndex = '0'>
|
||||
<span
|
||||
className = { 'chat-tab-title' }>
|
||||
{t('chat.tabs.chat')}
|
||||
{ t('chat.tabs.chat') }
|
||||
</span>
|
||||
{this.props._isPollsTabFocused
|
||||
{ this.props._isPollsTabFocused
|
||||
&& _nbUnreadMessages > 0 && (
|
||||
<span className = { 'chat-tab-badge' }>
|
||||
{_nbUnreadMessages}
|
||||
{ _nbUnreadMessages }
|
||||
</span>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
<div
|
||||
aria-controls = 'polls-panel'
|
||||
aria-label = { t('chat.tabs.polls') }
|
||||
aria-selected = { _isPollsTabFocused }
|
||||
className = { `chat-tab ${
|
||||
_isPollsTabFocused ? 'chat-tab-focus' : ''
|
||||
className = { `chat-tab ${_isPollsTabFocused ? 'chat-tab-focus' : ''
|
||||
}` }
|
||||
id = 'polls-tab'
|
||||
onClick = { this._onTogglePollsTab }
|
||||
|
@ -254,33 +216,19 @@ class Chat extends AbstractChat<Props> {
|
|||
role = 'tab'
|
||||
tabIndex = '0'>
|
||||
<span className = { 'chat-tab-title' }>
|
||||
{t('chat.tabs.polls')}
|
||||
{ t('chat.tabs.polls') }
|
||||
</span>
|
||||
{!_isPollsTabFocused
|
||||
{ !_isPollsTabFocused
|
||||
&& this.props._nbUnreadPolls > 0 && (
|
||||
<span className = { 'chat-tab-badge' }>
|
||||
{_nbUnreadPolls}
|
||||
{ _nbUnreadPolls }
|
||||
</span>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the chat messages so the latest message is visible.
|
||||
*
|
||||
* @param {boolean} withAnimation - Whether or not to show a scrolling
|
||||
* animation.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_scrollMessageContainerToBottom(withAnimation) {
|
||||
if (this._messageContainerRef.current) {
|
||||
this._messageContainerRef.current.scrollToBottom(withAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
_onSendMessage: (string) => void;
|
||||
|
||||
_onToggleChat: () => void;
|
||||
|
@ -295,7 +243,6 @@ class Chat extends AbstractChat<Props> {
|
|||
}
|
||||
_onTogglePollsTab: () => void;
|
||||
_onToggleChatTab: () => void;
|
||||
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(Chat));
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable lines-around-comment */
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, RefObject } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
|
@ -31,17 +31,10 @@ interface Props extends WithTranslation {
|
|||
*/
|
||||
dispatch: Dispatch<any>,
|
||||
|
||||
/**
|
||||
* Optional callback to invoke when the chat textarea has auto-resized to
|
||||
* fit overflowing text.
|
||||
*/
|
||||
onResize?: Function,
|
||||
|
||||
/**
|
||||
* Callback to invoke on message send.
|
||||
*/
|
||||
onSend: Function
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,7 +59,7 @@ type State = {
|
|||
* @augments Component
|
||||
*/
|
||||
class ChatInput extends Component<Props, State> {
|
||||
_textArea?: HTMLTextAreaElement;
|
||||
_textArea?: RefObject<HTMLTextAreaElement>;
|
||||
|
||||
state = {
|
||||
message: '',
|
||||
|
@ -82,7 +75,7 @@ class ChatInput extends Component<Props, State> {
|
|||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this._textArea = undefined;
|
||||
this._textArea = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onDetectSubmit = this._onDetectSubmit.bind(this);
|
||||
|
@ -90,7 +83,6 @@ class ChatInput extends Component<Props, State> {
|
|||
this._onSmileySelect = this._onSmileySelect.bind(this);
|
||||
this._onSubmitMessage = this._onSubmitMessage.bind(this);
|
||||
this._toggleSmileysPanel = this._toggleSmileysPanel.bind(this);
|
||||
this._setTextAreaRef = this._setTextAreaRef.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -101,7 +93,7 @@ class ChatInput extends Component<Props, State> {
|
|||
componentDidMount() {
|
||||
if (isMobileBrowser()) {
|
||||
// Ensure textarea is not focused when opening chat on mobile browser.
|
||||
this._textArea && this._textArea.blur();
|
||||
this._textArea?.current && this._textArea.current.blur();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -134,7 +126,7 @@ class ChatInput extends Component<Props, State> {
|
|||
onChange = { this._onMessageChange }
|
||||
onKeyPress = { this._onDetectSubmit }
|
||||
placeholder = { this.props.t('chat.messagebox') }
|
||||
ref = { this._setTextAreaRef }
|
||||
ref = { this._textArea }
|
||||
textarea = { true }
|
||||
value = { this.state.message } />
|
||||
<Button
|
||||
|
@ -155,7 +147,7 @@ class ChatInput extends Component<Props, State> {
|
|||
* @returns {void}
|
||||
*/
|
||||
_focus() {
|
||||
this._textArea && this._textArea.focus();
|
||||
this._textArea?.current && this._textArea.current.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -254,17 +246,6 @@ class ChatInput extends Component<Props, State> {
|
|||
}
|
||||
this.setState({ showSmileysPanel: !this.state.showSmileysPanel });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the reference to the HTML TextArea.
|
||||
*
|
||||
* @param {HTMLAudioElement} textAreaElement - The HTML text area element.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setTextAreaRef(textAreaElement?: HTMLTextAreaElement) {
|
||||
this._textArea = textAreaElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,6 +26,7 @@ class ChatMessage extends AbstractChatMessage<Props> {
|
|||
return (
|
||||
<div
|
||||
className = 'chatmessage-wrapper'
|
||||
id = { this.props.message.messageId }
|
||||
tabIndex = { -1 }>
|
||||
<div
|
||||
className = { `chatmessage ${message.privateMessage ? 'privatemessage' : ''} ${
|
||||
|
|
|
@ -44,15 +44,13 @@ class ChatMessageGroup extends Component<Props> {
|
|||
|
||||
return (
|
||||
<div className = { `chat-message-group ${className}` }>
|
||||
{
|
||||
messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key = { i }
|
||||
message = { message }
|
||||
showDisplayName = { i === 0 }
|
||||
showTimestamp = { i === messages.length - 1 } />
|
||||
))
|
||||
}
|
||||
{ messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key = { i }
|
||||
message = { message }
|
||||
showDisplayName = { i === 0 }
|
||||
showTimestamp = { i === messages.length - 1 } />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
import { scrollIntoView } from 'seamless-scroll-polyfill';
|
||||
|
||||
import { MESSAGE_TYPE_REMOTE } from '../../constants';
|
||||
import AbstractMessageContainer, { type Props }
|
||||
from '../AbstractMessageContainer';
|
||||
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
|
||||
/**
|
||||
* Displays all received chat messages, grouped by sender.
|
||||
*
|
||||
* @augments AbstractMessageContainer
|
||||
*/
|
||||
export default class MessageContainer extends AbstractMessageContainer<Props> {
|
||||
/**
|
||||
* Whether or not chat has been scrolled to the bottom of the screen. Used
|
||||
* to determine if chat should be scrolled automatically to the bottom when
|
||||
* the {@code ChatInput} resizes.
|
||||
*/
|
||||
_isScrolledToBottom: boolean;
|
||||
|
||||
/**
|
||||
* 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: Object;
|
||||
|
||||
/**
|
||||
* A React ref to the HTML element containing all {@code ChatMessageGroup}
|
||||
* instances.
|
||||
*/
|
||||
_messageListRef: Object;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code MessageContainer} instance.
|
||||
*
|
||||
* @param {Props} props - The React {@code Component} props to initialize
|
||||
* the new {@code MessageContainer} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this._isScrolledToBottom = true;
|
||||
|
||||
this._messageListRef = React.createRef();
|
||||
this._messagesListEndRef = React.createRef();
|
||||
|
||||
this._onChatScroll = this._onChatScroll.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const groupedMessages = this._getMessagesGroupedBySender();
|
||||
const messages = groupedMessages.map((group, index) => {
|
||||
const messageType = group[0] && group[0].messageType;
|
||||
|
||||
return (
|
||||
<ChatMessageGroup
|
||||
className = { messageType || MESSAGE_TYPE_REMOTE }
|
||||
key = { index }
|
||||
messages = { group } />
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-labelledby = 'chat-header'
|
||||
id = 'chatconversation'
|
||||
onScroll = { this._onChatScroll }
|
||||
ref = { this._messageListRef }
|
||||
role = 'log'
|
||||
tabIndex = { 0 }>
|
||||
{ messages }
|
||||
<div ref = { this._messagesListEndRef } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the bottom again if the instance had previously been scrolled
|
||||
* to the bottom. This method is used when a resize has occurred below the
|
||||
* instance and bottom scroll needs to be maintained.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
maybeUpdateBottomScroll() {
|
||||
if (this._isScrolledToBottom) {
|
||||
this.scrollToBottom(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically scrolls the displayed chat messages down to the latest.
|
||||
*
|
||||
* @param {boolean} withAnimation - Whether or not to show a scrolling
|
||||
* animation.
|
||||
* @returns {void}
|
||||
*/
|
||||
scrollToBottom(withAnimation: boolean) {
|
||||
scrollIntoView(this._messagesListEndRef.current, {
|
||||
behavior: withAnimation ? 'smooth' : 'auto',
|
||||
block: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
_getMessagesGroupedBySender: () => Array<Array<Object>>;
|
||||
|
||||
_onChatScroll: () => void;
|
||||
|
||||
/**
|
||||
* Callback invoked to listen to the current scroll location.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChatScroll() {
|
||||
const element = this._messageListRef.current;
|
||||
|
||||
this._isScrolledToBottom
|
||||
= element.scrollHeight - element.scrollTop === element.clientHeight;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,313 @@
|
|||
/* eslint-disable lines-around-comment */
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { RefObject } from 'react';
|
||||
import { scrollIntoView } from 'seamless-scroll-polyfill';
|
||||
|
||||
// @ts-ignore
|
||||
import { MESSAGE_TYPE_REMOTE } from '../../constants';
|
||||
import AbstractMessageContainer, { Props } from '../AbstractMessageContainer';
|
||||
|
||||
// @ts-ignore
|
||||
import ChatMessageGroup from './ChatMessageGroup';
|
||||
import NewMessagesButton from './NewMessagesButton';
|
||||
|
||||
interface State {
|
||||
/**
|
||||
* 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<Props, State> {
|
||||
/**
|
||||
* Component state used to decide when the hasNewMessages button to appear
|
||||
* and where to scroll when click on hasNewMessages button.
|
||||
*/
|
||||
state: State = {
|
||||
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<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* A React ref to the HTML element containing all {@code ChatMessageGroup}
|
||||
* instances.
|
||||
*/
|
||||
_messageListRef: RefObject<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* Intersection observer used to detect intersections of messages with the bottom of the message container.
|
||||
*/
|
||||
_bottomListObserver: IntersectionObserver;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code MessageContainer} instance.
|
||||
*
|
||||
* @param {Props} props - The React {@code Component} props to initialize
|
||||
* the new {@code MessageContainer} instance with.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this._messageListRef = React.createRef<HTMLDivElement>();
|
||||
this._messagesListEndRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
// 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] && group[0].messageType;
|
||||
|
||||
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: Props) {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
import { makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconArrowDown } from '../../../base/icons/svg/index';
|
||||
import { withPixelLineHeight } from '../../../base/styles/functions.web';
|
||||
|
||||
export interface INewMessagesButtonProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Function to notify messageContainer when click on goToFirstUnreadMessage button.
|
||||
*/
|
||||
onGoToFirstUnreadMessage: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: any) => {
|
||||
return {
|
||||
container: {
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 72px)',
|
||||
bottom: '15px'
|
||||
},
|
||||
|
||||
newMessagesButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: '32px',
|
||||
padding: '6px 8px',
|
||||
border: 'none',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.action02,
|
||||
boxShadow: '0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25)',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action02Hover
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
backgroundColor: theme.palette.action02Active
|
||||
}
|
||||
},
|
||||
|
||||
arrowDownIconContainer: {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: '5px',
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.uiBackground
|
||||
}
|
||||
},
|
||||
|
||||
textContainer: {
|
||||
...withPixelLineHeight(theme.typography.bodyShortRegular),
|
||||
color: theme.palette.text04,
|
||||
paddingLeft: '8px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** NewMessagesButton.
|
||||
*
|
||||
* @param {Function} onGoToFirstUnreadMessage - Function for lifting up onClick event.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
function NewMessagesButton({ onGoToFirstUnreadMessage, t }: INewMessagesButtonProps): JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { styles.container }>
|
||||
<button
|
||||
aria-label = { t('chat.newMessages') }
|
||||
className = { styles.newMessagesButton }
|
||||
onClick = { onGoToFirstUnreadMessage }
|
||||
type = 'button'>
|
||||
<Icon
|
||||
className = { styles.arrowDownIconContainer }
|
||||
size = { 14 }
|
||||
src = { IconArrowDown } />
|
||||
|
||||
<div className = { styles.textContainer }> { t('chat.newMessages') }</div>
|
||||
</button>
|
||||
</div>);
|
||||
}
|
||||
|
||||
export default translate(NewMessagesButton);
|
|
@ -27,7 +27,7 @@ const DEFAULT_STATE = {
|
|||
isLobbyChatActive: false
|
||||
};
|
||||
|
||||
interface IMessage {
|
||||
export interface IMessage {
|
||||
displayName: string;
|
||||
error?: Object;
|
||||
id: string;
|
||||
|
|
Loading…
Reference in New Issue