/* eslint-disable react/prop-types */ import React, { useState, useRef } from 'react'; import PropTypes from 'prop-types'; import './Message.scss'; import dateFormat from 'dateformat'; import { twemojify } from '../../../util/twemojify'; import initMatrix from '../../../client/initMatrix'; import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil'; import colorMXID from '../../../util/colorMXID'; import { getEventCords } from '../../../util/common'; import { redactEvent, sendReaction } from '../../../client/action/roomTimeline'; import { openEmojiBoard, openProfileViewer, openReadReceipts, replyTo, } from '../../../client/action/navigation'; import { sanitizeCustomHtml } from '../../../util/sanitize'; import Text from '../../atoms/text/Text'; import RawIcon from '../../atoms/system-icons/RawIcon'; import Button from '../../atoms/button/Button'; import Tooltip from '../../atoms/tooltip/Tooltip'; import Input from '../../atoms/input/Input'; import Avatar from '../../atoms/avatar/Avatar'; import IconButton from '../../atoms/button/IconButton'; import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu'; import * as Media from '../media/Media'; import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg'; import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg'; import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg'; import PencilIC from '../../../../public/res/ic/outlined/pencil.svg'; import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg'; import BinIC from '../../../../public/res/ic/outlined/bin.svg'; function PlaceholderMessage() { return (
); } function MessageHeader({ userId, name, color, time, }) { return (
{twemojify(name)} {twemojify(userId)}
{time}
); } MessageHeader.propTypes = { userId: PropTypes.string.isRequired, name: PropTypes.string.isRequired, color: PropTypes.string.isRequired, time: PropTypes.string.isRequired, }; function MessageReply({ name, color, body }) { return (
{twemojify(name)} {' '} {twemojify(body)}
); } MessageReply.propTypes = { name: PropTypes.string.isRequired, color: PropTypes.string.isRequired, body: PropTypes.string.isRequired, }; function MessageBody({ senderName, body, isCustomHTML, isEdited, msgType, }) { // if body is not string it is a React element. if (typeof body !== 'string') return
{body}
; const content = isCustomHTML ? twemojify(sanitizeCustomHtml(body), undefined, true, false) : twemojify(body, undefined, true); return (
{ msgType === 'm.emote' && ( <> {'* '} {twemojify(senderName)} {' '} )} { content }
{ isEdited && (edited)}
); } MessageBody.defaultProps = { isCustomHTML: false, isEdited: false, }; MessageBody.propTypes = { senderName: PropTypes.string.isRequired, body: PropTypes.node.isRequired, isCustomHTML: PropTypes.bool, isEdited: PropTypes.bool, msgType: PropTypes.string.isRequired, }; function MessageEdit({ body, onSave, onCancel }) { const editInputRef = useRef(null); const handleKeyDown = (e) => { if (e.keyCode === 13 && e.shiftKey === false) { e.preventDefault(); onSave(editInputRef.current.value); } }; return (
{ e.preventDefault(); onSave(editInputRef.current.value); }}>
); } MessageEdit.propTypes = { body: PropTypes.string.isRequired, onSave: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, }; function MessageReactionGroup({ children }) { return (
{ children }
); } MessageReactionGroup.propTypes = { children: PropTypes.node.isRequired, }; function genReactionMsg(userIds, reaction) { const genLessContText = (text) => {text}; let msg = <>; userIds.forEach((userId, index) => { if (index === 0) msg = <>{getUsername(userId)}; // eslint-disable-next-line react/jsx-one-expression-per-line else if (index === userIds.length - 1) msg = <>{msg}{genLessContText(' and ')}{getUsername(userId)}; // eslint-disable-next-line react/jsx-one-expression-per-line else msg = <>{msg}{genLessContText(', ')}{getUsername(userId)}; }); return ( <> {msg} {genLessContText(' reacted with')} {twemojify(reaction, { className: 'react-emoji' })} ); } function MessageReaction({ reaction, users, isActive, onClick, }) { return ( {genReactionMsg(users, reaction)}} > ); } MessageReaction.propTypes = { reaction: PropTypes.node.isRequired, users: PropTypes.arrayOf(PropTypes.string).isRequired, isActive: PropTypes.bool.isRequired, onClick: PropTypes.func.isRequired, }; function MessageOptions({ children }) { return (
{children}
); } MessageOptions.propTypes = { children: PropTypes.node.isRequired, }; function isMedia(mE) { return ( mE.getContent()?.msgtype === 'm.file' || mE.getContent()?.msgtype === 'm.image' || mE.getContent()?.msgtype === 'm.audio' || mE.getContent()?.msgtype === 'm.video' || mE.getType() === 'm.sticker' ); } function genMediaContent(mE) { const mx = initMatrix.matrixClient; const mContent = mE.getContent(); if (!mContent || !mContent.body) return Malformed event; let mediaMXC = mContent?.url; const isEncryptedFile = typeof mediaMXC === 'undefined'; if (isEncryptedFile) mediaMXC = mContent?.file?.url; let thumbnailMXC = mContent?.info?.thumbnail_url; if (typeof mediaMXC === 'undefined' || mediaMXC === '') return Malformed event; let msgType = mE.getContent()?.msgtype; if (mE.getType() === 'm.sticker') msgType = 'm.image'; switch (msgType) { case 'm.file': return ( ); case 'm.image': return ( ); case 'm.audio': return ( ); case 'm.video': if (typeof thumbnailMXC === 'undefined') { thumbnailMXC = mContent.info?.thumbnail_file?.url || null; } return ( ); default: return Malformed event; } } function getMyEmojiEventId(emojiKey, eventId, roomTimeline) { const mx = initMatrix.matrixClient; const rEvents = roomTimeline.reactionTimeline.get(eventId); let rEventId = null; rEvents?.find((rE) => { if (rE.getRelation() === null) return false; if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) { rEventId = rE.getId(); return true; } return false; }); return rEventId; } function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) { const myAlreadyReactEventId = getMyEmojiEventId(emojiKey, eventId, roomTimeline); if (typeof myAlreadyReactEventId === 'string') { if (myAlreadyReactEventId.indexOf('~') === 0) return; redactEvent(roomId, myAlreadyReactEventId); return; } sendReaction(roomId, eventId, emojiKey); } function pickEmoji(e, roomId, eventId, roomTimeline) { openEmojiBoard(getEventCords(e), (emoji) => { toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline); e.target.click(); }); } function parseReply(rawBody) { if (rawBody.indexOf('>') !== 0) return null; let body = rawBody.slice(rawBody.indexOf('<') + 1); const user = body.slice(0, body.indexOf('>')); body = body.slice(body.indexOf('>') + 2); const replyBody = body.slice(0, body.indexOf('\n\n')); body = body.slice(body.indexOf('\n\n') + 2); if (user === '') return null; const isUserId = user.match(/^@.+:.+/); return { userId: isUserId ? user : null, displayName: isUserId ? null : user, replyBody, body, }; } function getEditedBody(editedMEvent) { const newContent = editedMEvent.getContent()['m.new_content']; if (typeof newContent === 'undefined') return [null, false, null]; const isCustomHTML = newContent.format === 'org.matrix.custom.html'; const parsedContent = parseReply(newContent.body); if (parsedContent === null) { return [newContent.body, isCustomHTML, newContent.formatted_body ?? null]; } return [parsedContent.body, isCustomHTML, newContent.formatted_body ?? null]; } function Message({ mEvent, isBodyOnly, roomTimeline }) { const [isEditing, setIsEditing] = useState(false); const mx = initMatrix.matrixClient; const { room, roomId, editedTimeline, reactionTimeline, } = roomTimeline; const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')]; const content = mEvent.getContent(); const eventId = mEvent.getId(); const msgType = content?.msgtype; const senderId = mEvent.getSender(); const mxidColor = colorMXID(senderId); let { body } = content; const avatarSrc = mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop'); const username = getUsernameOfRoomMember(mEvent.sender); const time = `${dateFormat(mEvent.getDate(), 'hh:MM TT')}`; if (typeof body === 'undefined') return null; if (msgType === 'm.emote') className.push('message--type-emote'); const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel; const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); let [reply, reactions, isCustomHTML] = [null, null, content.format === 'org.matrix.custom.html']; const [isEdited, haveReactions] = [editedTimeline.has(eventId), reactionTimeline.has(eventId)]; const isReply = typeof content['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; let customHTML = isCustomHTML ? content.formatted_body : null; if (isEdited) { const editedList = editedTimeline.get(eventId); const editedMEvent = editedList[editedList.length - 1]; [body, isCustomHTML, customHTML] = getEditedBody(editedMEvent); if (typeof body !== 'string') return null; } if (haveReactions) { reactions = []; reactionTimeline.get(eventId).forEach((rEvent) => { if (rEvent.getRelation() === null) return; function alreadyHaveThisReaction(rE) { for (let i = 0; i < reactions.length; i += 1) { if (reactions[i].key === rE.getRelation().key) return true; } return false; } if (alreadyHaveThisReaction(rEvent)) { for (let i = 0; i < reactions.length; i += 1) { if (reactions[i].key === rEvent.getRelation().key) { reactions[i].users.push(rEvent.getSender()); if (reactions[i].isActive !== true) { const myUserId = mx.getUserId(); reactions[i].isActive = rEvent.getSender() === myUserId; if (reactions[i].isActive) reactions[i].id = rEvent.getId(); } break; } } } else { reactions.push({ id: rEvent.getId(), key: rEvent.getRelation().key, users: [rEvent.getSender()], isActive: (rEvent.getSender() === mx.getUserId()), }); } }); } if (isReply) { const parsedContent = parseReply(body); if (parsedContent !== null) { const c = room.currentState; const displayNameToUserIds = c.getUserIdsWithDisplayName(parsedContent.displayName); const ID = parsedContent.userId || displayNameToUserIds[0]; reply = { color: colorMXID(ID || parsedContent.displayName), to: parsedContent.displayName || getUsername(parsedContent.userId), body: parsedContent.replyBody, }; body = parsedContent.body; } } return (
{!isBodyOnly && ( )}
{!isBodyOnly && ( )} {reply !== null && ( )} {!isEditing && ( )} {isEditing && ( { if (newBody !== body) { initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody); } setIsEditing(false); }} onCancel={() => setIsEditing(false)} /> )} {haveReactions && ( { reactions.map((reaction) => ( { toggleEmoji(roomId, eventId, reaction.key, roomTimeline); }} /> )) } { pickEmoji(e, roomId, eventId, roomTimeline); }} src={EmojiAddIC} size="extra-small" tooltip="Add reaction" /> )} {!isEditing && ( pickEmoji(e, roomId, eventId, roomTimeline)} src={EmojiAddIC} size="extra-small" tooltip="Add reaction" /> replyTo(senderId, eventId, body)} src={ReplyArrowIC} size="extra-small" tooltip="Reply" /> {(senderId === mx.getUserId() && !isMedia(mEvent)) && ( setIsEditing(true)} src={PencilIC} size="extra-small" tooltip="Edit" /> )} ( <> Options openReadReceipts(roomId, eventId)} > Read receipts {(canIRedact || senderId === mx.getUserId()) && ( <> { if (window.confirm('Are you sure you want to delete this event')) { redactEvent(roomId, eventId); } }} > Delete )} )} render={(toggleMenu) => ( )} /> )}
); } Message.defaultProps = { isBodyOnly: false, }; Message.propTypes = { mEvent: PropTypes.shape({}).isRequired, isBodyOnly: PropTypes.bool, roomTimeline: PropTypes.shape({}).isRequired, }; export { Message, MessageReply, PlaceholderMessage };