/* eslint-disable react/prop-types */ import React, { useState, useEffect, useCallback, useRef, } from 'react'; import PropTypes from 'prop-types'; import './Message.scss'; import { twemojify } from '../../../util/twemojify'; import initMatrix from '../../../client/initMatrix'; import { getUsername, getUsernameOfRoomMember, parseReply, trimHTMLReply, } from '../../../util/matrixUtil'; import colorMXID from '../../../util/colorMXID'; import { getEventCords } from '../../../util/common'; import { redactEvent, sendReaction } from '../../../client/action/roomTimeline'; import { openEmojiBoard, openProfileViewer, openReadReceipts, openViewSource, 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 Time from '../../atoms/time/Time'; 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 CmdIC from '../../../../public/res/ic/outlined/cmd.svg'; import BinIC from '../../../../public/res/ic/outlined/bin.svg'; import { confirmDialog } from '../confirm-dialog/ConfirmDialog'; import { getBlobSafeMimeType } from '../../../util/mimetypes'; import { html, plain } from '../../../util/markdown'; function PlaceholderMessage() { return (
); } const MessageAvatar = React.memo(({ roomId, avatarSrc, userId, username, }) => (
)); const MessageHeader = React.memo(({ userId, username, timestamp, fullTime, }) => (
{twemojify(username)} {twemojify(userId)}
)); MessageHeader.defaultProps = { fullTime: false, }; MessageHeader.propTypes = { userId: PropTypes.string.isRequired, username: PropTypes.string.isRequired, timestamp: PropTypes.number.isRequired, fullTime: PropTypes.bool, }; function MessageReply({ name, color, body }) { return (
{twemojify(name)} {' '} {twemojify(body)}
); } MessageReply.propTypes = { name: PropTypes.string.isRequired, color: PropTypes.string.isRequired, body: PropTypes.string.isRequired, }; const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => { const [reply, setReply] = useState(null); const isMountedRef = useRef(true); useEffect(() => { const mx = initMatrix.matrixClient; const timelineSet = roomTimeline.getUnfilteredTimelineSet(); const loadReply = async () => { try { const eTimeline = await mx.getEventTimeline(timelineSet, eventId); await roomTimeline.decryptAllEventsOfTimeline(eTimeline); let mEvent = eTimeline.getTimelineSet().findEventById(eventId); const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); if (editedList) { mEvent = editedList[editedList.length - 1]; } const rawBody = mEvent.getContent().body; const username = getUsernameOfRoomMember(mEvent.sender); if (isMountedRef.current === false) return; const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***'; let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody; if (editedList && parsedBody.startsWith(' * ')) { parsedBody = parsedBody.slice(3); } setReply({ to: username, color: colorMXID(mEvent.getSender()), body: parsedBody, event: mEvent, }); } catch { setReply({ to: '** Unknown user **', color: 'var(--tc-danger-normal)', body: '*** Unable to load reply ***', event: null, }); } }; loadReply(); return () => { isMountedRef.current = false; }; }, []); const focusReply = (ev) => { if (!ev.key || ev.key === ' ' || ev.key === 'Enter') { if (ev.key) ev.preventDefault(); if (reply?.event === null) return; if (reply?.event.isRedacted()) return; roomTimeline.loadEventTimeline(eventId); } }; return (
{reply !== null && }
); }); MessageReplyWrapper.propTypes = { roomTimeline: PropTypes.shape({}).isRequired, eventId: PropTypes.string.isRequired, }; const MessageBody = React.memo(({ senderName, body, isCustomHTML, isEdited, msgType, }) => { // if body is not string it is a React element. if (typeof body !== 'string') return
{body}
; let content = null; if (isCustomHTML) { try { content = twemojify( sanitizeCustomHtml(initMatrix.matrixClient, body), undefined, true, false, true, ); } catch { console.error('Malformed custom html: ', body); content = twemojify(body, undefined); } } else { content = twemojify(body, undefined, true); } // Determine if this message should render with large emojis // Criteria: // - Contains only emoji // - Contains no more than 10 emoji let emojiOnly = false; if (content.type === 'img') { // If this messages contains only a single (inline) image emojiOnly = true; } else if (content.constructor.name === 'Array') { // Otherwise, it might be an array of images / texb // Count the number of emojis const nEmojis = content.filter((e) => e.type === 'img').length; // Make sure there's no text besides whitespace and variation selector U+FE0F if (nEmojis <= 10 && content.every((element) => ( (typeof element === 'object' && element.type === 'img') || (typeof element === 'string' && /^[\s\ufe0f]*$/g.test(element)) ))) { emojiOnly = true; } } if (!isCustomHTML) { // If this is a plaintext message, wrap it in a

element (automatically applying // white-space: pre-wrap) in order to preserve newlines content = (

{content}

); } return (
{ msgType === 'm.emote' && ( <> {'* '} {twemojify(senderName)} {' '} )} { content }
{ isEdited && (edited)}
); }); MessageBody.defaultProps = { isCustomHTML: false, isEdited: false, msgType: null, }; MessageBody.propTypes = { senderName: PropTypes.string.isRequired, body: PropTypes.node.isRequired, isCustomHTML: PropTypes.bool, isEdited: PropTypes.bool, msgType: PropTypes.string, }; function MessageEdit({ body, onSave, onCancel }) { const editInputRef = useRef(null); useEffect(() => { // makes the cursor end up at the end of the line instead of the beginning editInputRef.current.value = ''; editInputRef.current.value = body; }, []); const handleKeyDown = (e) => { if (e.key === 'Escape') { e.preventDefault(); onCancel(); } if (e.key === 'Enter' && e.shiftKey === false) { e.preventDefault(); onSave(editInputRef.current.value, body); } }; return (
{ e.preventDefault(); onSave(editInputRef.current.value, body); }}>
); } MessageEdit.propTypes = { body: PropTypes.string.isRequired, onSave: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, }; function getMyEmojiEvent(emojiKey, eventId, roomTimeline) { const mx = initMatrix.matrixClient; const rEvents = roomTimeline.reactionTimeline.get(eventId); let rEvent = null; rEvents?.find((rE) => { if (rE.getRelation() === null) return false; if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) { rEvent = rE; return true; } return false; }); return rEvent; } function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) { const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline); if (myAlreadyReactEvent) { const rId = myAlreadyReactEvent.getId(); if (rId.startsWith('~')) return; redactEvent(roomId, rId); return; } sendReaction(roomId, eventId, emojiKey, shortcode); } function pickEmoji(e, roomId, eventId, roomTimeline) { openEmojiBoard(getEventCords(e), (emoji) => { toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline); e.target.click(); }); } function genReactionMsg(userIds, reaction, shortcode) { return ( <> {userIds.map((userId, index) => ( {twemojify(getUsername(userId))} {index < userIds.length - 1 && ( {index === userIds.length - 2 ? ' and ' : ', '} )} ))} {' reacted with '} {twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })} ); } function MessageReaction({ reaction, shortcode, count, users, isActive, onClick, }) { let customEmojiUrl = null; if (reaction.match(/^mxc:\/\/\S+$/)) { customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction); } return ( {users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}} > ); } MessageReaction.defaultProps = { shortcode: undefined, }; MessageReaction.propTypes = { reaction: PropTypes.node.isRequired, shortcode: PropTypes.string, count: PropTypes.number.isRequired, users: PropTypes.arrayOf(PropTypes.string).isRequired, isActive: PropTypes.bool.isRequired, onClick: PropTypes.func.isRequired, }; function MessageReactionGroup({ roomTimeline, mEvent }) { const { roomId, room, reactionTimeline } = roomTimeline; const mx = initMatrix.matrixClient; const reactions = {}; const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId()); const eventReactions = reactionTimeline.get(mEvent.getId()); const addReaction = (key, shortcode, count, senderId, isActive) => { let reaction = reactions[key]; if (reaction === undefined) { reaction = { count: 0, users: [], isActive: false, }; } if (shortcode) reaction.shortcode = shortcode; if (count) { reaction.count = count; } else { reaction.users.push(senderId); reaction.count = reaction.users.length; if (isActive) reaction.isActive = isActive; } reactions[key] = reaction; }; if (eventReactions) { eventReactions.forEach((rEvent) => { if (rEvent.getRelation() === null) return; const reaction = rEvent.getRelation(); const senderId = rEvent.getSender(); const { shortcode } = rEvent.getContent(); const isActive = senderId === mx.getUserId(); addReaction(reaction.key, shortcode, undefined, senderId, isActive); }); } else { // Use aggregated reactions const aggregatedReaction = mEvent.getServerAggregatedRelation('m.annotation')?.chunk; if (!aggregatedReaction) return null; aggregatedReaction.forEach((reaction) => { if (reaction.type !== 'm.reaction') return; addReaction(reaction.key, undefined, reaction.count, undefined, false); }); } return (
{ Object.keys(reactions).map((key) => ( { toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline); }} /> )) } {canSendReaction && ( { pickEmoji(e, roomId, mEvent.getId(), roomTimeline); }} src={EmojiAddIC} size="extra-small" tooltip="Add reaction" /> )}
); } MessageReactionGroup.propTypes = { roomTimeline: PropTypes.shape({}).isRequired, mEvent: PropTypes.shape({}).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' ); } // if editedTimeline has mEventId then pass editedMEvent else pass mEvent to openViewSource function handleOpenViewSource(mEvent, roomTimeline) { const eventId = mEvent.getId(); const { editedTimeline } = roomTimeline ?? {}; let editedMEvent; if (editedTimeline?.has(eventId)) { const editedList = editedTimeline.get(eventId); editedMEvent = editedList[editedList.length - 1]; } openViewSource(editedMEvent !== undefined ? editedMEvent : mEvent); } const MessageOptions = React.memo(({ roomTimeline, mEvent, edit, reply, }) => { const { roomId, room } = roomTimeline; const mx = initMatrix.matrixClient; const senderId = mEvent.getSender(); const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel; const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId()); return (
{canSendReaction && ( pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} src={EmojiAddIC} size="extra-small" tooltip="Add reaction" /> )} reply()} src={ReplyArrowIC} size="extra-small" tooltip="Reply" /> {(senderId === mx.getUserId() && !isMedia(mEvent)) && ( edit(true)} src={PencilIC} size="extra-small" tooltip="Edit" /> )} ( <> Options openReadReceipts(roomId, roomTimeline.getEventReaders(mEvent))} > Read receipts handleOpenViewSource(mEvent, roomTimeline)} > View source {(canIRedact || senderId === mx.getUserId()) && ( <> { const isConfirmed = await confirmDialog( 'Delete message', 'Are you sure that you want to delete this message?', 'Delete', 'danger', ); if (!isConfirmed) return; redactEvent(roomId, mEvent.getId()); }} > Delete )} )} render={(toggleMenu) => ( )} />
); }); MessageOptions.propTypes = { roomTimeline: PropTypes.shape({}).isRequired, mEvent: PropTypes.shape({}).isRequired, edit: PropTypes.func.isRequired, reply: PropTypes.func.isRequired, }; 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; const safeMimetype = getBlobSafeMimeType(mContent.info?.mimetype); if (mE.getType() === 'm.sticker') { msgType = 'm.sticker'; } else if (safeMimetype === 'application/octet-stream') { msgType = 'm.file'; } const blurhash = mContent?.info?.['xyz.amorgan.blurhash']; switch (msgType) { case 'm.file': return ( ); case 'm.image': return ( ); case 'm.sticker': 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 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, focus, fullTime, isEdit, setEdit, cancelEdit, }) { const roomId = mEvent.getRoomId(); const { editedTimeline, reactionTimeline } = roomTimeline ?? {}; const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')]; if (focus) className.push('message--focus'); const content = mEvent.getContent(); const eventId = mEvent.getId(); const msgType = content?.msgtype; const senderId = mEvent.getSender(); let { body } = content; const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId); const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null; let isCustomHTML = content.format === 'org.matrix.custom.html'; let customHTML = isCustomHTML ? content.formatted_body : null; const edit = useCallback(() => { setEdit(eventId); }, []); const reply = useCallback(() => { replyTo(senderId, mEvent.getId(), body, customHTML); }, [body, customHTML]); if (msgType === 'm.emote') className.push('message--type-emote'); const isEdited = roomTimeline ? editedTimeline.has(eventId) : false; const haveReactions = roomTimeline ? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation') : false; const isReply = !!mEvent.replyEventId; if (isEdited) { const editedList = editedTimeline.get(eventId); const editedMEvent = editedList[editedList.length - 1]; [body, isCustomHTML, customHTML] = getEditedBody(editedMEvent); } if (isReply) { body = parseReply(body)?.body ?? body; customHTML = trimHTMLReply(customHTML); } if (typeof body !== 'string') body = ''; return (
{ isBodyOnly ?
: ( ) }
{!isBodyOnly && ( )} {roomTimeline && isReply && ( )} {!isEdit && ( )} {isEdit && ( { if (newBody !== oldBody) { initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody); } cancelEdit(); }} onCancel={cancelEdit} /> )} {haveReactions && ( )} {roomTimeline && !isEdit && ( )}
); } Message.defaultProps = { isBodyOnly: false, focus: false, roomTimeline: null, fullTime: false, isEdit: false, setEdit: null, cancelEdit: null, }; Message.propTypes = { mEvent: PropTypes.shape({}).isRequired, isBodyOnly: PropTypes.bool, roomTimeline: PropTypes.shape({}), focus: PropTypes.bool, fullTime: PropTypes.bool, isEdit: PropTypes.bool, setEdit: PropTypes.func, cancelEdit: PropTypes.func, }; export { Message, MessageReply, PlaceholderMessage };