Merge pull request #1715 from matrix-org/t3chguy/rich_quoting_linear

Linear Rich Quoting
This commit is contained in:
David Baker 2018-01-24 18:53:29 +01:00 committed by GitHub
commit ebfdd7c718
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 118 additions and 64 deletions

View File

@ -20,10 +20,11 @@ import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import {wantsDateSeparator} from '../../../DateUtils'; import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk'; 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 // For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) // 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 { export default class Quote extends React.Component {
static isMessageUrl(url) { static isMessageUrl(url) {
@ -32,111 +33,155 @@ export default class Quote extends React.Component {
static childContextTypes = { static childContextTypes = {
matrixClient: PropTypes.object, matrixClient: PropTypes.object,
addRichQuote: PropTypes.func,
}; };
static propTypes = { static propTypes = {
// The matrix.to url of the event // The matrix.to url of the event
url: PropTypes.string, url: PropTypes.string,
// The original node that was rendered
node: PropTypes.instanceOf(Element),
// The parent event // The parent event
parentEv: PropTypes.instanceOf(MatrixEvent), parentEv: PropTypes.instanceOf(MatrixEvent),
// Whether this isn't the first Quote, and we're being nested
isNested: PropTypes.bool,
}; };
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
// The event related to this quote // The event related to this quote and their nested rich quotes
event: null, events: [],
show: !this.props.isNested, // 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.onQuoteClick = this.onQuoteClick.bind(this);
this.addRichQuote = this.addRichQuote.bind(this);
} }
getChildContext() { getChildContext() {
return { return {
matrixClient: MatrixClientPeg.get(), 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) { componentWillReceiveProps(nextProps) {
let roomId; const {roomIdentifier, eventId} = this.parseUrl(nextProps.url);
let prefix; if (!roomIdentifier || !eventId) return;
let eventId;
if (nextProps.url) { const room = this.getRoom(roomIdentifier);
// Default to the empty array if no match for simplicity if (!room) return;
// 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 // 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. // 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() { componentWillMount() {
this.componentWillReceiveProps(this.props); this.componentWillReceiveProps(this.props);
} }
async getEvent(room, eventId) { getRoom(id) {
let event = room.findEventById(eventId); 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) { if (event) {
this.setState({room, event}); this.addEvent(event, show);
return; return;
} }
await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId); await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
event = room.findEventById(eventId); this.addEvent(room.findEventById(eventId), show);
this.setState({room, event}); }
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() { onQuoteClick() {
this.setState({ this.setState({ show: true });
show: true,
});
} }
render() { render() {
const ev = this.state.event; const events = this.state.events.slice();
if (ev) { if (events.length) {
if (this.state.show) { const evTiles = [];
const EventTile = sdk.getComponent('views.rooms.EventTile');
let dateSep = null;
const evDate = ev.getDate(); if (!this.state.show) {
if (wantsDateSeparator(this.props.parentEv.getDate(), evDate)) { const oldestEv = events.shift();
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const Pill = sdk.getComponent('elements.Pill');
dateSep = <a href={this.props.url}><DateSeparator ts={evDate} /></a>; const room = MatrixClientPeg.get().getRoom(oldestEv.getRoomId());
}
return <blockquote className="mx_Quote"> evTiles.push(<blockquote className="mx_Quote" key="load">
{ dateSep } {
<EventTile mxEvent={ev} tileShape="quote" /> _t('<a>In reply to</a> <pill>', {}, {
</blockquote>; 'a': (sub) => <a onClick={this.onQuoteClick} className="mx_Quote_show">{ sub }</a>,
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
url={makeUserPermalink(oldestEv.getSender())} shouldShowPillAvatar={true} />,
})
}
</blockquote>);
} }
return <div> const EventTile = sdk.getComponent('views.rooms.EventTile');
<a onClick={this.onQuoteClick} className="mx_Quote_show">{ _t('Quote') }</a> const DateSeparator = sdk.getComponent('messages.DateSeparator');
<br /> events.forEach((ev) => {
</div>; let dateSep = null;
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
evTiles.push(<blockquote className="mx_Quote" key={ev.getId()}>
{ dateSep }
<EventTile mxEvent={ev} tileShape="quote" />
</blockquote>);
});
return <div>{ evTiles }</div>;
} }
// Deliberately render nothing if the URL isn't recognised // Deliberately render nothing if the URL isn't recognised
return <div> return this.props.node;
<a href={this.props.url}>{ _t('Quote') }</a>
<br />
</div>;
} }
} }

View File

@ -61,6 +61,10 @@ module.exports = React.createClass({
tileShape: PropTypes.string, tileShape: PropTypes.string,
}, },
contextTypes: {
addRichQuote: PropTypes.func,
},
getInitialState: function() { getInitialState: function() {
return { return {
// the URLs (if any) to be previewed with a LinkPreviewWidget // 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 // update the current node with one that's now taken its place
node = pillContainer; node = pillContainer;
} else if (SettingsStore.isFeatureEnabled("feature_rich_quoting") && Quote.isMessageUrl(href)) { } 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. if (this.context.addRichQuote) { // We're already a Rich Quote so just append the next one above
const quoteContainer = document.createElement('span'); this.context.addRichQuote(href);
node.remove();
} else { // We're the first in the chain
const quoteContainer = document.createElement('span');
const quote = const quote =
<Quote url={href} parentEv={this.props.mxEvent} isNested={this.props.tileShape === 'quote'} />; <Quote url={href} parentEv={this.props.mxEvent} node={node} />;
ReactDOM.render(quote, quoteContainer);
node.parentNode.replaceChild(quoteContainer, node);
ReactDOM.render(quote, quoteContainer);
node.parentNode.replaceChild(quoteContainer, node);
node = quoteContainer;
}
pillified = true; pillified = true;
node = quoteContainer;
} }
} else if (node.nodeType == Node.TEXT_NODE) { } else if (node.nodeType == Node.TEXT_NODE) {
const Pill = sdk.getComponent('elements.Pill'); const Pill = sdk.getComponent('elements.Pill');

View File

@ -592,7 +592,7 @@ module.exports = withMatrixClient(React.createClass({
<div className={classes}> <div className={classes}>
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line mx_EventTile_quote">
<a href={permalink} onClick={this.onPermalinkClicked}> <a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp } { timestamp }
</a> </a>

View File

@ -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", "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 homeserver's URL": "Your homeserver's URL",
"Your identity server's URL": "Your identity server's URL", "Your identity server's URL": "Your identity server's URL",
"<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",
"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." "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."
} }

View File

@ -132,6 +132,8 @@ class RoomViewStore extends Store {
shouldPeek: payload.should_peek === undefined ? true : payload.should_peek, shouldPeek: payload.should_peek === undefined ? true : payload.should_peek,
// have we sent a join request for this room and are waiting for a response? // have we sent a join request for this room and are waiting for a response?
joining: payload.joining || false, joining: payload.joining || false,
// Reset quotingEvent because we don't want cross-room because bad UX
quotingEvent: null,
}; };
if (this._state.forwardingEvent) { if (this._state.forwardingEvent) {