diff --git a/res/css/_components.scss b/res/css/_components.scss index 9770010324..50e8eb11d1 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/views/elements/_Quote.scss b/res/css/views/elements/_ReplyThread.scss similarity index 74% rename from res/css/views/elements/_Quote.scss rename to res/css/views/elements/_ReplyThread.scss index 0af555b5aa..a02f42751c 100644 --- a/res/css/views/elements/_Quote.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -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; +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 788940fe0e..ce2bf9c8a4 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -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; } diff --git a/res/css/views/rooms/_QuotePreview.scss b/res/css/views/rooms/_QuotePreview.scss deleted file mode 100644 index 86fd79d473..0000000000 --- a/res/css/views/rooms/_QuotePreview.scss +++ /dev/null @@ -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; -} diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss new file mode 100644 index 0000000000..5bf4adff27 --- /dev/null +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -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; +} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index e3b7ba47f5..82b6830b78 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -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 ? : - { content.body }; + { strippedBody }; } export function emojifyText(text) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 6fc16b9760..c5f6a75cc5 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -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', }); }, diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 860d94ea0e..99ec493ced 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -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 = ( +
+ { _t('Collapse Reply Thread') } +
+ ); + } return (
@@ -320,6 +337,7 @@ module.exports = React.createClass({ { quoteButton } { replyButton } { externalURLButton } + { collapseReplyThread }
); }, diff --git a/src/components/views/elements/Quote.js b/src/components/views/elements/Quote.js deleted file mode 100644 index 761f7aa151..0000000000 --- a/src/components/views/elements/Quote.js +++ /dev/null @@ -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(
- { - _t('In reply to ', {}, { - 'a': (sub) => { sub }, - 'pill': , - }) - } -
); - } - - 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 = ; - } - - evTiles.push(
- { dateSep } - -
); - }); - - return
{ evTiles }
; - } - - // 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; - } -} diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js new file mode 100644 index 0000000000..138431259f --- /dev/null +++ b/src/components/views/elements/ReplyThread.js @@ -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(/^
[\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 = `
In reply to ${mxid}` + + `
${html || body}
`; + 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 = `
In reply to ${mxid}` + + `
sent an image.
`; + body = `> <${mxid}> sent an image.\n\n`; + break; + case 'm.video': + html = `
In reply to ${mxid}` + + `
sent a video.
`; + body = `> <${mxid}> sent a video.\n\n`; + break; + case 'm.audio': + html = `
In reply to ${mxid}` + + `
sent an audio file.
`; + body = `> <${mxid}> sent an audio file.\n\n`; + break; + case 'm.file': + html = `
In reply to ${mxid}` + + `
sent a file.
`; + body = `> <${mxid}> sent a file.\n\n`; + break; + case 'm.emote': { + html = `
In reply to * ` + + `${mxid}
${html || body}
`; + 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
; + } + return ; + } + + 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 =
+ { + _t('Unable to load event that was replied to, ' + + 'it either does not exist or you do not have permission to view it.') + } +
; + } 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 =
+ { + _t('In reply to ', {}, { + 'a': (sub) => { sub }, + 'pill': , + }) + } +
; + } else if (this.state.loading) { + const Spinner = sdk.getComponent("elements.Spinner"); + header = ; + } + + 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 = ; + } + + return
+ { dateSep } + +
; + }); + + return
+
{ header }
+
{ evTiles }
+
; + } +} diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index abb4da227c..a4a4b4ebe8 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -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 = - ; - - 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) { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 84acfb160e..c7150da67c 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -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({
); } - case 'quote': { + + case 'reply': + case 'reply_preview': { return (
{ avatar } { sender } -
+
{ timestamp } { this._renderE2EPadlock() } + { + this.props.tileShape === 'reply_preview' + && ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') + } { this._renderE2EPadlock() } + { ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') } ); } + const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + let replyToWarning = null; + if (isQuoting) { + replyToWarning =

{ + _t('At this time it is not possible to reply with a file so this will be sent without being a reply.') + }

; + } + Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, { title: _t('Upload Files'), description: ( @@ -119,6 +127,7 @@ export default class MessageComposer extends React.Component {
    { fileList }
+ { replyToWarning }
), onFinished: (shouldUpload) => { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index c142d97b28..97e8780f0f 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -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 = `Quote
${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 (
- { SettingsStore.isFeatureEnabled("feature_rich_quoting") && } + { SettingsStore.isFeatureEnabled("feature_rich_quoting") && } this.autocomplete = e} room={this.props.room} diff --git a/src/components/views/rooms/QuotePreview.js b/src/components/views/rooms/ReplyPreview.js similarity index 73% rename from src/components/views/rooms/QuotePreview.js rename to src/components/views/rooms/ReplyPreview.js index 614d51dada..46e2826634 100644 --- a/src/components/views/rooms/QuotePreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -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
-
- + return
+
+ { '💬 ' + _t('Replying') } -
+
-
- +
+
; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5b02fd1866..4adca0cc72 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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.", "In reply to ": "In reply to ", "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", diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 4c010f4e8e..1e7e50eae0 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -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() { diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index 1f0ede6ae2..eadd923726 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -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* italic'); + expect(spy.args[0][1].body).toEqual('\\*escaped\\* *italic*'); + expect(spy.args[0][1].formatted_body).toEqual('*escaped* italic'); }); 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 tags in Markdown mode', () => { - const spy = sinon.spy(client, 'sendHtmlMessage'); + 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][2]).toEqual('striked-out'); + expect(spy.args[0][1].body).toEqual('striked-out'); + expect(spy.args[0][1].formatted_body).toEqual('striked-out'); }); 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( 'Some Member', '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('Some Member'); + expect(spy.args[0][1].body).toEqual('Some Member'); + expect(spy.args[0][1].formatted_body).toEqual('Some Member'); }); 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('My Not-Tab-Completed Mention'); + 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('My Not-Tab-Completed Mention'); }); 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('Click here'); + expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)'); + expect(spy.args[0][1].formatted_body).toEqual('Click here'); }); }); diff --git a/test/test-utils.js b/test/test-utils.js index d2c685b371..2f482d2d4f 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -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,