Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Weblate 2018-05-02 12:38:49 +00:00
commit 6504201190
19 changed files with 584 additions and 383 deletions

View file

@ -53,7 +53,7 @@
@import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_MemberEventListSummary.scss";
@import "./views/elements/_ProgressBar.scss";
@import "./views/elements/_Quote.scss";
@import "./views/elements/_ReplyThread.scss";
@import "./views/elements/_RichText.scss";
@import "./views/elements/_RoleButton.scss";
@import "./views/elements/_Spinner.scss";
@ -89,7 +89,7 @@
@import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_QuotePreview.scss";
@import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_RoomDropTarget.scss";
@import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss";

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,13 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_Quote .mx_DateSeparator {
.mx_ReplyThread .mx_DateSeparator {
font-size: 1em !important;
margin-bottom: 0;
padding-bottom: 1px;
bottom: -5px;
}
.mx_Quote_show {
.mx_ReplyThread_show {
cursor: pointer;
}
blockquote.mx_ReplyThread {
margin-left: 0;
padding-left: 10px;
border-left: 4px solid $blockquote-bar-color;
}

View file

@ -84,7 +84,7 @@ limitations under the License.
position: absolute;
}
.mx_EventTile_line {
.mx_EventTile_line, .mx_EventTile_reply {
position: relative;
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
@ -96,7 +96,7 @@ limitations under the License.
line-height: 22px;
}
.mx_EventTile_quote {
.mx_EventTile_reply {
margin-right: 10px;
}
@ -119,7 +119,7 @@ limitations under the License.
background-color: $event-selected-color;
}
.mx_EventTile:hover .mx_EventTile_line:not(.mx_EventTile_quote),
.mx_EventTile:hover .mx_EventTile_line,
.mx_EventTile.menu .mx_EventTile_line
{
background-color: $event-selected-color;
@ -157,7 +157,8 @@ limitations under the License.
color: $event-notsent-color;
}
.mx_EventTile_redacted .mx_EventTile_line .mx_UnknownBody {
.mx_EventTile_redacted .mx_EventTile_line .mx_UnknownBody,
.mx_EventTile_redacted .mx_EventTile_reply .mx_UnknownBody {
display: block;
width: 100%;
height: 22px;
@ -202,10 +203,10 @@ limitations under the License.
text-decoration: none;
}
.mx_EventTile_last .mx_MessageTimestamp,
.mx_EventTile:hover .mx_MessageTimestamp,
.mx_EventTile.menu .mx_MessageTimestamp
{
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile_last > div > a > .mx_MessageTimestamp,
.mx_EventTile:hover > div > a > .mx_MessageTimestamp,
.mx_EventTile.menu > div > a > .mx_MessageTimestamp {
visibility: visible;
}
@ -235,12 +236,7 @@ limitations under the License.
}
.mx_EventTile:hover .mx_EventTile_editButton,
.mx_EventTile.menu .mx_EventTile_editButton
{
visibility: visible;
}
.mx_EventTile.menu .mx_MessageTimestamp {
.mx_EventTile.menu .mx_EventTile_editButton {
visibility: visible;
}
@ -358,8 +354,9 @@ limitations under the License.
border-left: $e2e-unverified-color 5px solid;
}
.mx_EventTile:hover.mx_EventTile_verified .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unverified .mx_MessageTimestamp {
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp {
left: 3px;
width: auto;
}
@ -370,8 +367,9 @@ limitations under the License.
}
*/
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_e2eIcon,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_e2eIcon {
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon {
display: block;
left: 41px;
}
@ -466,7 +464,7 @@ limitations under the License.
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
padding-top: 0px;
font-size: 13px;
.mx_EventTile_line {
.mx_EventTile_line, .mx_EventTile_reply {
line-height: 20px;
}
.mx_EventTile_avatar {
@ -484,7 +482,7 @@ limitations under the License.
.mx_EventTile_avatar {
top: 2px;
}
.mx_EventTile_line {
.mx_EventTile_line, .mx_EventTile_reply {
padding-top: 0px;
padding-bottom: 1px;
}
@ -492,13 +490,13 @@ limitations under the License.
.mx_EventTile.mx_EventTile_emote.mx_EventTile_continuation {
padding-top: 0;
.mx_EventTile_line {
.mx_EventTile_line, .mx_EventTile_reply {
padding-top: 0px;
padding-bottom: 0px;
}
}
.mx_EventTile_line {
.mx_EventTile_line, .mx_EventTile_reply {
padding-top: 0px;
padding-bottom: 0px;
}

View file

@ -1,36 +0,0 @@
.mx_QuotePreview {
position: absolute;
bottom: 0;
z-index: 1000;
width: 100%;
border: 1px solid $primary-hairline-color;
background: $primary-bg-color;
border-bottom: none;
border-radius: 4px 4px 0 0;
max-height: 50vh;
overflow: auto
}
.mx_QuotePreview_section {
border-bottom: 1px solid $primary-hairline-color;
}
.mx_QuotePreview_header {
margin: 12px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_QuotePreview_title {
float: left;
}
.mx_QuotePreview_cancel {
float: right;
cursor: pointer;
}
.mx_QuotePreview_clear {
clear: both;
}

View file

@ -0,0 +1,52 @@
/*
Copyright 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ReplyPreview {
position: absolute;
bottom: 0;
z-index: 1000;
width: 100%;
border: 1px solid $primary-hairline-color;
background: $primary-bg-color;
border-bottom: none;
border-radius: 4px 4px 0 0;
max-height: 50vh;
overflow: auto
}
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
}
.mx_ReplyPreview_header {
margin: 12px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_ReplyPreview_title {
float: left;
}
.mx_ReplyPreview_cancel {
float: right;
cursor: pointer;
}
.mx_ReplyPreview_clear {
clear: both;
}

View file

@ -17,6 +17,8 @@ limitations under the License.
'use strict';
import ReplyThread from "./components/views/elements/ReplyThread";
const React = require('react');
const sanitizeHtml = require('sanitize-html');
const highlight = require('highlight.js');
@ -184,6 +186,7 @@ const sanitizeHtmlParams = {
],
allowedAttributes: {
// custom ones first:
blockquote: ['data-mx-reply'], // used to allow explicit removal of a reply fallback blockquote, value ignored
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
@ -408,12 +411,14 @@ class TextHighlighter extends BaseHighlighter {
*
* opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class.
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
*/
export function bodyToHtml(content, highlights, opts={}) {
let isHtml = (content.format === "org.matrix.custom.html");
let isHtml = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
let strippedBody;
let safeBody;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
@ -431,17 +436,22 @@ export function bodyToHtml(content, highlights, opts={}) {
};
}
bodyHasEmoji = containsEmoji(isHtml ? content.formatted_body : content.body);
let formattedBody = content.formatted_body;
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
bodyHasEmoji = containsEmoji(isHtml ? formattedBody : content.body);
// Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtml) {
safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams);
safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams);
} else {
// ... or if there are emoji, which we insert as HTML alongside the
// escaped plaintext body.
if (bodyHasEmoji) {
isHtml = true;
safeBody = sanitizeHtml(escape(content.body), sanitizeHtmlParams);
safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams);
}
}
@ -458,7 +468,7 @@ export function bodyToHtml(content, highlights, opts={}) {
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0;
const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
const contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
}
@ -471,7 +481,7 @@ export function bodyToHtml(content, highlights, opts={}) {
return isHtml ?
<span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span className={className} dir="auto">{ content.body }</span>;
<span className={className} dir="auto">{ strippedBody }</span>;
}
export function emojifyText(text) {

View file

@ -908,17 +908,17 @@ module.exports = React.createClass({
this.setState({ draggingFile: false });
},
uploadFile: function(file) {
uploadFile: async function(file) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
return;
}
ContentMessages.sendContentToRoom(
file, this.state.room.roomId, MatrixClientPeg.get(),
).catch((error) => {
try {
await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get());
} catch (error) {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
// Let the status bar handle this
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -928,6 +928,14 @@ module.exports = React.createClass({
description: ((error && error.message)
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
});
// bail early to avoid calling the dispatch below
return;
}
// Send message_sent callback, for things like _checkIfAlone because after all a file is still a message.
dis.dispatch({
action: 'message_sent',
});
},

View file

@ -18,6 +18,7 @@ limitations under the License.
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
@ -34,13 +35,16 @@ module.exports = React.createClass({
propTypes: {
/* the MatrixEvent associated with the context menu */
mxEvent: React.PropTypes.object.isRequired,
mxEvent: PropTypes.object.isRequired,
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
eventTileOps: React.PropTypes.object,
eventTileOps: PropTypes.object,
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyThread: PropTypes.func,
/* callback called when the menu is dismissed */
onFinished: React.PropTypes.func,
onFinished: PropTypes.func,
},
getInitialState: function() {
@ -182,12 +186,17 @@ module.exports = React.createClass({
onReplyClick: function() {
dis.dispatch({
action: 'quote_event',
action: 'reply_to_event',
event: this.props.mxEvent,
});
this.closeMenu();
},
onCollapseReplyThreadClick: function() {
this.props.collapseReplyThread();
this.closeMenu();
},
render: function() {
const eventStatus = this.props.mxEvent.status;
let resendButton;
@ -200,6 +209,7 @@ module.exports = React.createClass({
let externalURLButton;
let quoteButton;
let replyButton;
let collapseReplyThread;
if (eventStatus === 'not_sent') {
resendButton = (
@ -305,6 +315,13 @@ module.exports = React.createClass({
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<div className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
{ _t('Collapse Reply Thread') }
</div>
);
}
return (
<div>
@ -320,6 +337,7 @@ module.exports = React.createClass({
{ quoteButton }
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
</div>
);
},

View file

@ -1,188 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
import {makeUserPermalink} from "../../../matrix-to";
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
const REGEX_LOCAL_MATRIXTO = /^#\/room\/([\#\!][^\/]*)\/(\$[^\/]*)$/;
export default class Quote extends React.Component {
static isMessageUrl(url) {
return !!REGEX_LOCAL_MATRIXTO.exec(url);
}
static childContextTypes = {
matrixClient: PropTypes.object,
addRichQuote: PropTypes.func,
};
static propTypes = {
// The matrix.to url of the event
url: PropTypes.string,
// The original node that was rendered
node: PropTypes.instanceOf(Element),
// The parent event
parentEv: PropTypes.instanceOf(MatrixEvent),
};
constructor(props, context) {
super(props, context);
this.state = {
// The event related to this quote and their nested rich quotes
events: [],
// Whether the top (oldest) event should be shown or spoilered
show: true,
// Whether an error was encountered fetching nested older event, show node if it does
err: false,
};
this.onQuoteClick = this.onQuoteClick.bind(this);
this.addRichQuote = this.addRichQuote.bind(this);
}
getChildContext() {
return {
matrixClient: MatrixClientPeg.get(),
addRichQuote: this.addRichQuote,
};
}
parseUrl(url) {
if (!url) return;
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = REGEX_LOCAL_MATRIXTO.exec(url) || [];
const [, roomIdentifier, eventId] = matrixToMatch;
return {roomIdentifier, eventId};
}
componentWillReceiveProps(nextProps) {
const {roomIdentifier, eventId} = this.parseUrl(nextProps.url);
if (!roomIdentifier || !eventId) return;
const room = this.getRoom(roomIdentifier);
if (!room) return;
// Only try and load the event if we know about the room
// otherwise we just leave a `Quote` anchor which can be used to navigate/join the room manually.
this.setState({ events: [] });
if (room) this.getEvent(room, eventId, true);
}
componentWillMount() {
this.componentWillReceiveProps(this.props);
}
getRoom(id) {
const cli = MatrixClientPeg.get();
if (id[0] === '!') return cli.getRoom(id);
return cli.getRooms().find((r) => {
return r.getAliases().includes(id);
});
}
async getEvent(room, eventId, show) {
const event = room.findEventById(eventId);
if (event) {
this.addEvent(event, show);
return;
}
await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
this.addEvent(room.findEventById(eventId), show);
}
addEvent(event, show) {
const events = [event].concat(this.state.events);
this.setState({events, show});
}
// addRichQuote(roomId, eventId) {
addRichQuote(href) {
const {roomIdentifier, eventId} = this.parseUrl(href);
if (!roomIdentifier || !eventId) {
this.setState({ err: true });
return;
}
const room = this.getRoom(roomIdentifier);
if (!room) {
this.setState({ err: true });
return;
}
this.getEvent(room, eventId, false);
}
onQuoteClick() {
this.setState({ show: true });
}
render() {
const events = this.state.events.slice();
if (events.length) {
const evTiles = [];
if (!this.state.show) {
const oldestEv = events.shift();
const Pill = sdk.getComponent('elements.Pill');
const room = MatrixClientPeg.get().getRoom(oldestEv.getRoomId());
evTiles.push(<blockquote className="mx_Quote" key="load">
{
_t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_Quote_show">{ sub }</a>,
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
url={makeUserPermalink(oldestEv.getSender())} shouldShowPillAvatar={true} />,
})
}
</blockquote>);
}
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
events.forEach((ev) => {
let dateSep = null;
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
evTiles.push(<blockquote className="mx_Quote" key={ev.getId()}>
{ dateSep }
<EventTile mxEvent={ev} tileShape="quote" />
</blockquote>);
});
return <div>{ evTiles }</div>;
}
// Deliberately render nothing if the URL isn't recognised
// in case we get an undefined/falsey node, replace it with null to make React happy
return this.props.node || null;
}
}

View file

@ -0,0 +1,306 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
// This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
// be low as each event being loaded (after the first) is triggered by an explicit user action.
export default class ReplyThread extends React.Component {
static propTypes = {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onWidgetLoad: PropTypes.func.isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
constructor(props, context) {
super(props, context);
this.state = {
// The loaded events to be rendered as linear-replies
events: [],
// The latest loaded event which has not yet been shown
loadedEv: null,
// Whether the component is still loading more events
loading: true,
// Whether as error was encountered fetching a replied to event.
err: false,
};
this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this);
}
static async getEvent(room, eventId) {
const event = room.findEventById(eventId);
if (event) return event;
try {
// ask the client to fetch the event we want using the context API, only interface to do so is to ask
// for a timeline with that event, but once it is loaded we can use findEventById to look up the ev map
await this.context.matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
} catch (e) {
// if it fails catch the error and return early, there's no point trying to find the event in this case.
// Return null as it is falsey and thus should be treated as an error (as the event cannot be resolved).
return null;
}
return room.findEventById(eventId);
}
static getParentEventId(ev) {
if (!ev || ev.isRedacted()) return;
const mRelatesTo = ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
}
}
// Part of Replies fallback support
static stripPlainReply(body) {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === '') lines.shift();
return lines.join('\n');
}
// Part of Replies fallback support
static stripHTMLReply(html) {
return html.replace(/^<blockquote data-mx-reply>[\s\S]+?<\/blockquote>/, '');
}
// Part of Replies fallback support
static getNestedReplyText(ev) {
if (!ev) return null;
let {body, formatted_body: html} = ev.getContent();
if (this.getParentEventId(ev)) {
if (body) body = this.stripPlainReply(body);
if (html) html = this.stripHTMLReply(html);
}
const evLink = makeEventPermalink(ev.getRoomId(), ev.getId());
const userLink = makeUserPermalink(ev.getSender());
const mxid = ev.getSender();
// This fallback contains text that is explicitly EN.
switch (ev.getContent().msgtype) {
case 'm.text':
case 'm.notice': {
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html || body}</blockquote>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
case 'm.image':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an image.</blockquote>`;
body = `> <${mxid}> sent an image.\n\n`;
break;
case 'm.video':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a video.</blockquote>`;
body = `> <${mxid}> sent a video.\n\n`;
break;
case 'm.audio':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an audio file.</blockquote>`;
body = `> <${mxid}> sent an audio file.\n\n`;
break;
case 'm.file':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a file.</blockquote>`;
body = `> <${mxid}> sent a file.\n\n`;
break;
case 'm.emote': {
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html || body}</blockquote>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
}
break;
}
default:
return null;
}
return {body, html};
}
static makeReplyMixIn(ev) {
if (!ev) return {};
return {
'm.relates_to': {
'm.in_reply_to': {
'event_id': ev.getId(),
},
},
};
}
static makeThread(parentEv, onWidgetLoad, ref) {
if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !ReplyThread.getParentEventId(parentEv)) {
return <div />;
}
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;
}
componentWillMount() {
this.unmounted = false;
this.room = this.context.matrixClient.getRoom(this.props.parentEv.getRoomId());
this.initialize();
}
componentDidUpdate() {
this.props.onWidgetLoad();
}
componentWillUnmount() {
this.unmounted = true;
}
async initialize() {
const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await ReplyThread.getEvent(this.room, ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return;
if (ev) {
this.setState({
events: [ev],
}, this.loadNextEvent);
} else {
this.setState({err: true});
}
}
async loadNextEvent() {
if (this.unmounted) return;
const ev = this.state.events[0];
const inReplyToEventId = ReplyThread.getParentEventId(ev);
if (!inReplyToEventId) {
this.setState({
loading: false,
});
return;
}
const loadedEv = await ReplyThread.getEvent(this.room, inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else {
this.setState({err: true});
}
}
canCollapse() {
return this.state.events.length > 1;
}
collapse() {
this.initialize();
}
onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events];
this.setState({
loadedEv: null,
events,
}, this.loadNextEvent);
dis.dispatch({action: 'focus_composer'});
}
render() {
let header = null;
if (this.state.err) {
header = <blockquote className="mx_ReplyThread mx_ReplyThread_error">
{
_t('Unable to load event that was replied to, ' +
'it either does not exist or you do not have permission to view it.')
}
</blockquote>;
} else if (this.state.loadedEv) {
const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.matrixClient.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread">
{
_t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
url={makeUserPermalink(ev.getSender())} shouldShowPillAvatar={true} />,
})
}
</blockquote>;
} else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />;
}
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => {
let dateSep = null;
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile mxEvent={ev}
tileShape="reply"
onWidgetLoad={this.props.onWidgetLoad}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</blockquote>;
});
return <div>
<div>{ header }</div>
<div>{ evTiles }</div>
</div>;
}
}

View file

@ -35,6 +35,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread";
linkifyMatrix(linkify);
@ -61,10 +62,6 @@ module.exports = React.createClass({
tileShape: PropTypes.string,
},
contextTypes: {
addRichQuote: PropTypes.func,
},
getInitialState: function() {
return {
// the URLs (if any) to be previewed with a LinkPreviewWidget
@ -186,7 +183,6 @@ module.exports = React.createClass({
// If the link is a (localised) matrix.to link, replace it with a pill
const Pill = sdk.getComponent('elements.Pill');
const Quote = sdk.getComponent('elements.Quote');
if (Pill.isMessagePillUrl(href)) {
const pillContainer = document.createElement('span');
@ -205,21 +201,6 @@ module.exports = React.createClass({
// update the current node with one that's now taken its place
node = pillContainer;
} else if (SettingsStore.isFeatureEnabled("feature_rich_quoting") && Quote.isMessageUrl(href)) {
if (this.context.addRichQuote) { // We're already a Rich Quote so just append the next one above
this.context.addRichQuote(href);
node.remove();
} else { // We're the first in the chain
const quoteContainer = document.createElement('span');
const quote =
<Quote url={href} parentEv={this.props.mxEvent} node={node} />;
ReactDOM.render(quote, quoteContainer);
node.parentNode.replaceChild(quoteContainer, node);
node = quoteContainer;
}
pillified = true;
}
} else if (node.nodeType == Node.TEXT_NODE) {
const Pill = sdk.getComponent('elements.Pill');
@ -441,8 +422,12 @@ module.exports = React.createClass({
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const stripReply = SettingsStore.isFeatureEnabled("feature_rich_quoting") &&
ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'),
// Part of Replies fallback support
stripReplyFallback: stripReply,
});
if (this.props.highlightLink) {

View file

@ -18,6 +18,8 @@ limitations under the License.
'use strict';
import ReplyThread from "../elements/ReplyThread";
const React = require('react');
import PropTypes from 'prop-types';
const classNames = require("classnames");
@ -153,6 +155,11 @@ module.exports = withMatrixClient(React.createClass({
isTwelveHour: PropTypes.bool,
},
defaultProps: {
// no-op function because onWidgetLoad is optional yet some subcomponents assume its existence
onWidgetLoad: function() {},
},
getInitialState: function() {
return {
// Whether the context menu is being displayed.
@ -301,12 +308,16 @@ module.exports = withMatrixClient(React.createClass({
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const self = this;
const {tile, replyThread} = this.refs;
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
eventTileOps: this.refs.tile && this.refs.tile.getEventTileOps ? this.refs.tile.getEventTileOps() : undefined,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
onFinished: function() {
self.setState({menu: false});
},
@ -543,7 +554,7 @@ module.exports = withMatrixClient(React.createClass({
if (needsSenderProfile) {
let text = null;
if (!this.props.tileShape || this.props.tileShape === 'quote') {
if (!this.props.tileShape) {
if (msgtype === 'm.image') text = _td('%(senderName)s sent an image');
else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video');
else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file');
@ -647,18 +658,23 @@ module.exports = withMatrixClient(React.createClass({
</div>
);
}
case 'quote': {
case 'reply':
case 'reply_preview': {
return (
<div className={classes}>
{ avatar }
{ sender }
<div className="mx_EventTile_line mx_EventTile_quote">
<div className="mx_EventTile_reply">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{
this.props.tileShape === 'reply_preview'
&& ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread')
}
<EventTileType ref="tile"
tileShape="quote"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
@ -681,6 +697,7 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{ ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}

View file

@ -111,6 +111,14 @@ export default class MessageComposer extends React.Component {
</li>);
}
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
let replyToWarning = null;
if (isQuoting) {
replyToWarning = <p>{
_t('At this time it is not possible to reply with a file so this will be sent without being a reply.')
}</p>;
}
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
title: _t('Upload Files'),
description: (
@ -119,6 +127,7 @@ export default class MessageComposer extends React.Component {
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ fileList }
</ul>
{ replyToWarning }
</div>
),
onFinished: (shouldUpload) => {

View file

@ -51,9 +51,11 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g')
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
import QuotePreview from "./QuotePreview";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
@ -273,7 +275,7 @@ export default class MessageComposerInput extends React.Component {
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'quote_event':
case 'reply_to_event':
case 'focus_composer':
editor.focus();
break;
@ -751,16 +753,14 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const quotingEv = RoomViewStore.getQuotingEvent();
const replyingToEv = RoomViewStore.getQuotingEvent();
const mustSendHTML = Boolean(replyingToEv);
if (this.state.isRichtextEnabled) {
// We should only send HTML if any block is styled or contains inline style
let shouldSendHTML = false;
// If we are quoting we need HTML Content
if (quotingEv) {
shouldSendHTML = true;
}
if (mustSendHTML) shouldSendHTML = true;
const blocks = contentState.getBlocksAsArray();
if (blocks.some((block) => block.getType() !== 'unstyled')) {
@ -820,15 +820,15 @@ export default class MessageComposerInput extends React.Component {
const md = new Markdown(pt);
// if contains no HTML and we're not quoting (needing HTML)
if (md.isPlainText() && !quotingEv) {
if (md.isPlainText() && !mustSendHTML) {
contentText = md.toPlaintext();
} else {
contentHTML = md.toHTML();
}
}
let sendHtmlFn = this.client.sendHtmlMessage;
let sendTextFn = this.client.sendTextMessage;
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(
contentState,
@ -836,45 +836,54 @@ export default class MessageComposerInput extends React.Component {
);
if (contentText.startsWith('/me')) {
if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
title: _t("Unable to reply"),
description: _t("At this time it is not possible to reply with an emote."),
});
return false;
}
contentText = contentText.substring(4);
// bit of a hack, but the alternative would be quite complicated
if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, '');
sendHtmlFn = this.client.sendHtmlEmote;
sendTextFn = this.client.sendEmoteMessage;
sendHtmlFn = ContentHelpers.makeHtmlEmote;
sendTextFn = ContentHelpers.makeEmoteMessage;
}
if (quotingEv) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(quotingEv.getRoomId());
const sender = room.currentState.getMember(quotingEv.getSender());
const {body/*, formatted_body*/} = quotingEv.getContent();
let content = contentHTML ? sendHtmlFn(contentText, contentHTML) : sendTextFn(contentText);
const perma = makeEventPermalink(quotingEv.getRoomId(), quotingEv.getId());
contentText = `${sender.name}:\n> ${body}\n\n${contentText}`;
contentHTML = `<a href="${perma}">Quote<br></a>${contentHTML}`;
if (replyingToEv) {
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
content = Object.assign(replyContent, content);
// we have finished quoting, clear the quotingEvent
// Part of Replies fallback support - prepend the text we're sending with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
}
content.body = nestedReply.body + content.body;
}
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
dis.dispatch({
action: 'quote_event',
action: 'reply_to_event',
event: null,
});
}
let sendMessagePromise;
if (contentHTML) {
sendMessagePromise = sendHtmlFn.call(
this.client, this.props.room.roomId, contentText, contentHTML,
);
} else {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
}
sendMessagePromise.done((res) => {
this.client.sendMessage(this.props.room.roomId, content).then((res) => {
dis.dispatch({
action: 'message_sent',
});
}, (e) => onSendMessageFailed(e, this.props.room));
}).catch((e) => {
onSendMessageFailed(e, this.props.room);
});
this.setState({
editorState: this.createEditorState(),
@ -1173,7 +1182,7 @@ export default class MessageComposerInput extends React.Component {
return (
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_autocomplete_wrapper">
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <QuotePreview /> }
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <ReplyPreview /> }
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}

View file

@ -19,15 +19,16 @@ import dis from '../../../dispatcher';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
function cancelQuoting() {
dis.dispatch({
action: 'quote_event',
action: 'reply_to_event',
event: null,
});
}
export default class QuotePreview extends React.Component {
export default class ReplyPreview extends React.Component {
constructor(props, context) {
super(props, context);
@ -61,17 +62,20 @@ export default class QuotePreview extends React.Component {
const EventTile = sdk.getComponent('rooms.EventTile');
const EmojiText = sdk.getComponent('views.elements.EmojiText');
return <div className="mx_QuotePreview">
<div className="mx_QuotePreview_section">
<EmojiText element="div" className="mx_QuotePreview_header mx_QuotePreview_title">
return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<EmojiText element="div" className="mx_ReplyPreview_header mx_ReplyPreview_title">
{ '💬 ' + _t('Replying') }
</EmojiText>
<div className="mx_QuotePreview_header mx_QuotePreview_cancel">
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={cancelQuoting} />
</div>
<div className="mx_QuotePreview_clear" />
<EventTile mxEvent={this.state.event} last={true} tileShape="quote" />
<div className="mx_ReplyPreview_clear" />
<EventTile last={true}
tileShape="reply_preview"
mxEvent={this.state.event}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</div>
</div>;
}

View file

@ -359,6 +359,7 @@
"Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
"Attachment": "Attachment",
"At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.",
"Upload Files": "Upload Files",
"Are you sure you want to upload the following files?": "Are you sure you want to upload the following files?",
"Encrypted room": "Encrypted room",
@ -379,6 +380,8 @@
"Server error": "Server error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
"Command error": "Command error",
"Unable to reply": "Unable to reply",
"At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.",
"bold": "bold",
"italic": "italic",
"strike": "strike",
@ -406,9 +409,9 @@
"Idle": "Idle",
"Offline": "Offline",
"Unknown": "Unknown",
"Replying": "Replying",
"Seen by %(userName)s at %(dateTime)s": "Seen by %(userName)s at %(dateTime)s",
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
"Replying": "Replying",
"No rooms to show": "No rooms to show",
"Unnamed room": "Unnamed room",
"World readable": "World readable",
@ -729,6 +732,7 @@
"expand": "expand",
"Custom of %(powerLevel)s": "Custom of %(powerLevel)s",
"Custom level": "Custom level",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.",
"<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",
"Room directory": "Room directory",
"Start chat": "Start chat",
@ -862,6 +866,7 @@
"Permalink": "Permalink",
"Quote": "Quote",
"Source URL": "Source URL",
"Collapse Reply Thread": "Collapse Reply Thread",
"Failed to set Direct Message status of room": "Failed to set Direct Message status of room",
"All messages (noisy)": "All messages (noisy)",
"All messages": "All messages",

View file

@ -111,10 +111,11 @@ class RoomViewStore extends Store {
forwardingEvent: payload.event,
});
break;
case 'quote_event':
case 'reply_to_event':
this._setState({
quotingEvent: payload.event,
replyingToEvent: payload.event,
});
break;
}
}
@ -132,8 +133,8 @@ class RoomViewStore extends Store {
shouldPeek: payload.should_peek === undefined ? true : payload.should_peek,
// have we sent a join request for this room and are waiting for a response?
joining: payload.joining || false,
// Reset quotingEvent because we don't want cross-room because bad UX
quotingEvent: null,
// Reset replyingToEvent because we don't want cross-room because bad UX
replyingToEvent: null,
};
if (this._state.forwardingEvent) {
@ -297,7 +298,7 @@ class RoomViewStore extends Store {
// The mxEvent if one is currently being replied to/quoted
getQuotingEvent() {
return this._state.quotingEvent;
return this._state.replyingToEvent;
}
shouldPeek() {

View file

@ -75,39 +75,37 @@ describe('MessageComposerInput', () => {
});
it('should not send messages when composer is empty', () => {
const textSpy = sinon.spy(client, 'sendTextMessage');
const htmlSpy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(true);
mci.handleReturn(sinon.stub());
expect(textSpy.calledOnce).toEqual(false, 'should not send text message');
expect(htmlSpy.calledOnce).toEqual(false, 'should not send html message');
expect(spy.calledOnce).toEqual(false, 'should not send message');
});
it('should not change content unnecessarily on RTE -> Markdown conversion', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(true);
addTextToDraft('a');
mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('a');
expect(spy.args[0][1].body).toEqual('a');
});
it('should not change content unnecessarily on Markdown -> RTE conversion', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('a');
mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('a');
expect(spy.args[0][1].body).toEqual('a');
});
it('should send emoji messages when rich text is enabled', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(true);
addTextToDraft('☹');
mci.handleReturn(sinon.stub());
@ -116,7 +114,7 @@ describe('MessageComposerInput', () => {
});
it('should send emoji messages when Markdown is enabled', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('☹');
mci.handleReturn(sinon.stub());
@ -149,98 +147,98 @@ describe('MessageComposerInput', () => {
// });
it('should insert formatting characters in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
mci.handleKeyCommand('italic');
mci.handleReturn(sinon.stub());
expect(['__', '**']).toContain(spy.args[0][1]);
expect(['__', '**']).toContain(spy.args[0][1].body);
});
it('should not entity-encode " in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('"');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('"');
expect(spy.args[0][1].body).toEqual('"');
});
it('should escape characters without other markup in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('\\*escaped\\*');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('*escaped*');
expect(spy.args[0][1].body).toEqual('*escaped*');
});
it('should escape characters with other markup in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('\\*escaped\\* *italic*');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('\\*escaped\\* *italic*');
expect(spy.args[0][2]).toEqual('*escaped* <em>italic</em>');
expect(spy.args[0][1].body).toEqual('\\*escaped\\* *italic*');
expect(spy.args[0][1].formatted_body).toEqual('*escaped* <em>italic</em>');
});
it('should not convert -_- into a horizontal rule in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('-_-');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('-_-');
expect(spy.args[0][1].body).toEqual('-_-');
});
it('should not strip <del> tags in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('<del>striked-out</del>');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('<del>striked-out</del>');
expect(spy.args[0][2]).toEqual('<del>striked-out</del>');
expect(spy.args[0][1].body).toEqual('<del>striked-out</del>');
expect(spy.args[0][1].formatted_body).toEqual('<del>striked-out</del>');
});
it('should not strike-through ~~~ in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('~~~striked-out~~~');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('~~~striked-out~~~');
expect(spy.args[0][1].body).toEqual('~~~striked-out~~~');
});
it('should not mark single unmarkedup paragraphs as HTML in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');
expect(spy.args[0][1].body).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');
});
it('should not mark two unmarkedup paragraphs as HTML in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
expect(spy.args[0][1].body).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
});
it('should strip tab-completed mentions so that only the display name is sent in the plain body in Markdown mode', () => {
// Sending a HTML message because we have entities in the composer (because of completions)
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(false);
mci.setDisplayedCompletion({
completion: 'Some Member',
@ -250,11 +248,11 @@ describe('MessageComposerInput', () => {
mci.handleReturn(sinon.stub());
expect(spy.args[0][1]).toEqual(
expect(spy.args[0][1].body).toEqual(
'Some Member',
'the plaintext body should only include the display name',
);
expect(spy.args[0][2]).toEqual(
expect(spy.args[0][1].formatted_body).toEqual(
'<a href="https://matrix.to/#/@some_member:domain.bla">Some Member</a>',
'the html body should contain an anchor tag with a matrix.to href and display name text',
);
@ -262,7 +260,7 @@ describe('MessageComposerInput', () => {
it('should strip tab-completed mentions so that only the display name is sent in the plain body in RTE mode', () => {
// Sending a HTML message because we have entities in the composer (because of completions)
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
mci.enableRichtext(true);
mci.setDisplayedCompletion({
completion: 'Some Member',
@ -272,33 +270,33 @@ describe('MessageComposerInput', () => {
mci.handleReturn(sinon.stub());
expect(spy.args[0][1]).toEqual('Some Member');
expect(spy.args[0][2]).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">Some Member</a>');
expect(spy.args[0][1].body).toEqual('Some Member');
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">Some Member</a>');
});
it('should not strip non-tab-completed mentions when manually typing MD', () => {
// Sending a HTML message because we have entities in the composer (because of completions)
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
// Markdown mode enabled
mci.enableRichtext(false);
addTextToDraft('[My Not-Tab-Completed Mention](https://matrix.to/#/@some_member:domain.bla)');
mci.handleReturn(sinon.stub());
expect(spy.args[0][1]).toEqual('[My Not-Tab-Completed Mention](https://matrix.to/#/@some_member:domain.bla)');
expect(spy.args[0][2]).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">My Not-Tab-Completed Mention</a>');
expect(spy.args[0][1].body).toEqual('[My Not-Tab-Completed Mention](https://matrix.to/#/@some_member:domain.bla)');
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://matrix.to/#/@some_member:domain.bla">My Not-Tab-Completed Mention</a>');
});
it('should not strip arbitrary typed (i.e. not tab-completed) MD links', () => {
// Sending a HTML message because we have entities in the composer (because of completions)
const spy = sinon.spy(client, 'sendHtmlMessage');
const spy = sinon.spy(client, 'sendMessage');
// Markdown mode enabled
mci.enableRichtext(false);
addTextToDraft('[Click here](https://some.lovely.url)');
mci.handleReturn(sinon.stub());
expect(spy.args[0][1]).toEqual('[Click here](https://some.lovely.url)');
expect(spy.args[0][2]).toEqual('<a href="https://some.lovely.url">Click here</a>');
expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)');
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>');
});
});

View file

@ -95,8 +95,7 @@ export function createTestClient() {
mxcUrlToHttp: (mxc) => 'http://this.is.a.url/',
setAccountData: sinon.stub(),
sendTyping: sinon.stub().returns(Promise.resolve({})),
sendTextMessage: () => Promise.resolve({}),
sendHtmlMessage: () => Promise.resolve({}),
sendMessage: () => Promise.resolve({}),
getSyncState: () => "SYNCING",
generateClientSecret: () => "t35tcl1Ent5ECr3T",
isGuest: () => false,