theluyuan 38ba663466
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled
init
2025-09-02 14:49:16 +08:00

448 lines
16 KiB
TypeScript

import { Theme } from '@mui/material';
import React, { useCallback, useMemo, 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';
import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
import { IChatMessageProps } from '../../types';
import MessageMenu from './MessageMenu';
import ReactButton from './ReactButton';
interface IProps extends IChatMessageProps {
className?: string;
enablePrivateChat?: boolean;
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.ui02,
borderRadius: '4px 12px 12px 12px',
maxWidth: '100%',
marginTop: '4px',
boxSizing: 'border-box' as const,
'&.privatemessage': {
backgroundColor: theme.palette.support05
},
'&.local': {
backgroundColor: theme.palette.ui04,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.support05
},
'&.local': {
backgroundColor: theme.palette.ui04,
borderRadius: '12px 4px 12px 12px',
'&.privatemessage': {
backgroundColor: theme.palette.support05
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.support05
}
},
'&.error': {
backgroundColor: theme.palette.actionDanger,
borderRadius: 0,
fontWeight: 100
},
'&.lobbymessage': {
backgroundColor: theme.palette.support05
}
},
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',
flex: 1
},
optionsButtonContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(1),
minWidth: '32px',
minHeight: '32px'
},
displayName: {
...theme.typography.labelBold,
color: theme.palette.text02,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
marginBottom: theme.spacing(1),
maxWidth: '130px'
},
userMessage: {
...theme.typography.bodyShortRegular,
color: theme.palette.text01,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
privateMessageNotice: {
...theme.typography.labelRegular,
color: theme.palette.text02,
marginTop: theme.spacing(1)
},
timestamp: {
...theme.typography.labelRegular,
color: theme.palette.text03,
marginTop: theme.spacing(1),
marginLeft: theme.spacing(1),
whiteSpace: 'nowrap',
flexShrink: 0
},
reactionsPopover: {
padding: theme.spacing(2),
backgroundColor: theme.palette.ui03,
borderRadius: theme.shape.borderRadius,
maxWidth: '150px',
maxHeight: '400px',
overflowY: 'auto',
color: theme.palette.text01
},
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'
}
};
});
const ChatMessage = ({
className = '',
message,
state,
showDisplayName,
shouldDisplayMenuOnRight,
enablePrivateChat,
knocking,
t
}: IProps) => {
const { classes, cx } = useStyles();
const [ isHovered, setIsHovered ] = useState(false);
const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
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, isFromVisitor = false } = message;
return (
<div
aria-hidden = { true }
className = { cx('display-name', classes.displayName) }>
{`${displayName}${isFromVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`}
</div>
);
}
/**
* Renders the message privacy notice.
*
* @returns {React$Element<*>}
*/
function _renderPrivateNotice() {
return (
<div className = { classes.privateMessageNotice }>
{getPrivateNoticeMessage(message)}
</div>
);
}
/**
* Renders the time at which the message was sent.
*
* @returns {React$Element<*>}
*/
function _renderTimestamp() {
return (
<div className = { cx('timestamp', classes.timestamp) }>
<p>
{getFormattedTimestamp(message)}
</p>
</div>
);
}
/**
* 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 = (
<div className = { classes.reactionsPopover }>
{reactionsArray.map(({ reaction, participants }) => (
<div
className = { classes.reactionItem }
key = { reaction }>
<p>
<span>{reaction}</span>
<span>{participants.size}</span>
</p>
<div className = { classes.participantList }>
{Array.from(participants).map(participantId => (
<p
className = { classes.participant }
key = { participantId }>
{state && getParticipantDisplayName(state, participantId)}
</p>
))}
</div>
</div>
))}
</div>
);
return (
<Popover
content = { reactionsContent }
onPopoverClose = { handleReactionsClose }
onPopoverOpen = { handleReactionsOpen }
position = 'top'
trigger = 'hover'
visible = { isReactionsOpen }>
<div className = { classes.reactionBox }>
{reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>
<p key = { index }>{reaction}</p>
)}
{reactionsArray.length > numReactionsDisplayed && (
<p className = { classes.reactionCount }>
+{totalReactions - numReactionsDisplayed}
</p>
)}
</div>
</Popover>
);
}, [ message?.reactions, isHovered, isReactionsOpen ]);
return (
<div
className = { cx(classes.chatMessageWrapper, className) }
id = { message.messageId }
onMouseEnter = { handleMouseEnter }
onMouseLeave = { handleMouseLeave }
tabIndex = { -1 }>
<div className = { classes.sideBySideContainer }>
{!shouldDisplayMenuOnRight && (
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
message = { message.message }
participantId = { message.participantId } />}
</div>
)}
<div
className = { cx(
'chatmessage',
classes.chatMessage,
className,
message.privateMessage && 'privatemessage',
message.lobbyChat && !knocking && 'lobbymessage'
) }>
<div className = { classes.replyWrapper }>
<div className = { cx('messagecontent', classes.messageContent) }>
{showDisplayName && _renderDisplayName()}
<div className = { cx('usermessage', classes.userMessage) }>
<Message
screenReaderHelpText = { message.displayName === message.recipient
? t<string>('chat.messageAccessibleTitleMe')
: t<string>('chat.messageAccessibleTitle', {
user: message.displayName
}) }
text = { getMessageText(message) } />
{(message.privateMessage || (message.lobbyChat && !knocking))
&& _renderPrivateNotice()}
<div className = { classes.chatMessageFooter }>
<div className = { classes.chatMessageFooterLeft }>
{message.reactions && message.reactions.size > 0 && (
<>
{renderReactions}
</>
)}
</div>
{_renderTimestamp()}
</div>
</div>
</div>
</div>
</div>
{shouldDisplayMenuOnRight && (
<div className = { classes.sideBySideContainer }>
{!message.privateMessage && !message.lobbyChat && <div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <ReactButton
messageId = { message.messageId }
receiverId = { '' } />}
</div>
</div>}
<div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
message = { message.message }
participantId = { message.participantId } />}
</div>
</div>
</div>
)}
</div>
</div>
);
};
/**
* 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 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,
knocking,
state
};
}
export default translate(connect(_mapStateToProps)(ChatMessage));