Implement Rich Quoting/Replies

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2017-12-10 12:50:41 +00:00
parent 4f58b92a14
commit 0f85391587
No known key found for this signature in database
GPG key ID: 3F879DA5AD802A5E
8 changed files with 405 additions and 98 deletions

View file

@ -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 <a href={this.props.url} >*/
return <blockquote>
{/*<span style={{borderBottom: '2px red'}}>*/}
{/*<Pill room={this.state.room} url={senderUrl} shouldShowPillAvatar={this.props.shouldShowPillAvatar} />*/}
{/*&nbsp;<a href={this.props.url}><EmojiText>🔗</EmojiText> { DateUtils.formatTime(new Date(ev.getTs())) }</a>*/}
{/*</span>*/}
<EventTile mxEvent={ev} tileShape="quote" />
{/*<EventTileType mxEvent={ev} />*/}
</blockquote>;
/*</a>;*/
} else {
// Deliberately render nothing if the URL isn't recognised
return <div>
<a href={this.props.url}>Quote</a>
<br />
</div>;
}
},
});
export default Quote;

View file

@ -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 = <Quote url={href} shouldShowPillAvatar={shouldShowPillAvatar} />;
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');

View file

@ -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() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
if (this.props.tileShape === "notif") {
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
</a>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
<a href={permalink} onClick={this.onPermalinkClicked}>
{ sender }
{ timestamp }
</a>
</div>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
</div>
);
} else if (this.props.tileShape === "file_grid") {
return (
<div className={classes}>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
<a
className="mx_EventTile_senderDetailsLink"
href={permalink}
onClick={this.onPermalinkClicked}
>
switch (this.props.tileShape) {
case 'notif': {
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
</a>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
<a href={permalink} onClick={this.onPermalinkClicked}>
{ sender }
{ timestamp }
</a>
</div>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
</a>
</div>
);
} else {
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
);
}
case 'file_grid': {
return (
<div className={classes}>
<div className="mx_EventTile_line" >
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
<a
className="mx_EventTile_senderDetailsLink"
href={permalink}
onClick={this.onPermalinkClicked}
>
<div className="mx_EventTile_senderDetails">
{ sender }
{ timestamp }
</div>
</a>
{ this._renderE2EPadlock() }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
{ editButton }
</div>
</div>
);
);
}
case 'quote': {
return (
<div className={classes}>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ this._renderE2EPadlock() }
<EventTileType ref="tile"
tileShape="quote"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={false} />
</div>
</div>
);
}
default: {
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ this._renderE2EPadlock() }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
{ editButton }
</div>
</div>
);
}
}
},
}));
@ -653,3 +678,5 @@ function E2ePadlockUnencrypted(props) {
function E2ePadlock(props) {
return <img className="mx_EventTile_e2eIcon" {...props} />;
}
module.exports.getHandlerTile = getHandlerTile;

View file

@ -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(`<blockquote>${body}</blockquote>`);
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 = `<a href="${perma}">Quote<br></a>${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 (
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_autocomplete_wrapper">
<QuotePreview />
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}

View file

@ -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 <div className="mx_Quoting">
<div className="mx_Quoting_section">
<EmojiText element="div" className="mx_Quoting_header mx_Quoting_title">💬 Quoting</EmojiText>
<div className="mx_Quoting_header mx_Quoting_cancel">
<img className="mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={cancelQuoting} />
</div>
<div className="mx_Quoting_clear" />
<EventTile mxEvent={this.state.event} last={true} tileShape="quote" />
</div>
</div>;
}
}

View file

@ -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) {

33
src/matrix-to.js Normal file
View file

@ -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}`;
}

View file

@ -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;
}