import { Theme } from '@mui/material'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { connect } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { IReduxState } from '../../../app/types'; import { translate } from '../../../base/i18n/functions'; import { getParticipantById, getParticipantDisplayName, isPrivateChatEnabled } from '../../../base/participants/functions'; import Popover from '../../../base/popover/components/Popover.web'; import Message from '../../../base/react/components/web/Message'; import { MESSAGE_TYPE_LOCAL } from '../../constants'; // AFTER import { getActiveChatSearchMatch, getChatSearchQuery, getDisplayNameSuffix, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage, isFileMessage } from '../../functions'; import { IChatMessageProps } from '../../types'; import FileMessage from './FileMessage'; import MessageMenu from './MessageMenu'; import ReactButton from './ReactButton'; interface IProps extends IChatMessageProps { className?: string; enablePrivateChat?: boolean; isActiveSearchMatch?: boolean; isEditing?: boolean; onCancelEdit?: () => void; onEditMessage?: (message: IChatMessageProps['message']) => void; searchQuery?: string; shouldDisplayMenuOnRight?: boolean; state?: IReduxState; } const useStyles = makeStyles()((theme: Theme) => { return { chatMessageFooter: { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: theme.spacing(1) }, chatMessageFooterLeft: { display: 'flex', flexGrow: 1, overflow: 'hidden' }, chatMessageWrapper: { maxWidth: '100%' }, chatMessage: { display: 'inline-flex', padding: '12px', backgroundColor: theme.palette.chatMessageRemote, borderRadius: '4px 12px 12px 12px', maxWidth: '100%', marginTop: '4px', boxSizing: 'border-box' as const, '&.file': { display: 'flex', maxWidth: '100%', minWidth: 0, '& $replyWrapper': { width: '100%', minWidth: 0 }, '& $messageContent': { width: '100%', minWidth: 0 } }, '&.privatemessage': { backgroundColor: theme.palette.chatMessagePrivate }, '&.local': { backgroundColor: theme.palette.chatMessageLocal, borderRadius: '12px 4px 12px 12px', '&.privatemessage': { backgroundColor: theme.palette.chatMessagePrivate }, '&.local': { backgroundColor: theme.palette.chatMessageLocal, borderRadius: '12px 4px 12px 12px', '&.privatemessage': { backgroundColor: theme.palette.chatMessagePrivate } }, '&.error': { backgroundColor: theme.palette.actionDanger, borderRadius: 0, fontWeight: 100 }, '&.lobbymessage': { backgroundColor: theme.palette.chatMessagePrivate } }, '&.error': { backgroundColor: theme.palette.actionDanger, borderRadius: 0, fontWeight: 100 }, '&.lobbymessage': { backgroundColor: theme.palette.chatMessagePrivate } }, searchMatchActive: { outline: `2px solid ${theme.palette.action01}`, outlineOffset: '2px', animation: '$searchMatchFlash 1.4s ease-out' }, '@keyframes searchMatchFlash': { '0%': { backgroundColor: theme.palette.warning01 }, '100%': { backgroundColor: 'transparent' } }, sideBySideContainer: { display: 'flex', flexDirection: 'row', justifyContent: 'left', alignItems: 'center', marginLeft: theme.spacing(1) }, reactionBox: { display: 'flex', alignItems: 'center', gap: theme.spacing(1), backgroundColor: theme.palette.grey[800], borderRadius: theme.shape.borderRadius, padding: theme.spacing(0, 1), cursor: 'pointer' }, reactionCount: { fontSize: '0.8rem', color: theme.palette.grey[400] }, replyButton: { padding: '2px' }, replyWrapper: { display: 'flex', flexDirection: 'row' as const, alignItems: 'center', maxWidth: '100%' }, messageContent: { maxWidth: '100%', overflow: 'hidden' }, optionsButtonContainer: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: theme.spacing(1), minWidth: '32px', minHeight: '32px' }, displayName: { ...theme.typography.labelBold, color: theme.palette.chatSenderName, whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', marginBottom: theme.spacing(1), maxWidth: '130px' }, userMessage: { ...theme.typography.bodyShortRegular, color: theme.palette.chatMessageText, whiteSpace: 'pre-wrap', wordBreak: 'break-word', '& mark': { backgroundColor: theme.palette.warning01, color: theme.palette.text04, borderRadius: '2px', padding: '0 1px' } }, deletedMessage: { fontStyle: 'italic', opacity: 0.7 }, privateMessageNotice: { ...theme.typography.labelRegular, color: theme.palette.chatPrivateNotice, marginTop: theme.spacing(1) }, timestamp: { ...theme.typography.labelRegular, color: theme.palette.chatTimestamp, marginTop: theme.spacing(1), marginLeft: theme.spacing(1), whiteSpace: 'nowrap', flexShrink: 0 }, editedLabel: { ...theme.typography.labelRegular, color: theme.palette.chatTimestamp, marginLeft: theme.spacing(1), whiteSpace: 'nowrap' }, reactionsPopover: { padding: theme.spacing(2), backgroundColor: theme.palette.chatInputBackground, borderRadius: theme.shape.borderRadius, maxWidth: '150px', maxHeight: '400px', overflowY: 'auto', color: theme.palette.chatMessageText }, reactionItem: { display: 'flex', alignItems: 'center', marginBottom: theme.spacing(1), gap: theme.spacing(1), borderBottom: `1px solid ${theme.palette.common.white}`, paddingBottom: theme.spacing(1), '&:last-child': { borderBottom: 'none', paddingBottom: 0 } }, participantList: { marginLeft: theme.spacing(1), fontSize: '0.8rem', maxWidth: '120px' }, participant: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, editingNotice: { alignItems: 'center', borderTop: `1px solid ${theme.palette.chatInputBorder}`, color: theme.palette.chatMessageText, display: 'flex', justifyContent: 'space-between', marginTop: theme.spacing(1), paddingTop: theme.spacing(1), width: '100%', ...theme.typography.labelRegular }, cancelEdit: { cursor: 'pointer', color: theme.palette.link01 }, }; }); const ChatMessage = ({ className = '', isEditing, message, state, showDisplayName, shouldDisplayMenuOnRight, isActiveSearchMatch, searchQuery, enablePrivateChat, knocking, onCancelEdit, onEditMessage, t }: IProps) => { const { classes, cx } = useStyles(); const [ isHovered, setIsHovered ] = useState(false); const [ isReactionsOpen, setIsReactionsOpen ] = useState(false); const messageRef = useRef(null); useEffect(() => { if (!isActiveSearchMatch || !messageRef.current) { return; } const messageElement = messageRef.current; const container = messageElement.closest('#chatconversation') as HTMLElement | null; if (!container) { return; } const messageRect = messageElement.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const offset = messageRect.top - containerRect.top - (containerRect.height / 2) + (messageRect.height / 2); container.scrollTo({ top: container.scrollTop + offset, behavior: 'smooth' }); }, [ isActiveSearchMatch ]); const handleEditMessage = useCallback(() => { onEditMessage?.(message); }, [ message, onEditMessage ]); const handleMouseEnter = useCallback(() => { setIsHovered(true); }, []); const handleMouseLeave = useCallback(() => { setIsHovered(false); }, []); const handleReactionsOpen = useCallback(() => { setIsReactionsOpen(true); }, []); const handleReactionsClose = useCallback(() => { setIsReactionsOpen(false); }, []); /** * Renders the display name of the sender. * * @returns {React$Element<*>} */ function _renderDisplayName() { const { displayName } = message; return (
{`${displayName}${getDisplayNameSuffix(message)}`}
); } /** * Renders the message privacy notice. * * @returns {React$Element<*>} */ function _renderPrivateNotice() { return (
{getPrivateNoticeMessage(message)}
); } /** * Renders the time at which the message was sent. * * @returns {React$Element<*>} */ function _renderTimestamp() { return (

{getFormattedTimestamp(message)}

); } /** * Renders the editing notice for which message is edited. * * @returns {React$Element<*>} */ function _renderEditingNotice() { return (
{t('chat.editingMessage', 'Editing')} {t('chat.cancel', 'Cancel')}
); } /** * Renders the reactions for the message. * * @returns {React$Element<*>} */ const renderReactions = useMemo(() => { if (!message.reactions || message.reactions.size === 0) { return null; } const reactionsArray = Array.from(message.reactions.entries()) .map(([ reaction, participants ]) => { return { reaction, participants }; }) .sort((a, b) => b.participants.size - a.participants.size); const totalReactions = reactionsArray.reduce((sum, { participants }) => sum + participants.size, 0); const numReactionsDisplayed = 3; const reactionsContent = (
{reactionsArray.map(({ reaction, participants }) => (

{reaction} {participants.size}

{Array.from(participants).map(participantId => (

{state && getParticipantDisplayName(state, participantId)}

))}
))}
); return (
{reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>

{reaction}

)} {reactionsArray.length > numReactionsDisplayed && (

+{totalReactions - numReactionsDisplayed}

)}
); }, [ message?.reactions, isHovered, isReactionsOpen ]); const messageText = message.isDeleted ? ( message.messageType === MESSAGE_TYPE_LOCAL ? t('chat.deletedMessageByMe') : t('chat.deletedMessage') ) : message.isModerated ? t('chat.moderatedMessage') : getMessageText(message); const canEdit = message.messageType === MESSAGE_TYPE_LOCAL && !message.lobbyChat && !message.isReaction && !message.isFromVisitor && !isFileMessage(message); return (
{!shouldDisplayMenuOnRight && (
{isHovered && !message.isModerated && }
)}
{showDisplayName && _renderDisplayName()}
{!message.isModerated && isFileMessage(message) ? ( ('chat.fileAccessibleTitleMe') : t('chat.fileAccessibleTitle', { user: message.displayName }) } /> ) : ( ('chat.messageAccessibleTitleMe') : t('chat.messageAccessibleTitle', { user: message.displayName }) } text = { messageText } /> )} {message.isModerated && message.moderationReason && (
{t('chat.moderationReason', { reason: message.moderationReason })}
)} {(message.privateMessage || (message.lobbyChat && !knocking)) && _renderPrivateNotice()}
{!message.isDeleted && !message.isModerated && message.reactions && message.reactions.size > 0 && ( <> {renderReactions} )}
{message.isEdited && (
{t('chat.edited', '(edited)')}
)} {_renderTimestamp()}
{shouldDisplayMenuOnRight && (
{!message.privateMessage && !message.lobbyChat && !message.isReaction && !message.isDeleted && !message.isModerated &&
{isHovered && }
}
{isHovered && !message.isDeleted && !message.isModerated && }
)}
{isEditing && _renderEditingNotice()}
); }; /** * Maps part of the Redux store to the props of this component. * * @param {Object} state - The Redux state. * @returns {IProps} */ function _mapStateToProps(state: IReduxState, { message }: IProps) { const { knocking } = state['features/lobby']; const activeSearchMatch = getActiveChatSearchMatch(state); const isActiveSearchMatch = Boolean(activeSearchMatch && activeSearchMatch.messageId === message.messageId); const participant = getParticipantById(state, message.participantId); // For visitor private messages, participant will be undefined but we should still allow private chat // Create a visitor participant object for visitor messages to pass to isPrivateChatEnabled const participantForCheck = message.isFromVisitor ? { id: message.participantId, name: message.displayName, isVisitor: true as const } : participant; const enablePrivateChat = (!message.isFromVisitor || message.privateMessage) && isPrivateChatEnabled(participantForCheck, state); // Only the local messages appear on the right side of the chat therefore only for them the menu has to be on the // left side. const shouldDisplayMenuOnRight = message.messageType !== MESSAGE_TYPE_LOCAL; return { shouldDisplayMenuOnRight, enablePrivateChat, isActiveSearchMatch, searchQuery: getChatSearchQuery(state), knocking, state }; } export default translate(connect(_mapStateToProps)(ChatMessage));