diff --git a/public/res/ic/outlined/pencil.svg b/public/res/ic/outlined/pencil.svg new file mode 100644 index 0000000..1b8ac24 --- /dev/null +++ b/public/res/ic/outlined/pencil.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/app/atoms/context-menu/ContextMenu.scss b/src/app/atoms/context-menu/ContextMenu.scss index 82a645b..fd6ca07 100644 --- a/src/app/atoms/context-menu/ContextMenu.scss +++ b/src/app/atoms/context-menu/ContextMenu.scss @@ -44,6 +44,7 @@ justify-content: start; border-radius: 0; box-shadow: none; + white-space: nowrap; .text:first-child { margin: { diff --git a/src/app/organisms/channel/ChannelViewContent.jsx b/src/app/organisms/channel/ChannelViewContent.jsx index 276f55e..f01e8b2 100644 --- a/src/app/organisms/channel/ChannelViewContent.jsx +++ b/src/app/organisms/channel/ChannelViewContent.jsx @@ -16,11 +16,13 @@ import { openEmojiBoard, openReadReceipts } from '../../../client/action/navigat import Divider from '../../atoms/divider/Divider'; import Avatar from '../../atoms/avatar/Avatar'; import IconButton from '../../atoms/button/IconButton'; +import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu'; import { Message, MessageHeader, MessageReply, MessageContent, + MessageEdit, MessageReactionGroup, MessageReaction, MessageOptions, @@ -32,6 +34,8 @@ import TimelineChange from '../../molecules/message/TimelineChange'; 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'; @@ -182,193 +186,6 @@ function pickEmoji(e, roomId, eventId, roomTimeline) { }); } -function genMessage(roomId, prevMEvent, mEvent, roomTimeline, viewEvent) { - const mx = initMatrix.matrixClient; - const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; - const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); - - const isContentOnly = ( - prevMEvent !== null - && prevMEvent.getType() !== 'm.room.member' - && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES - && prevMEvent.getSender() === mEvent.getSender() - ); - - let content = mEvent.getContent().body; - if (typeof content === 'undefined') return null; - let reply = null; - let reactions = null; - let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html'; - const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; - const isEdited = roomTimeline.editedTimeline.has(mEvent.getId()); - const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId()); - - if (isReply) { - const parsedContent = parseReply(content); - if (parsedContent !== null) { - const c = roomTimeline.room.currentState; - const ID = parsedContent.userId || c.getUserIdsWithDisplayName(parsedContent.displayName)[0]; - reply = { - color: colorMXID(ID || parsedContent.displayName), - to: parsedContent.displayName || getUsername(parsedContent.userId), - content: parsedContent.replyContent, - }; - content = parsedContent.content; - } - } - - if (isEdited) { - const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); - const latestEdited = editedList[editedList.length - 1]; - if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null; - const latestEditBody = latestEdited.getContent()['m.new_content'].body; - const parsedEditedContent = parseReply(latestEditBody); - isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html'; - if (parsedEditedContent === null) { - content = latestEditBody; - } else { - content = parsedEditedContent.content; - } - } - - if (haveReactions) { - reactions = []; - roomTimeline.reactionTimeline.get(mEvent.getId()).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 = initMatrix.matrixClient.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() === initMatrix.matrixClient.getUserId()), - }); - } - }); - } - - const senderMXIDColor = colorMXID(mEvent.sender.userId); - const userAvatar = isContentOnly ? null : ( - - ); - const userHeader = isContentOnly ? null : ( - - ); - const userReply = reply === null ? null : ( - - ); - const userContent = ( - - ); - const userReactions = reactions === null ? null : ( - - { - reactions.map((reaction) => ( - { - toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline); - }} - /> - )) - } - pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} - src={EmojiAddIC} - size="extra-small" - tooltip="Add reaction" - /> - - ); - const userOptions = ( - - pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} - src={EmojiAddIC} - size="extra-small" - tooltip="Add reaction" - /> - { - viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); - }} - src={ReplyArrowIC} - size="extra-small" - tooltip="Reply" - /> - openReadReceipts(roomId, mEvent.getId())} - src={TickMarkIC} - size="extra-small" - tooltip="Read receipts" - /> - {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( - { - if (window.confirm('Are you sure you want to delete this event')) { - redactEvent(roomId, mEvent.getId()); - } - }} - src={BinIC} - size="extra-small" - tooltip="Delete" - /> - )} - - ); - - const myMessageEl = ( - - ); - return myMessageEl; -} - let wasAtBottom = true; function ChannelViewContent({ roomId, roomTimeline, timelineScroll, viewEvent, @@ -376,6 +193,7 @@ function ChannelViewContent({ const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false); const [onStateUpdate, updateState] = useState(null); const [onPagination, setOnPagination] = useState(null); + const [editEvent, setEditEvent] = useState(null); const mx = initMatrix.matrixClient; function autoLoadTimeline() { @@ -453,6 +271,250 @@ function ChannelViewContent({ }, [onStateUpdate]); let prevMEvent = null; + function genMessage(mEvent) { + const myPowerlevel = roomTimeline.room.getMember(mx.getUserId()).powerLevel; + const canIRedact = roomTimeline.room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); + + const isContentOnly = ( + prevMEvent !== null + && prevMEvent.getType() !== 'm.room.member' + && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES + && prevMEvent.getSender() === mEvent.getSender() + ); + + let content = mEvent.getContent().body; + if (typeof content === 'undefined') return null; + let reply = null; + let reactions = null; + let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html'; + const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; + const isEdited = roomTimeline.editedTimeline.has(mEvent.getId()); + const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId()); + + if (isReply) { + const parsedContent = parseReply(content); + if (parsedContent !== null) { + const c = roomTimeline.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), + content: parsedContent.replyContent, + }; + content = parsedContent.content; + } + } + + if (isEdited) { + const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); + const latestEdited = editedList[editedList.length - 1]; + if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null; + const latestEditBody = latestEdited.getContent()['m.new_content'].body; + const parsedEditedContent = parseReply(latestEditBody); + isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html'; + if (parsedEditedContent === null) { + content = latestEditBody; + } else { + content = parsedEditedContent.content; + } + } + + if (haveReactions) { + reactions = []; + roomTimeline.reactionTimeline.get(mEvent.getId()).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 = initMatrix.matrixClient.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() === initMatrix.matrixClient.getUserId()), + }); + } + }); + } + + const senderMXIDColor = colorMXID(mEvent.sender.userId); + const userAvatar = isContentOnly ? null : ( + + ); + const userHeader = isContentOnly ? null : ( + + ); + const userReply = reply === null ? null : ( + + ); + const userContent = ( + + ); + const userReactions = reactions === null ? null : ( + + { + reactions.map((reaction) => ( + { + toggleEmoji(roomId, mEvent.getId(), reaction.key, roomTimeline); + }} + /> + )) + } + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + + ); + const userOptions = ( + + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + src={EmojiAddIC} + size="extra-small" + tooltip="Add reaction" + /> + { + viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); + }} + src={ReplyArrowIC} + size="extra-small" + tooltip="Reply" + /> + {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && ( + setEditEvent(mEvent)} + src={PencilIC} + size="extra-small" + tooltip="Edit" + /> + )} + ( + <> + Options + pickEmoji(e, roomId, mEvent.getId(), roomTimeline)} + > + Add reaciton + + { + viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); + }} + > + Reply + + {(mEvent.getSender() === mx.getUserId() && !isMedia(mEvent)) && ( + setEditEvent(mEvent)}>Edit + )} + openReadReceipts(roomId, mEvent.getId())} + > + Read receipts + + {(canIRedact || mEvent.getSender() === mx.getUserId()) && ( + <> + + { + if (window.confirm('Are you sure you want to delete this event')) { + redactEvent(roomId, mEvent.getId()); + } + }} + > + Delete + + + )} + + )} + render={(toggleMenu) => ( + + )} + /> + + ); + + const isEditingEvent = editEvent?.getId() === mEvent.getId(); + const myMessageEl = ( + { + if (newBody !== content) { + initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody); + } + setEditEvent(null); + }} + onCancel={() => setEditEvent(null)} + /> + ) : null} + reactions={userReactions} + options={editEvent !== null && isEditingEvent ? null : userOptions} + /> + ); + return myMessageEl; + } + function renderMessage(mEvent) { if (mEvent.getType() === 'm.room.create') return genChannelIntro(mEvent, roomTimeline); if ( @@ -472,7 +534,7 @@ function ChannelViewContent({ } if (mEvent.getType() !== 'm.room.member') { - const messageComp = genMessage(roomId, prevMEvent, mEvent, roomTimeline, viewEvent); + const messageComp = genMessage(mEvent); prevMEvent = mEvent; return ( diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index a921619..d1100cd 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -86,7 +86,10 @@ function getFormattedBody(markdown) { extensions: [gfm()], htmlExtensions: [gfmHtml], }); - return result; + const bodyParts = result.match(/^(

)(.*)(<\/p>)$/); + if (bodyParts === null) return result; + if (bodyParts[2].indexOf('

') >= 0) return result; + return bodyParts[2]; } function getReplyFormattedBody(roomId, reply) { @@ -212,8 +215,11 @@ class RoomsInput extends EventEmitter { msgtype: 'm.text', }; if (settings.isMarkdown) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = getFormattedBody(input.message); + const formattedBody = getFormattedBody(input.message); + if (formattedBody !== input.message) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = formattedBody; + } } if (typeof input.replyTo !== 'undefined') { content = bindReplyToContent(roomId, input.replyTo, content); @@ -326,6 +332,45 @@ class RoomsInput extends EventEmitter { } return { url }; } + + async sendEditedMessage(roomId, mEvent, editedBody) { + const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; + + const content = { + body: ` * ${editedBody}`, + msgtype: 'm.text', + 'm.new_content': { + body: editedBody, + msgtype: 'm.text', + }, + 'm.relates_to': { + event_id: mEvent.getId(), + rel_type: 'm.replace', + }, + }; + if (settings.isMarkdown) { + const formattedBody = getFormattedBody(editedBody); + if (formattedBody !== editedBody) { + content.formatted_body = ` * ${formattedBody}`; + content.format = 'org.matrix.custom.html'; + content['m.new_content'].formatted_body = formattedBody; + content['m.new_content'].format = 'org.matrix.custom.html'; + } + } + if (isReply) { + const evBody = mEvent.getContent().body; + const replyHead = evBody.slice(0, evBody.indexOf('\n\n')); + const evFBody = mEvent.getContent().formatted_body; + const fReplyHead = evFBody.slice(0, evFBody.indexOf('')); + + content.format = 'org.matrix.custom.html'; + content.formatted_body = `${fReplyHead}${(content.formatted_body || content.body)}`; + + content.body = `${replyHead}\n\n${content.body}`; + } + + this.matrixClient.sendMessage(roomId, content); + } } export default RoomsInput;