You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { Theme } from '@mui/material';
  2. import React, { useCallback, useState } from 'react';
  3. import { connect } from 'react-redux';
  4. import { makeStyles } from 'tss-react/mui';
  5. import { IReduxState } from '../../../app/types';
  6. import { translate } from '../../../base/i18n/functions';
  7. import { getParticipantDisplayName } from '../../../base/participants/functions';
  8. import Popover from '../../../base/popover/components/Popover.web';
  9. import Message from '../../../base/react/components/web/Message';
  10. import { withPixelLineHeight } from '../../../base/styles/functions.web';
  11. import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
  12. import { IChatMessageProps } from '../../types';
  13. import MessageMenu from './MessageMenu';
  14. import ReactButton from './ReactButton';
  15. interface IProps extends IChatMessageProps {
  16. shouldDisplayChatMessageMenu: boolean;
  17. state?: IReduxState;
  18. type: string;
  19. }
  20. const useStyles = makeStyles()((theme: Theme) => {
  21. return {
  22. chatMessageFooter: {
  23. display: 'flex',
  24. flexDirection: 'row',
  25. justifyContent: 'space-between',
  26. alignItems: 'center',
  27. marginTop: theme.spacing(1)
  28. },
  29. chatMessageFooterLeft: {
  30. display: 'flex',
  31. flexGrow: 1,
  32. overflow: 'hidden'
  33. },
  34. chatMessageWrapper: {
  35. maxWidth: '100%'
  36. },
  37. chatMessage: {
  38. display: 'inline-flex',
  39. padding: '12px',
  40. backgroundColor: theme.palette.ui02,
  41. borderRadius: '4px 12px 12px 12px',
  42. maxWidth: '100%',
  43. marginTop: '4px',
  44. boxSizing: 'border-box' as const,
  45. '&.privatemessage': {
  46. backgroundColor: theme.palette.support05
  47. },
  48. '&.local': {
  49. backgroundColor: theme.palette.ui04,
  50. borderRadius: '12px 4px 12px 12px',
  51. '&.privatemessage': {
  52. backgroundColor: theme.palette.support05
  53. },
  54. '&.local': {
  55. backgroundColor: theme.palette.ui04,
  56. borderRadius: '12px 4px 12px 12px',
  57. '&.privatemessage': {
  58. backgroundColor: theme.palette.support05
  59. }
  60. },
  61. '&.error': {
  62. backgroundColor: theme.palette.actionDanger,
  63. borderRadius: 0,
  64. fontWeight: 100
  65. },
  66. '&.lobbymessage': {
  67. backgroundColor: theme.palette.support05
  68. }
  69. },
  70. '&.error': {
  71. backgroundColor: theme.palette.actionDanger,
  72. borderRadius: 0,
  73. fontWeight: 100
  74. },
  75. '&.lobbymessage': {
  76. backgroundColor: theme.palette.support05
  77. }
  78. },
  79. sideBySideContainer: {
  80. display: 'flex',
  81. flexDirection: 'row',
  82. justifyContent: 'left',
  83. alignItems: 'center',
  84. marginLeft: theme.spacing(1)
  85. },
  86. reactionBox: {
  87. display: 'flex',
  88. alignItems: 'center',
  89. gap: theme.spacing(1),
  90. backgroundColor: theme.palette.grey[800],
  91. borderRadius: theme.shape.borderRadius,
  92. padding: theme.spacing(0, 1),
  93. cursor: 'pointer'
  94. },
  95. reactionCount: {
  96. fontSize: '0.8rem',
  97. color: theme.palette.grey[400]
  98. },
  99. replyButton: {
  100. padding: '2px'
  101. },
  102. replyWrapper: {
  103. display: 'flex',
  104. flexDirection: 'row' as const,
  105. alignItems: 'center',
  106. maxWidth: '100%'
  107. },
  108. messageContent: {
  109. maxWidth: '100%',
  110. overflow: 'hidden',
  111. flex: 1
  112. },
  113. optionsButtonContainer: {
  114. display: 'flex',
  115. flexDirection: 'column',
  116. alignItems: 'center',
  117. gap: theme.spacing(1),
  118. minWidth: '32px',
  119. minHeight: '32px'
  120. },
  121. displayName: {
  122. ...withPixelLineHeight(theme.typography.labelBold),
  123. color: theme.palette.text02,
  124. whiteSpace: 'nowrap',
  125. textOverflow: 'ellipsis',
  126. overflow: 'hidden',
  127. marginBottom: theme.spacing(1),
  128. maxWidth: '130px'
  129. },
  130. userMessage: {
  131. ...withPixelLineHeight(theme.typography.bodyShortRegular),
  132. color: theme.palette.text01,
  133. whiteSpace: 'pre-wrap',
  134. wordBreak: 'break-word'
  135. },
  136. privateMessageNotice: {
  137. ...withPixelLineHeight(theme.typography.labelRegular),
  138. color: theme.palette.text02,
  139. marginTop: theme.spacing(1)
  140. },
  141. timestamp: {
  142. ...withPixelLineHeight(theme.typography.labelRegular),
  143. color: theme.palette.text03,
  144. marginTop: theme.spacing(1),
  145. marginLeft: theme.spacing(1),
  146. whiteSpace: 'nowrap',
  147. flexShrink: 0
  148. },
  149. reactionsPopover: {
  150. padding: theme.spacing(2),
  151. backgroundColor: theme.palette.ui03,
  152. borderRadius: theme.shape.borderRadius,
  153. maxWidth: '150px',
  154. maxHeight: '400px',
  155. overflowY: 'auto',
  156. color: theme.palette.text01
  157. },
  158. reactionItem: {
  159. display: 'flex',
  160. alignItems: 'center',
  161. marginBottom: theme.spacing(1),
  162. gap: theme.spacing(1),
  163. borderBottom: `1px solid ${theme.palette.common.white}`,
  164. paddingBottom: theme.spacing(1),
  165. '&:last-child': {
  166. borderBottom: 'none',
  167. paddingBottom: 0
  168. }
  169. },
  170. participantList: {
  171. marginLeft: theme.spacing(1),
  172. fontSize: '0.8rem',
  173. maxWidth: '120px'
  174. },
  175. participant: {
  176. overflow: 'hidden',
  177. textOverflow: 'ellipsis',
  178. whiteSpace: 'nowrap'
  179. }
  180. };
  181. });
  182. const ChatMessage = ({
  183. message,
  184. state,
  185. showDisplayName,
  186. type,
  187. shouldDisplayChatMessageMenu,
  188. knocking,
  189. t
  190. }: IProps) => {
  191. const { classes, cx } = useStyles();
  192. const [ isHovered, setIsHovered ] = useState(false);
  193. const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
  194. const handleMouseEnter = useCallback(() => {
  195. setIsHovered(true);
  196. }, []);
  197. const handleMouseLeave = useCallback(() => {
  198. setIsHovered(false);
  199. }, []);
  200. const handleReactionsOpen = useCallback(() => {
  201. setIsReactionsOpen(true);
  202. }, []);
  203. const handleReactionsClose = useCallback(() => {
  204. setIsReactionsOpen(false);
  205. }, []);
  206. /**
  207. * Renders the display name of the sender.
  208. *
  209. * @returns {React$Element<*>}
  210. */
  211. function _renderDisplayName() {
  212. return (
  213. <div
  214. aria-hidden = { true }
  215. className = { cx('display-name', classes.displayName) }>
  216. {message.displayName}
  217. </div>
  218. );
  219. }
  220. /**
  221. * Renders the message privacy notice.
  222. *
  223. * @returns {React$Element<*>}
  224. */
  225. function _renderPrivateNotice() {
  226. return (
  227. <div className = { classes.privateMessageNotice }>
  228. {getPrivateNoticeMessage(message)}
  229. </div>
  230. );
  231. }
  232. /**
  233. * Renders the time at which the message was sent.
  234. *
  235. * @returns {React$Element<*>}
  236. */
  237. function _renderTimestamp() {
  238. return (
  239. <div className = { cx('timestamp', classes.timestamp) }>
  240. {getFormattedTimestamp(message)}
  241. </div>
  242. );
  243. }
  244. /**
  245. * Renders the reactions for the message.
  246. *
  247. * @returns {React$Element<*>}
  248. */
  249. const renderReactions = () => {
  250. if (!message.reactions || message.reactions.size === 0) {
  251. return null;
  252. }
  253. const reactionsArray = Array.from(message.reactions.entries())
  254. .map(([ reaction, participants ]) => {
  255. return { reaction,
  256. participants };
  257. })
  258. .sort((a, b) => b.participants.size - a.participants.size);
  259. const totalReactions = reactionsArray.reduce((sum, { participants }) => sum + participants.size, 0);
  260. const numReactionsDisplayed = 3;
  261. const reactionsContent = (
  262. <div className = { classes.reactionsPopover }>
  263. {reactionsArray.map(({ reaction, participants }) => (
  264. <div
  265. className = { classes.reactionItem }
  266. key = { reaction }>
  267. <span>{reaction}</span>
  268. <span>{participants.size}</span>
  269. <div className = { classes.participantList }>
  270. {Array.from(participants).map(participantId => (
  271. <div
  272. className = { classes.participant }
  273. key = { participantId }>
  274. {state && getParticipantDisplayName(state, participantId)}
  275. </div>
  276. ))}
  277. </div>
  278. </div>
  279. ))}
  280. </div>
  281. );
  282. return (
  283. <Popover
  284. content = { reactionsContent }
  285. onPopoverClose = { handleReactionsClose }
  286. onPopoverOpen = { handleReactionsOpen }
  287. position = 'top'
  288. trigger = 'hover'
  289. visible = { isReactionsOpen }>
  290. <div className = { classes.reactionBox }>
  291. {reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>
  292. <span key = { index }>{reaction}</span>
  293. )}
  294. {reactionsArray.length > numReactionsDisplayed && (
  295. <span className = { classes.reactionCount }>
  296. +{totalReactions - numReactionsDisplayed}
  297. </span>
  298. )}
  299. </div>
  300. </Popover>
  301. );
  302. };
  303. return (
  304. <div
  305. className = { cx(classes.chatMessageWrapper, type) }
  306. id = { message.messageId }
  307. onMouseEnter = { handleMouseEnter }
  308. onMouseLeave = { handleMouseLeave }
  309. tabIndex = { -1 }>
  310. <div className = { classes.sideBySideContainer }>
  311. {!shouldDisplayChatMessageMenu && (
  312. <div className = { classes.optionsButtonContainer }>
  313. {isHovered && <MessageMenu
  314. isLobbyMessage = { message.lobbyChat }
  315. message = { message.message }
  316. participantId = { message.participantId }
  317. shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
  318. </div>
  319. )}
  320. <div
  321. className = { cx(
  322. 'chatmessage',
  323. classes.chatMessage,
  324. type,
  325. message.privateMessage && 'privatemessage',
  326. message.lobbyChat && !knocking && 'lobbymessage'
  327. ) }>
  328. <div className = { classes.replyWrapper }>
  329. <div className = { cx('messagecontent', classes.messageContent) }>
  330. {showDisplayName && _renderDisplayName()}
  331. <div className = { cx('usermessage', classes.userMessage) }>
  332. <span className = 'sr-only'>
  333. {message.displayName === message.recipient
  334. ? t('chat.messageAccessibleTitleMe')
  335. : t('chat.messageAccessibleTitle', {
  336. user: message.displayName
  337. })}
  338. </span>
  339. <Message text = { getMessageText(message) } />
  340. {(message.privateMessage || (message.lobbyChat && !knocking))
  341. && _renderPrivateNotice()}
  342. <div className = { classes.chatMessageFooter }>
  343. <div className = { classes.chatMessageFooterLeft }>
  344. {message.reactions && message.reactions.size > 0 && (
  345. <>
  346. {renderReactions()}
  347. </>
  348. )}
  349. </div>
  350. {_renderTimestamp()}
  351. </div>
  352. </div>
  353. </div>
  354. </div>
  355. </div>
  356. {shouldDisplayChatMessageMenu && (
  357. <div className = { classes.sideBySideContainer }>
  358. {!message.privateMessage && <div>
  359. <div className = { classes.optionsButtonContainer }>
  360. {isHovered && <ReactButton
  361. messageId = { message.messageId }
  362. receiverId = { '' } />}
  363. </div>
  364. </div>}
  365. <div>
  366. <div className = { classes.optionsButtonContainer }>
  367. {isHovered && <MessageMenu
  368. isLobbyMessage = { message.lobbyChat }
  369. message = { message.message }
  370. participantId = { message.participantId }
  371. shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
  372. </div>
  373. </div>
  374. </div>
  375. )}
  376. </div>
  377. </div>
  378. );
  379. };
  380. /**
  381. * Maps part of the Redux store to the props of this component.
  382. *
  383. * @param {Object} state - The Redux state.
  384. * @returns {IProps}
  385. */
  386. function _mapStateToProps(state: IReduxState, { message }: IProps) {
  387. const { knocking } = state['features/lobby'];
  388. const localParticipantId = state['features/base/participants'].local?.id;
  389. return {
  390. shouldDisplayChatMessageMenu: message.participantId !== localParticipantId,
  391. knocking,
  392. state
  393. };
  394. }
  395. export default translate(connect(_mapStateToProps)(ChatMessage));