diff --git a/src/components/views/elements/Quote.js b/src/components/views/elements/Quote.js index bceba0f536..0c0078978d 100644 --- a/src/components/views/elements/Quote.js +++ b/src/components/views/elements/Quote.js @@ -20,10 +20,11 @@ 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\/(([\#\!])[^\/]*)\/(\$[^\/]*)$/; +const REGEX_LOCAL_MATRIXTO = /^#\/room\/([\#\!][^\/]*)\/(\$[^\/]*)$/; export default class Quote extends React.Component { static isMessageUrl(url) { @@ -32,111 +33,155 @@ export default class Quote extends React.Component { 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), - // Whether this isn't the first Quote, and we're being nested - isNested: PropTypes.bool, }; constructor(props, context) { super(props, context); this.state = { - // The event related to this quote - event: null, - show: !this.props.isNested, + // 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) { - let roomId; - let prefix; - let eventId; + const {roomIdentifier, eventId} = this.parseUrl(nextProps.url); + if (!roomIdentifier || !eventId) return; - 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); + 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. - if (room) this.getEvent(room, eventId); + this.setState({ events: [] }); + if (room) this.getEvent(room, eventId, true); } componentWillMount() { this.componentWillReceiveProps(this.props); } - async getEvent(room, eventId) { - let event = room.findEventById(eventId); + 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.setState({room, event}); + this.addEvent(event, show); return; } await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId); - event = room.findEventById(eventId); - this.setState({room, event}); + 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, - }); + this.setState({ show: true }); } render() { - const ev = this.state.event; - if (ev) { - if (this.state.show) { - const EventTile = sdk.getComponent('views.rooms.EventTile'); - let dateSep = null; + const events = this.state.events.slice(); + if (events.length) { + const evTiles = []; - const evDate = ev.getDate(); - if (wantsDateSeparator(this.props.parentEv.getDate(), evDate)) { - const DateSeparator = sdk.getComponent('messages.DateSeparator'); - dateSep = ; - } + if (!this.state.show) { + const oldestEv = events.shift(); + const Pill = sdk.getComponent('elements.Pill'); + const room = MatrixClientPeg.get().getRoom(oldestEv.getRoomId()); - return
- { dateSep } - -
; + evTiles.push(
+ { + _t('In reply to ', {}, { + 'a': (sub) => { sub }, + 'pill': , + }) + } +
); } - return
- { _t('Quote') } -
-
; + 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 - return
- { _t('Quote') } -
-
; + return this.props.node; } } diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 4f34a635dc..31c1df7b44 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -61,6 +61,10 @@ module.exports = React.createClass({ tileShape: PropTypes.string, }, + contextTypes: { + addRichQuote: PropTypes.func, + }, + getInitialState: function() { return { // the URLs (if any) to be previewed with a LinkPreviewWidget @@ -202,18 +206,20 @@ 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)) { - // only allow this branch if we're not already in a quote, as fun as infinite nesting is. - const quoteContainer = document.createElement('span'); + 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); + const quote = + ; + ReactDOM.render(quote, quoteContainer); + node.parentNode.replaceChild(quoteContainer, node); + node = quoteContainer; + } 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 937826b4fd..4e0ff2c6d6 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -592,7 +592,7 @@ module.exports = withMatrixClient(React.createClass({
{ avatar } { sender } -
+
{ timestamp } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4d31ae7875..93af7aa47c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -981,5 +981,6 @@ "Whether or not you're using the Richtext mode of the Rich Text Editor": "Whether or not you're using the Richtext mode of the Rich Text Editor", "Your homeserver's URL": "Your homeserver's URL", "Your identity server's URL": "Your identity server's URL", + "In reply to ": "In reply to ", "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite." } diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index cf895d2190..4c010f4e8e 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -132,6 +132,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, }; if (this._state.forwardingEvent) {