--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <g>
+ <g>
+ <rect x="9" y="8" width="2" height="8"/>
+ </g>
+ <g>
+ <rect x="13" y="8" width="2" height="8"/>
+ </g>
+ </g>
+ <path d="M21,3h-5l-1.4-1.4C14.2,1.2,13.7,1,13.2,1h-2.3c-0.5,0-1,0.2-1.4,0.6L8,3H3v2h2v14c0,1.1,0.9,2,2,2h10c1.1,0,2-0.9,2-2V5h2
+ V3z M17,19H7V5h10V19z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,20c-4.4,0-8-3.6-8-8s3.6-8,8-8V2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10h-2C20,16.4,16.4,20,12,20z"/>
+ <circle cx="9.5" cy="8.5" r="1.5"/>
+ <circle cx="14.5" cy="8.5" r="1.5"/>
+ <path d="M6,12c0,3.3,2.7,6,6,6s6-2.7,6-6h-2c0,2.2-1.8,4-4,4s-4-1.8-4-4H6z"/>
+ <polygon points="20.8,3.3 20.8,0 19.3,0 19.3,3.3 16,3.3 16,4.8 19.3,4.8 19.3,8 20.8,8 20.8,4.8 24,4.8 24,3.3 "/>
+</g>
+</svg>
<div className="text text-b1">
{ isMarkdown ? genMarkdown(content) : linkifyContent(content) }
</div>
- { isEdited && <Text className="message__edited" variant="b3">(edited)</Text>}
+ { isEdited && <Text className="message__content-edited" variant="b3">(edited)</Text>}
</div>
);
}
};
function genReactionMsg(userIds, reaction) {
- let msg = '';
+ const genLessContText = (text) => <span style={{ opacity: '.6' }}>{text}</span>;
+ 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))}
</>
);
onClick: PropTypes.func.isRequired,
};
+function MessageOptions({ children }) {
+ return (
+ <div className="message__options">
+ {children}
+ </div>
+ );
+}
+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 (
{reply !== null && reply}
{content}
{reactions !== null && reactions}
+ {options !== null && options}
</div>
</div>
);
header: null,
reply: null,
reactions: null,
+ options: null,
};
Message.propTypes = {
avatar: PropTypes.node,
reply: PropTypes.node,
content: PropTypes.node.isRequired,
reactions: PropTypes.node,
+ options: PropTypes.node,
};
export {
MessageContent,
MessageReactionGroup,
MessageReaction,
+ MessageOptions,
PlaceholderMessage,
};
&:hover {
background-color: var(--bg-surface-hover);
+ & .message__options {
+ display: flex;
+ }
}
[dir=rtl] & {
padding-top: 6px;
}
- &__avatar-container,
- &__profile {
+ &__avatar-container{
margin-right: var(--sp-tight);
[dir=rtl] & {
&__main-container {
flex: 1;
min-width: 0;
+
+ position: relative;
}
}
&__avatar-container {
width: var(--av-small);
}
- &__edited {
- color: var(--tc-surface-low);
- }
}
.ph-msg {
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;
& a {
word-break: break-all;
}
+ &-edited {
+ color: var(--tc-surface-low);
+ }
}
.message__reactions {
display: flex;
}
}
}
+.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 {
import Divider from '../../atoms/divider/Divider';
import Avatar from '../../atoms/avatar/Avatar';
+import IconButton from '../../atoms/button/IconButton';
import {
Message,
MessageHeader,
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;
}
</MessageReactionGroup>
);
+ const userOptions = (
+ <MessageOptions>
+ <IconButton
+ onClick={() => {
+ viewEvent.emit('reply_to', mEvent.getSender(), mEvent.getId(), isMedia(mEvent) ? mEvent.getContent().body : content);
+ }}
+ src={ReplyArrowIC}
+ size="extra-small"
+ tooltip="Reply"
+ />
+ <IconButton src={BinIC} size="extra-small" tooltip="Delete" />
+ </MessageOptions>
+ );
const myMessageEl = (
<Message
reply={userReply}
content={userContent}
reactions={userReactions}
+ options={userOptions}
/>
);
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';
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;
}) {
const [attachment, setAttachment] = useState(null);
const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
+ const [replyTo, setReplyTo] = useState(null);
const textAreaRef = useRef(null);
const inputBaseRef = useRef(null);
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);
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;
timelineScroll.reachBottom();
viewEvent.emit('message_sent');
textAreaRef.current.style.height = 'unset';
+ if (replyTo !== null) setReplyTo(null);
}
function processTyping(msg) {
);
}
+ function attachReply() {
+ return (
+ <div className="channel-reply">
+ <IconButton
+ onClick={() => {
+ roomsInput.cancelReplyTo(roomId);
+ setReplyTo(null);
+ }}
+ src={CrossIC}
+ tooltip="Cancel reply"
+ size="extra-small"
+ />
+ <MessageReply
+ userId={replyTo.userId}
+ name={getUsername(replyTo.userId)}
+ color={colorMXID(replyTo.userId)}
+ content={replyTo.content}
+ />
+ </div>
+ );
+ }
+
return (
<>
+ { replyTo !== null && attachReply()}
{ attachment !== null && attachFile() }
<form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
{
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
});
}
-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 = `<a href="https://matrix.to/#/${roomId}/${reply.eventId}">In reply to</a>`;
+ const userLink = `<a href="https://matrix.to/#/${reply.userId}">${reply.userId}</a>`;
+ return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${reply.content}</blockquote></mx-reply>`;
+}
+
+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();
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);
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 = {
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);
}
}
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);
}
html {
height: 100%;
+ overflow: hidden;
}
body {