diff --git a/public/res/ic/outlined/bin.svg b/public/res/ic/outlined/bin.svg new file mode 100644 index 00000000..984be625 --- /dev/null +++ b/public/res/ic/outlined/bin.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/public/res/ic/outlined/emoji-add.svg b/public/res/ic/outlined/emoji-add.svg new file mode 100644 index 00000000..c4cacef2 --- /dev/null +++ b/public/res/ic/outlined/emoji-add.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx index 5d6b575e..1e169bd1 100644 --- a/src/app/molecules/message/Message.jsx +++ b/src/app/molecules/message/Message.jsx @@ -113,7 +113,7 @@ function MessageContent({ content, isMarkdown, isEdited }) {
{ isMarkdown ? genMarkdown(content) : linkifyContent(content) }
- { isEdited && (edited)} + { isEdited && (edited)} ); } @@ -139,15 +139,19 @@ MessageReactionGroup.propTypes = { }; function genReactionMsg(userIds, reaction) { - let msg = ''; + const genLessContText = (text) => {text}; + let msg = <>; userIds.forEach((userId, index) => { - if (index === 0) msg += getUsername(userId); - else if (index === userIds.length - 1) msg += ` and ${getUsername(userId)}`; - else msg += `, ${getUsername(userId)}`; + 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} reacted with`} + {msg} + {genLessContText(' reacted with')} {parse(twemoji.parse(reaction))} ); @@ -179,8 +183,19 @@ MessageReaction.propTypes = { onClick: PropTypes.func.isRequired, }; +function MessageOptions({ children }) { + return ( +
+ {children} +
+ ); +} +MessageOptions.propTypes = { + children: PropTypes.node.isRequired, +}; + function Message({ - avatar, header, reply, content, reactions, + avatar, header, reply, content, reactions, options, }) { const msgClass = header === null ? ' message--content-only' : ' message--full'; return ( @@ -193,6 +208,7 @@ function Message({ {reply !== null && reply} {content} {reactions !== null && reactions} + {options !== null && options} ); @@ -202,6 +218,7 @@ Message.defaultProps = { header: null, reply: null, reactions: null, + options: null, }; Message.propTypes = { avatar: PropTypes.node, @@ -209,6 +226,7 @@ Message.propTypes = { reply: PropTypes.node, content: PropTypes.node.isRequired, reactions: PropTypes.node, + options: PropTypes.node, }; export { @@ -218,5 +236,6 @@ export { MessageContent, MessageReactionGroup, MessageReaction, + MessageOptions, PlaceholderMessage, }; diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss index f8a4108d..73484cf1 100644 --- a/src/app/molecules/message/Message.scss +++ b/src/app/molecules/message/Message.scss @@ -8,6 +8,9 @@ &:hover { background-color: var(--bg-surface-hover); + & .message__options { + display: flex; + } } [dir=rtl] & { @@ -21,8 +24,7 @@ padding-top: 6px; } - &__avatar-container, - &__profile { + &__avatar-container{ margin-right: var(--sp-tight); [dir=rtl] & { @@ -36,6 +38,8 @@ &__main-container { flex: 1; min-width: 0; + + position: relative; } } @@ -49,9 +53,6 @@ &__avatar-container { width: var(--av-small); } - &__edited { - color: var(--tc-surface-low); - } } .ph-msg { @@ -106,6 +107,12 @@ flex: 1; min-width: 0; color: var(--tc-surface-high); + margin-right: var(--sp-tight); + + [dir=rtl] & { + margin-left: var(--sp-tight); + margin-right: 0; + } & > .text { color: inherit; @@ -144,6 +151,9 @@ & a { word-break: break-all; } + &-edited { + color: var(--tc-surface-low); + } } .message__reactions { display: flex; @@ -205,6 +215,22 @@ } } } +.message__options { + position: absolute; + top: 0; + right: 60px; + transform: translateY(-50%); + + border-radius: var(--bo-radius); + box-shadow: var(--bs-surface-border); + background-color: var(--bg-surface-low); + display: none; + + [dir=rtl] & { + left: 60px; + right: unset; + } +} // markdown formating .message__content { diff --git a/src/app/organisms/channel/ChannelViewContent.jsx b/src/app/organisms/channel/ChannelViewContent.jsx index 4476b43f..737cbaac 100644 --- a/src/app/organisms/channel/ChannelViewContent.jsx +++ b/src/app/organisms/channel/ChannelViewContent.jsx @@ -13,6 +13,7 @@ import { diffMinutes, isNotInSameDay } from '../../../util/common'; import Divider from '../../atoms/divider/Divider'; import Avatar from '../../atoms/avatar/Avatar'; +import IconButton from '../../atoms/button/IconButton'; import { Message, MessageHeader, @@ -20,12 +21,16 @@ import { MessageContent, MessageReactionGroup, MessageReaction, + MessageOptions, PlaceholderMessage, } from '../../molecules/message/Message'; import * as Media from '../../molecules/media/Media'; import ChannelIntro from '../../molecules/channel-intro/ChannelIntro'; import TimelineChange from '../../molecules/message/TimelineChange'; +import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg'; +import BinIC from '../../../../public/res/ic/outlined/bin.svg'; + import { parseReply, parseTimelineChange } from './common'; const MAX_MSG_DIFF_MINUTES = 5; @@ -335,6 +340,19 @@ function ChannelViewContent({ } ); + const userOptions = ( + + { + viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content); + }} + src={ReplyArrowIC} + size="extra-small" + tooltip="Reply" + /> + + + ); const myMessageEl = ( ); diff --git a/src/app/organisms/channel/ChannelViewInput.jsx b/src/app/organisms/channel/ChannelViewInput.jsx index e3c90da1..63f47307 100644 --- a/src/app/organisms/channel/ChannelViewInput.jsx +++ b/src/app/organisms/channel/ChannelViewInput.jsx @@ -9,12 +9,15 @@ import initMatrix from '../../../client/initMatrix'; import cons from '../../../client/state/cons'; import settings from '../../../client/state/settings'; import { bytesToSize } from '../../../util/common'; +import { getUsername } from '../../../util/matrixUtil'; +import colorMXID from '../../../util/colorMXID'; import Text from '../../atoms/text/Text'; import RawIcon from '../../atoms/system-icons/RawIcon'; import IconButton from '../../atoms/button/IconButton'; import ContextMenu from '../../atoms/context-menu/ContextMenu'; import ScrollView from '../../atoms/scroll/ScrollView'; +import { MessageReply } from '../../molecules/message/Message'; import EmojiBoard from '../emoji-board/EmojiBoard'; import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; @@ -25,6 +28,7 @@ import VLCIC from '../../../../public/res/ic/outlined/vlc.svg'; import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg'; import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg'; import FileIC from '../../../../public/res/ic/outlined/file.svg'; +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; const CMD_REGEX = /(\/|>[#*@]|:)(\S*)$/; let isTyping = false; @@ -35,6 +39,7 @@ function ChannelViewInput({ }) { const [attachment, setAttachment] = useState(null); const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown); + const [replyTo, setReplyTo] = useState(null); const textAreaRef = useRef(null); const inputBaseRef = useRef(null); @@ -123,17 +128,24 @@ function ChannelViewInput({ deactivateCmd(); } + function setUpReply(userId, eventId, content) { + setReplyTo({ userId, eventId, content }); + roomsInput.setReplyTo(roomId, { userId, eventId, content }); + } + useEffect(() => { roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress); roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment); roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment); viewEvent.on('cmd_error', errorCmd); viewEvent.on('cmd_fired', firedCmd); + viewEvent.on('reply_to', setUpReply); if (textAreaRef?.current !== null) { isTyping = false; textAreaRef.current.focus(); textAreaRef.current.value = roomsInput.getMessage(roomId); setAttachment(roomsInput.getAttachment(roomId)); + setReplyTo(roomsInput.getReplyTo(roomId)); } return () => { roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress); @@ -141,6 +153,7 @@ function ChannelViewInput({ roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment); viewEvent.removeListener('cmd_error', errorCmd); viewEvent.removeListener('cmd_fired', firedCmd); + viewEvent.removeListener('reply_to', setUpReply); if (isCmdActivated) deactivateCmd(); if (textAreaRef?.current === null) return; @@ -180,6 +193,7 @@ function ChannelViewInput({ timelineScroll.reachBottom(); viewEvent.emit('message_sent'); textAreaRef.current.style.height = 'unset'; + if (replyTo !== null) setReplyTo(null); } function processTyping(msg) { @@ -316,8 +330,31 @@ function ChannelViewInput({ ); } + function attachReply() { + return ( +
+ { + roomsInput.cancelReplyTo(roomId); + setReplyTo(null); + }} + src={CrossIC} + tooltip="Cancel reply" + size="extra-small" + /> + +
+ ); + } + return ( <> + { replyTo !== null && attachReply()} { attachment !== null && attachFile() }
{ e.preventDefault(); }}> { diff --git a/src/app/organisms/channel/ChannelViewInput.scss b/src/app/organisms/channel/ChannelViewInput.scss index 915aa6d9..46c70394 100644 --- a/src/app/organisms/channel/ChannelViewInput.scss +++ b/src/app/organisms/channel/ChannelViewInput.scss @@ -98,4 +98,19 @@ background-color: var(--bg-caution); } } +} + +.channel-reply { + display: flex; + align-items: center; + background-color: var(--bg-surface-low); + border-bottom: 1px solid var(--bg-surface-border); + + & .ic-btn-surface { + margin: 0 13px 0 17px; + border-radius: 0; + [dir=rtl] & { + margin: 0 17px 0 13px; + } + } } \ No newline at end of file diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 970bd621..c298ff57 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -80,13 +80,32 @@ function getVideoThumbnail(video, width, height, mimeType) { }); } -function getFormatedBody(markdown) { +function getFormattedBody(markdown) { const reader = new Parser(); const writer = new HtmlRenderer(); const parsed = reader.parse(markdown); return writer.render(parsed); } +function getReplyFormattedBody(roomId, reply) { + const replyToLink = `In reply to`; + const userLink = `${reply.userId}`; + return `
${replyToLink}${userLink}
${reply.content}
`; +} + +function bindReplyToContent(roomId, reply, content) { + const newContent = { ...content }; + newContent.body = `> <${reply.userId}> ${reply.content}`; + newContent.body += `\n\n${content.body}`; + newContent.format = 'org.matrix.custom.html'; + newContent['m.relates_to'] = content['m.relates_to'] || {}; + newContent['m.relates_to']['m.in_reply_to'] = { event_id: reply.eventId }; + + const formattedReply = getReplyFormattedBody(roomId, reply); + newContent.formatted_body = formattedReply + (content.formatted_body || content.body); + return newContent; +} + class RoomsInput extends EventEmitter { constructor(mx) { super(); @@ -98,6 +117,7 @@ class RoomsInput extends EventEmitter { cleanEmptyEntry(roomId) { const input = this.getInput(roomId); const isEmpty = typeof input.attachment === 'undefined' + && typeof input.replyTo === 'undefined' && (typeof input.message === 'undefined' || input.message === ''); if (isEmpty) { this.roomIdToInput.delete(roomId); @@ -121,6 +141,25 @@ class RoomsInput extends EventEmitter { return input.message; } + setReplyTo(roomId, replyTo) { + const input = this.getInput(roomId); + input.replyTo = replyTo; + this.roomIdToInput.set(roomId, input); + } + + getReplyTo(roomId) { + const input = this.getInput(roomId); + if (typeof input.replyTo === 'undefined') return null; + return input.replyTo; + } + + cancelReplyTo(roomId) { + const input = this.getInput(roomId); + if (typeof input.replyTo === 'undefined') return; + delete input.replyTo; + this.roomIdToInput.set(roomId, input); + } + setAttachment(roomId, file) { const input = this.getInput(roomId); input.attachment = { @@ -145,13 +184,9 @@ class RoomsInput extends EventEmitter { this.matrixClient.cancelUpload(uploadingPromise); delete input.attachment.uploadingPromise; } - if (input.message) { - delete input.attachment; - delete input.isSending; - this.roomIdToInput.set(roomId, input); - } else { - this.roomIdToInput.delete(roomId); - } + delete input.attachment; + delete input.isSending; + this.roomIdToInput.set(roomId, input); this.emit(cons.events.roomsInput.ATTACHMENT_CANCELED, roomId); } @@ -168,13 +203,16 @@ class RoomsInput extends EventEmitter { } if (this.getMessage(roomId).trim() !== '') { - const content = { + let content = { body: input.message, msgtype: 'm.text', }; if (settings.isMarkdown) { content.format = 'org.matrix.custom.html'; - content.formatted_body = getFormatedBody(input.message); + content.formatted_body = getFormattedBody(input.message); + } + if (typeof input.replyTo !== 'undefined') { + content = bindReplyToContent(roomId, input.replyTo, content); } this.matrixClient.sendMessage(roomId, content); } diff --git a/src/index.scss b/src/index.scss index 38bfb762..a3819a95 100644 --- a/src/index.scss +++ b/src/index.scss @@ -221,6 +221,7 @@ html { height: 100%; + overflow: hidden; } body {