Merge pull request #1715 from matrix-org/t3chguy/rich_quoting_linear
Linear Rich Quoting
This commit is contained in:
commit
ebfdd7c718
5 changed files with 118 additions and 64 deletions
|
@ -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>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue