diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 5c6cbd6c1b..139c522932 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'], // for reply fallback 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,10 +411,19 @@ 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={}) { const isHtml = (content.format === "org.matrix.custom.html"); - const body = isHtml ? content.formatted_body : escape(content.body); + let body; + if (isHtml) { + body = content.formatted_body; + // Part of Replies fallback support + if (opts.stripReplyFallback) body = ReplyThread.stripHTMLReply(body); + } else { + // Part of Replies fallback support - special because strip must be before escape + body = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : escape(content.body); + } let bodyHasEmoji = false; diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 24ab6163fb..a4728ac67a 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -21,13 +21,13 @@ import dis from '../../../dispatcher'; import MatrixClientPeg from '../../../MatrixClientPeg'; import {wantsDateSeparator} from '../../../DateUtils'; import {MatrixEvent} from 'matrix-js-sdk'; -import {makeUserPermalink} from "../../../matrix-to"; +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 Reply extends React.Component { +export default class ReplyThread extends React.Component { static propTypes = { // the latest event in this chain of replies parentEv: PropTypes.instanceOf(MatrixEvent), @@ -66,13 +66,13 @@ export default class Reply extends React.Component { async initialize() { const {parentEv} = this.props; - const inReplyTo = Reply.getInReplyTo(parentEv); + const inReplyTo = ReplyThread.getInReplyTo(parentEv); if (!inReplyTo) { this.setState({err: true}); return; } - const ev = await Reply.getEvent(this.room, inReplyTo['event_id']); + const ev = await ReplyThread.getEvent(this.room, inReplyTo['event_id']); if (this.unmounted) return; if (ev) { @@ -87,7 +87,7 @@ export default class Reply extends React.Component { async loadNextEvent() { if (this.unmounted) return; const ev = this.state.events[0]; - const inReplyTo = Reply.getInReplyTo(ev); + const inReplyTo = ReplyThread.getInReplyTo(ev); if (!inReplyTo) { this.setState({ @@ -96,7 +96,7 @@ export default class Reply extends React.Component { return; } - const loadedEv = await Reply.getEvent(this.room, inReplyTo['event_id']); + const loadedEv = await ReplyThread.getEvent(this.room, inReplyTo['event_id']); if (this.unmounted) return; if (loadedEv) { @@ -130,7 +130,7 @@ export default class Reply extends React.Component { } static getInReplyTo(ev) { - if (ev.isRedacted()) return; + if (!ev || ev.isRedacted()) return; const mRelatesTo = ev.getWireContent()['m.relates_to']; if (mRelatesTo && mRelatesTo['m.in_reply_to']) { @@ -139,7 +139,39 @@ export default class Reply extends React.Component { } } - static getMRelatesTo(ev) { + // Part of Replies fallback support + static stripPlainReply(body) { + const lines = body.split('\n'); + while (lines[0].startsWith('> ')) 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.getInReplyTo(ev)) { + if (body) body = this.stripPlainReply(body); + if (html) html = this.stripHTMLReply(html); + } + + html = `
In reply to ` + + `${ev.getSender()} ${html || body}
`; + // `<${ev.getSender()}> ${html || body}
`; + const lines = body.split('\n'); + const first = `> <${ev.getSender()}> ${lines.shift()}`; + body = first + lines.map((line) => `> ${line}`).join('\n') + '\n'; + + return {body, html}; + } + + static getReplyEvContent(ev) { if (!ev) return {}; return { 'm.relates_to': { @@ -151,8 +183,10 @@ export default class Reply extends React.Component { } static getQuote(parentEv, onWidgetLoad) { - if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !Reply.getInReplyTo(parentEv)) return
; - return ; + if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !ReplyThread.getInReplyTo(parentEv)) { + return
; + } + return ; } render() { diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index c91178d9b5..a4ea9e4f06 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); @@ -423,6 +424,8 @@ module.exports = React.createClass({ let body = HtmlUtils.bodyToHtml(content, this.props.highlights, { disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'), + // Part of Replies fallback support + stripReplyFallback: Boolean(ReplyThread.getInReplyTo(mxEvent)), }); if (this.props.highlightLink) { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index c2fcfd9688..1b3299ab94 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -54,7 +54,7 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {makeUserPermalink} from "../../../matrix-to"; import ReplyPreview from "./ReplyPreview"; import RoomViewStore from '../../../stores/RoomViewStore'; -import Reply from "../elements/ReplyThread"; +import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; const EMOJI_SHORTNAMES = Object.keys(emojioneList); @@ -830,7 +830,18 @@ export default class MessageComposerInput extends React.Component { this.state.isRichtextEnabled ? 'html' : 'markdown', ); + const replyingToEv = RoomViewStore.getQuotingEvent(); + 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 ?/, ''); @@ -838,18 +849,24 @@ export default class MessageComposerInput extends React.Component { sendTextFn = ContentHelpers.makeEmoteMessage; } - const content = Reply.getMRelatesTo(RoomViewStore.getQuotingEvent()); - let sendMessagePromise; - if (contentHTML) { - Object.assign(content, sendHtmlFn(contentText, contentHTML)); - sendMessagePromise = this.client.sendMessage(this.props.room.roomId, content); - } else { - Object.assign(content, sendTextFn(contentText)); - sendMessagePromise = this.client.sendMessage(this.props.room.roomId, content); + let content = contentHTML || replyingToEv ? sendHtmlFn(contentText, contentHTML) : sendTextFn(contentText); + + if (replyingToEv) { + const replyContent = ReplyThread.getReplyEvContent(replyingToEv); + content = Object.assign(replyContent, content); + + // 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; + } } - sendMessagePromise.done((res) => { + this.client.sendMessage(this.props.room.roomId, content).done((res) => { dis.dispatch({ action: 'message_sent', }); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5bde93f46c..2bb6448fa0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -38,6 +38,10 @@ "The file '%(fileName)s' failed to upload": "The file '%(fileName)s' failed to upload", "The file '%(fileName)s' exceeds this home server's size limit for uploads": "The file '%(fileName)s' exceeds this home server's size limit for uploads", "Upload Failed": "Upload Failed", + "Failure to create room": "Failure to create room", + "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", + "Send anyway": "Send anyway", + "Send": "Send", "Sun": "Sun", "Mon": "Mon", "Tue": "Tue", @@ -77,6 +81,7 @@ "Failed to invite users to community": "Failed to invite users to community", "Failed to invite users to %(groupId)s": "Failed to invite users to %(groupId)s", "Failed to add the following rooms to %(groupId)s:": "Failed to add the following rooms to %(groupId)s:", + "Unnamed Room": "Unnamed Room", "Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings", "Riot was not given permission to send notifications - please try again": "Riot was not given permission to send notifications - please try again", "Unable to enable Notifications": "Unable to enable Notifications", @@ -174,11 +179,6 @@ "%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing", "%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing", "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", - "Failure to create room": "Failure to create room", - "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", - "Send anyway": "Send anyway", - "Send": "Send", - "Unnamed Room": "Unnamed Room", "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Not a valid Riot keyfile": "Not a valid Riot keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", @@ -252,6 +252,29 @@ "Failed to set display name": "Failed to set display name", "Disable Notifications": "Disable Notifications", "Enable Notifications": "Enable Notifications", + "Invalid alias format": "Invalid alias format", + "'%(alias)s' is not a valid format for an alias": "'%(alias)s' is not a valid format for an alias", + "Invalid address format": "Invalid address format", + "'%(alias)s' is not a valid format for an address": "'%(alias)s' is not a valid format for an address", + "not specified": "not specified", + "not set": "not set", + "Remote addresses for this room:": "Remote addresses for this room:", + "Addresses": "Addresses", + "The main address for this room is": "The main address for this room is", + "Local addresses for this room:": "Local addresses for this room:", + "This room has no local addresses": "This room has no local addresses", + "New address (e.g. #foo:%(localDomain)s)": "New address (e.g. #foo:%(localDomain)s)", + "Invalid community ID": "Invalid community ID", + "'%(groupId)s' is not a valid community ID": "'%(groupId)s' is not a valid community ID", + "Flair": "Flair", + "Showing flair for these communities:": "Showing flair for these communities:", + "This room is not showing flair for any communities": "This room is not showing flair for any communities", + "New community ID (e.g. +foo:%(localDomain)s)": "New community ID (e.g. +foo:%(localDomain)s)", + "You have enabled URL previews by default.": "You have enabled URL previews by default.", + "You have disabled URL previews by default.": "You have disabled URL previews by default.", + "URL previews are enabled by default for participants in this room.": "URL previews are enabled by default for participants in this room.", + "URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.", + "URL Previews": "URL Previews", "Cannot add any more widgets": "Cannot add any more widgets", "The maximum permitted number of widgets have already been added to this room.": "The maximum permitted number of widgets have already been added to this room.", "Add a widget": "Add a widget", @@ -332,6 +355,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", @@ -342,11 +367,11 @@ "numbullet": "numbullet", "Markdown is disabled": "Markdown is disabled", "Markdown is enabled": "Markdown is enabled", - "Unpin Message": "Unpin Message", - "Jump to message": "Jump to message", "No pinned messages.": "No pinned messages.", "Loading...": "Loading...", "Pinned Messages": "Pinned Messages", + "Unpin Message": "Unpin Message", + "Jump to message": "Jump to message", "%(duration)ss": "%(duration)ss", "%(duration)sm": "%(duration)sm", "%(duration)sh": "%(duration)sh", @@ -381,9 +406,6 @@ "Drop here to restore": "Drop here to restore", "Drop here to demote": "Drop here to demote", "Drop here to tag %(section)s": "Drop here to tag %(section)s", - "Failed to set direct chat tag": "Failed to set direct chat tag", - "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", - "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room", "Press to start a chat with someone": "Press to start a chat with someone", "You're not in any rooms yet! Press to make a room or to browse the directory": "You're not in any rooms yet! Press to make a room or to browse the directory", "Community Invites": "Community Invites", @@ -471,29 +493,6 @@ "Scroll to unread messages": "Scroll to unread messages", "Jump to first unread message.": "Jump to first unread message.", "Close": "Close", - "Invalid alias format": "Invalid alias format", - "'%(alias)s' is not a valid format for an alias": "'%(alias)s' is not a valid format for an alias", - "Invalid address format": "Invalid address format", - "'%(alias)s' is not a valid format for an address": "'%(alias)s' is not a valid format for an address", - "not specified": "not specified", - "not set": "not set", - "Remote addresses for this room:": "Remote addresses for this room:", - "Addresses": "Addresses", - "The main address for this room is": "The main address for this room is", - "Local addresses for this room:": "Local addresses for this room:", - "This room has no local addresses": "This room has no local addresses", - "New address (e.g. #foo:%(localDomain)s)": "New address (e.g. #foo:%(localDomain)s)", - "Invalid community ID": "Invalid community ID", - "'%(groupId)s' is not a valid community ID": "'%(groupId)s' is not a valid community ID", - "Flair": "Flair", - "Showing flair for these communities:": "Showing flair for these communities:", - "This room is not showing flair for any communities": "This room is not showing flair for any communities", - "New community ID (e.g. +foo:%(localDomain)s)": "New community ID (e.g. +foo:%(localDomain)s)", - "You have enabled URL previews by default.": "You have enabled URL previews by default.", - "You have disabled URL previews by default.": "You have disabled URL previews by default.", - "URL previews are enabled by default for participants in this room.": "URL previews are enabled by default for participants in this room.", - "URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.", - "URL Previews": "URL Previews", "Error decrypting audio": "Error decrypting audio", "Error decrypting attachment": "Error decrypting attachment", "Decrypt %(text)s": "Decrypt %(text)s", @@ -985,5 +984,8 @@ "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.", "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.", "File to import": "File to import", - "Import": "Import" + "Import": "Import", + "Failed to set direct chat tag": "Failed to set direct chat tag", + "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", + "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room" }