From 0f853915876b10d34ed53f6365c30bb5f7d34ad4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sun, 10 Dec 2017 12:50:41 +0000 Subject: [PATCH] Implement Rich Quoting/Replies Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/elements/Quote.js | 138 +++++++++++++++ src/components/views/messages/TextualBody.js | 17 ++ src/components/views/rooms/EventTile.js | 159 ++++++++++-------- .../views/rooms/MessageComposerInput.js | 65 +++---- src/components/views/rooms/QuotePreview.js | 75 +++++++++ src/linkify-matrix.js | 4 +- src/matrix-to.js | 33 ++++ src/stores/RoomViewStore.js | 12 ++ 8 files changed, 405 insertions(+), 98 deletions(-) create mode 100644 src/components/views/elements/Quote.js create mode 100644 src/components/views/rooms/QuotePreview.js create mode 100644 src/matrix-to.js diff --git a/src/components/views/elements/Quote.js b/src/components/views/elements/Quote.js new file mode 100644 index 0000000000..1925cfbd65 --- /dev/null +++ b/src/components/views/elements/Quote.js @@ -0,0 +1,138 @@ +/* +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 dis from '../../../dispatcher'; +import classNames from 'classnames'; +import { Room, RoomMember } from 'matrix-js-sdk'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import { getDisplayAliasForRoom } from '../../../Rooms'; +import {makeUserPermalink} from "../../../matrix-to"; +import DateUtils from "../../../DateUtils"; + +// 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\/(([\#\!])[^\/]*)\/(\$[^\/]*)$/; + +const Quote = React.createClass({ + statics: { + isMessageUrl: (url) => { + return !!REGEX_LOCAL_MATRIXTO.exec(url); + }, + }, + + childContextTypes: { + matrixClient: React.PropTypes.object, + }, + + props: { + // The matrix.to url of the event + url: PropTypes.string, + // Whether to include an avatar in the pill + shouldShowPillAvatar: PropTypes.bool, + }, + + getChildContext: function() { + return { + matrixClient: MatrixClientPeg.get(), + }; + }, + + getInitialState() { + return { + // The room related to this quote + room: null, + // The event related to this quote + event: null, + }; + }, + + componentWillReceiveProps(nextProps) { + let roomId; + let prefix; + let eventId; + + if (nextProps.url) { + // 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(nextProps.url) || []; + + roomId = matrixToMatch[1]; // The room ID + prefix = matrixToMatch[2]; // The first character of prefix + eventId = matrixToMatch[3]; // The event ID + } + + const room = prefix === '#' ? + MatrixClientPeg.get().getRooms().find((r) => { + return r.getAliases().includes(roomId); + }) : MatrixClientPeg.get().getRoom(roomId); + + // 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. + if (room) this.getEvent(room, eventId); + }, + + componentWillMount() { + this._unmounted = false; + this.componentWillReceiveProps(this.props); + }, + + componentWillUnmount() { + this._unmounted = true; + }, + + async getEvent(room, eventId) { + let event = room.findEventById(eventId); + if (event) { + this.setState({room, event}); + return; + } + + await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId); + event = room.findEventById(eventId); + this.setState({room, event}); + }, + + render: function() { + const ev = this.state.event; + if (ev) { + const EventTile = sdk.getComponent('views.rooms.EventTile'); + // const EmojiText = sdk.getComponent('views.elements.EmojiText'); + // const Pill = sdk.getComponent('views.elements.Pill'); + // const senderUrl = makeUserPermalink(ev.getSender()); + // const EventTileType = sdk.getComponent(EventTile.getHandlerTile(ev)); + /*return */ + return
+ {/**/} + {/**/} + {/* ðŸ”— { DateUtils.formatTime(new Date(ev.getTs())) }*/} + {/**/} + + {/**/} +
; + /*;*/ + } else { + // Deliberately render nothing if the URL isn't recognised + return
+ Quote +
+
; + } + }, +}); + +export default Quote; diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 4b096d0a76..1899e8428a 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -56,6 +57,9 @@ module.exports = React.createClass({ /* callback for when our widget has loaded */ onWidgetLoad: React.PropTypes.func, + + /* the shsape of the tile, used */ + tileShape: React.PropTypes.string, }, getInitialState: function() { @@ -179,6 +183,7 @@ 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'); @@ -197,6 +202,18 @@ module.exports = React.createClass({ // update the current node with one that's now taken its place node = pillContainer; + } else if (this.props.tileShape !== 'quote' && Quote.isMessageUrl(href)) { + // only allow this branch if we're not already in a quote, as fun as infinite nesting is. + const quoteContainer = document.createElement('span'); + + const quote = ; + + ReactDOM.render(quote, quoteContainer); + node.parentNode.replaceChild(quoteContainer, node); + + pillified = true; + + node = quoteContainer; } } else if (node.nodeType == Node.TEXT_NODE) { const Pill = sdk.getComponent('elements.Pill'); diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 3407ea159d..645a3fdb71 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -528,79 +529,103 @@ module.exports = withMatrixClient(React.createClass({ const timestamp = this.props.mxEvent.getTs() ? : null; - if (this.props.tileShape === "notif") { - const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); - return ( -
-
- - { room ? room.name : '' } - -
-
- { avatar } - - { sender } - { timestamp } - -
-
- -
-
- ); - } else if (this.props.tileShape === "file_grid") { - return ( -
-
- -
- + switch (this.props.tileShape) { + case 'notif': { + const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); + return ( +
+
+ + { room ? room.name : '' } + +
+ { avatar } + { sender } { timestamp } + +
+
+
- -
- ); - } else { - return ( -
-
- { readAvatars }
- { avatar } - { sender } -
- - { timestamp } + ); + } + case 'file_grid': { + return ( +
+
+ +
+
+
+ { sender } + { timestamp } +
- { this._renderE2EPadlock() } - - { editButton }
-
- ); + ); + } + case 'quote': { + return ( +
+ { avatar } + { sender } +
+ + { timestamp } + + { this._renderE2EPadlock() } + +
+
+ ); + } + default: { + return ( +
+
+ { readAvatars } +
+ { avatar } + { sender } +
+ + { timestamp } + + { this._renderE2EPadlock() } + + { editButton } +
+
+ ); + } } }, })); @@ -653,3 +678,5 @@ function E2ePadlockUnencrypted(props) { function E2ePadlock(props) { return ; } + +module.exports.getHandlerTile = getHandlerTile; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index cd30f20645..ad7e81ccb1 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -50,6 +50,10 @@ 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} from "../../../matrix-to"; +import QuotePreview from "./QuotePreview"; +import RoomViewStore from '../../../stores/RoomViewStore'; + const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); @@ -293,35 +297,6 @@ export default class MessageComposerInput extends React.Component { }); } break; - case 'quote': { - /// XXX: Not doing rich-text quoting from formatted-body because draft-js - /// has regressed such that when links are quoted, errors are thrown. See - /// https://github.com/vector-im/riot-web/issues/4756. - const body = escape(payload.text); - if (body) { - let content = RichText.htmlToContentState(`
${body}
`); - if (!this.state.isRichtextEnabled) { - content = ContentState.createFromText(RichText.stateToMarkdown(content)); - } - - const blockMap = content.getBlockMap(); - let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); - contentState = Modifier.splitBlock(contentState, startSelection); - startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); - contentState = Modifier.replaceWithFragment(contentState, - startSelection, - blockMap); - startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); - if (this.state.isRichtextEnabled) { - contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); - } - let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); - editorState = EditorState.moveSelectionToEnd(editorState); - this.onEditorContentChanged(editorState); - editor.focus(); - } - } - break; } }; @@ -659,7 +634,7 @@ export default class MessageComposerInput extends React.Component { } return false; - } + }; onTextPasted(text: string, html?: string) { const currentSelection = this.state.editorState.getSelection(); @@ -749,9 +724,17 @@ export default class MessageComposerInput extends React.Component { return true; } + const quotingEv = RoomViewStore.getQuotingEvent(); + 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; + } + const blocks = contentState.getBlocksAsArray(); if (blocks.some((block) => block.getType() !== 'unstyled')) { shouldSendHTML = true; @@ -809,7 +792,8 @@ export default class MessageComposerInput extends React.Component { }).join('\n'); const md = new Markdown(pt); - if (md.isPlainText()) { + // if contains no HTML and we're not quoting (needing HTML) + if (md.isPlainText() && !quotingEv) { contentText = md.toPlaintext(); } else { contentHTML = md.toHTML(); @@ -832,6 +816,24 @@ export default class MessageComposerInput extends React.Component { sendTextFn = this.client.sendEmoteMessage; } + 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(); + + const perma = makeEventPermalink(quotingEv.getRoomId(), quotingEv.getId()); + contentText = `${sender.name}:\n> ${body}\n\n${contentText}`; + contentHTML = `Quote
${contentHTML}`; + + // we have finished quoting, clear the quotingEvent + dis.dispatch({ + action: 'quote_event', + event: null, + }); + } + let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( @@ -1144,6 +1146,7 @@ export default class MessageComposerInput extends React.Component { return (
+ this.autocomplete = e} room={this.props.room} diff --git a/src/components/views/rooms/QuotePreview.js b/src/components/views/rooms/QuotePreview.js new file mode 100644 index 0000000000..2259350cdc --- /dev/null +++ b/src/components/views/rooms/QuotePreview.js @@ -0,0 +1,75 @@ +/* +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 dis from '../../../dispatcher'; +import sdk from '../../../index'; +import RoomViewStore from '../../../stores/RoomViewStore'; + +function cancelQuoting() { + dis.dispatch({ + action: 'quote_event', + event: null, + }); +} + +export default class QuotePreview extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + event: null, + }; + + this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); + + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._onRoomViewStoreUpdate(); + } + + componentWillUnmount() { + // Remove RoomStore listener + if (this._roomStoreToken) { + this._roomStoreToken.remove(); + } + } + + _onRoomViewStoreUpdate() { + const event = RoomViewStore.getQuotingEvent(); + if (this.state.event !== event) { + this.setState({ event }); + } + } + + render() { + if (!this.state.event) return null; + + const EventTile = sdk.getComponent('rooms.EventTile'); + const EmojiText = sdk.getComponent('views.elements.EmojiText'); + + return
+
+ 💬 Quoting +
+ +
+
+ +
+
; + } +} diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 74489a09ea..6bbea77733 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {baseUrl} from "./matrix-to"; + function matrixLinkify(linkify) { // Text tokens const TT = linkify.scanner.TOKENS; @@ -170,7 +172,7 @@ matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:" matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)"; matrixLinkify.MATRIXTO_MD_LINK_PATTERN = '\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!)[^\\)]*)\\)'; -matrixLinkify.MATRIXTO_BASE_URL= "https://matrix.to"; +matrixLinkify.MATRIXTO_BASE_URL= baseUrl; matrixLinkify.options = { events: function(href, type) { diff --git a/src/matrix-to.js b/src/matrix-to.js new file mode 100644 index 0000000000..72fb3c38fc --- /dev/null +++ b/src/matrix-to.js @@ -0,0 +1,33 @@ +/* +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. +*/ + +export const baseUrl = "https://matrix.to"; + +export function makeEventPermalink(roomId, eventId) { + return `${baseUrl}/#/${roomId}/${eventId}`; +} + +export function makeUserPermalink(userId) { + return `${baseUrl}/#/${userId}`; +} + +export function makeRoomPermalink(roomId) { + return `${baseUrl}/#/${roomId}`; +} + +export function makeGroupPermalink(groupId) { + return `${baseUrl}/#/${groupId}`; +} diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 0a8eca8797..cf895d2190 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -41,6 +42,8 @@ const INITIAL_STATE = { roomLoadError: null, forwardingEvent: null, + + quotingEvent: null, }; /** @@ -108,6 +111,10 @@ class RoomViewStore extends Store { forwardingEvent: payload.event, }); break; + case 'quote_event': + this._setState({ + quotingEvent: payload.event, + }); } } @@ -286,6 +293,11 @@ class RoomViewStore extends Store { return this._state.forwardingEvent; } + // The mxEvent if one is currently being replied to/quoted + getQuotingEvent() { + return this._state.quotingEvent; + } + shouldPeek() { return this._state.shouldPeek; }