mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-16 21:24:59 +08:00
correctly send pills in messages
This commit is contained in:
parent
d7c2c8ba7b
commit
9c0c806af4
@ -133,7 +133,10 @@ export default class Markdown {
|
|||||||
* Render the markdown message to plain text. That is, essentially
|
* Render the markdown message to plain text. That is, essentially
|
||||||
* just remove any backslashes escaping what would otherwise be
|
* just remove any backslashes escaping what would otherwise be
|
||||||
* markdown syntax
|
* markdown syntax
|
||||||
* (to fix https://github.com/vector-im/riot-web/issues/2870)
|
* (to fix https://github.com/vector-im/riot-web/issues/2870).
|
||||||
|
*
|
||||||
|
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
|
||||||
|
* which has no formatting. Otherwise it emits HTML(!).
|
||||||
*/
|
*/
|
||||||
toPlaintext() {
|
toPlaintext() {
|
||||||
const renderer = new commonmark.HtmlRenderer({safe: false});
|
const renderer = new commonmark.HtmlRenderer({safe: false});
|
||||||
@ -161,6 +164,14 @@ export default class Markdown {
|
|||||||
if (is_multi_line(node) && node.next) this.lit('\n\n');
|
if (is_multi_line(node) && node.next) this.lit('\n\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// convert MD links into console-friendly ' < http://foo >' style links
|
||||||
|
// ...except given this function never gets called with links, it's useless.
|
||||||
|
// renderer.link = function(node, entering) {
|
||||||
|
// if (!entering) {
|
||||||
|
// this.lit(` < ${node.destination} >`);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
return renderer.render(this.parsed);
|
return renderer.render(this.parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -40,6 +40,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||||||
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
|
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
|
||||||
return [{
|
return [{
|
||||||
completion: '@room',
|
completion: '@room',
|
||||||
|
completionId: '@room',
|
||||||
suffix: ' ',
|
suffix: ' ',
|
||||||
component: (
|
component: (
|
||||||
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
|
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
|
||||||
|
89
src/autocomplete/PlainWithPillsSerializer.js
Normal file
89
src/autocomplete/PlainWithPillsSerializer.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2018 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Based originally on slate-plain-serializer
|
||||||
|
|
||||||
|
import { Block } from 'slate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text serializer, which converts a Slate `value` to a plain text string,
|
||||||
|
* serializing pills into various different formats as required.
|
||||||
|
*
|
||||||
|
* @type {PlainWithPillsSerializer}
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PlainWithPillsSerializer {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @param {String} options.pillFormat - either 'md', 'plain', 'id'
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
let {
|
||||||
|
pillFormat = 'plain',
|
||||||
|
} = options;
|
||||||
|
this.pillFormat = pillFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a Slate `value` to a plain text string,
|
||||||
|
* serializing pills as either MD links, plain text representations or
|
||||||
|
* ID representations as required.
|
||||||
|
*
|
||||||
|
* @param {Value} value
|
||||||
|
* @return {String}
|
||||||
|
*/
|
||||||
|
serialize = value => {
|
||||||
|
return this._serializeNode(value.document)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a `node` to plain text.
|
||||||
|
*
|
||||||
|
* @param {Node} node
|
||||||
|
* @return {String}
|
||||||
|
*/
|
||||||
|
_serializeNode = node => {
|
||||||
|
if (
|
||||||
|
node.object == 'document' ||
|
||||||
|
(node.object == 'block' && Block.isBlockList(node.nodes))
|
||||||
|
) {
|
||||||
|
return node.nodes.map(this._serializeNode).join('\n');
|
||||||
|
} else if (node.type == 'pill') {
|
||||||
|
switch (this.pillFormat) {
|
||||||
|
case 'plain':
|
||||||
|
return node.text;
|
||||||
|
case 'md':
|
||||||
|
return `[${ node.text }](${ node.data.get('url') })`;
|
||||||
|
case 'id':
|
||||||
|
return node.data.completionId || node.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (node.nodes) {
|
||||||
|
return node.nodes.map(this._serializeNode).join('');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return node.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export.
|
||||||
|
*
|
||||||
|
* @type {PlainWithPillsSerializer}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default PlainWithPillsSerializer
|
@ -78,6 +78,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||||||
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
|
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
|
||||||
return {
|
return {
|
||||||
completion: displayAlias,
|
completion: displayAlias,
|
||||||
|
completionId: displayAlias,
|
||||||
suffix: ' ',
|
suffix: ' ',
|
||||||
href: makeRoomPermalink(displayAlias),
|
href: makeRoomPermalink(displayAlias),
|
||||||
component: (
|
component: (
|
||||||
|
@ -113,6 +113,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||||||
// Length of completion should equal length of text in decorator. draft-js
|
// Length of completion should equal length of text in decorator. draft-js
|
||||||
// relies on the length of the entity === length of the text in the decoration.
|
// relies on the length of the entity === length of the text in the decoration.
|
||||||
completion: user.rawDisplayName.replace(' (IRC)', ''),
|
completion: user.rawDisplayName.replace(' (IRC)', ''),
|
||||||
|
completionId: user.userId,
|
||||||
suffix: range.start === 0 ? ': ' : ' ',
|
suffix: range.start === 0 ? ': ' : ' ',
|
||||||
href: makeUserPermalink(user.userId),
|
href: makeUserPermalink(user.userId),
|
||||||
component: (
|
component: (
|
||||||
|
@ -263,7 +263,6 @@ export default class Autocomplete extends React.Component {
|
|||||||
const componentPosition = position;
|
const componentPosition = position;
|
||||||
position++;
|
position++;
|
||||||
|
|
||||||
const onMouseMove = () => this.setSelection(componentPosition);
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
this.setSelection(componentPosition);
|
this.setSelection(componentPosition);
|
||||||
this.onCompletionClicked();
|
this.onCompletionClicked();
|
||||||
@ -273,7 +272,6 @@ export default class Autocomplete extends React.Component {
|
|||||||
key: i,
|
key: i,
|
||||||
ref: `completion${position - 1}`,
|
ref: `completion${position - 1}`,
|
||||||
className,
|
className,
|
||||||
onMouseMove,
|
|
||||||
onClick,
|
onClick,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -25,6 +25,7 @@ import { Value, Document, Event, Inline, Text, Range, Node } from 'slate';
|
|||||||
import Html from 'slate-html-serializer';
|
import Html from 'slate-html-serializer';
|
||||||
import { Markdown as Md } from 'slate-md-serializer';
|
import { Markdown as Md } from 'slate-md-serializer';
|
||||||
import Plain from 'slate-plain-serializer';
|
import Plain from 'slate-plain-serializer';
|
||||||
|
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
|
||||||
|
|
||||||
// import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier,
|
// import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier,
|
||||||
// getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState,
|
// getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState,
|
||||||
@ -157,7 +158,7 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
// the currently displayed editor state (note: this is always what is modified on input)
|
// the currently displayed editor state (note: this is always what is modified on input)
|
||||||
editorState: this.createEditorState(
|
editorState: this.createEditorState(
|
||||||
isRichtextEnabled,
|
isRichtextEnabled,
|
||||||
MessageComposerStore.getContentState(this.props.room.roomId),
|
MessageComposerStore.getEditorState(this.props.room.roomId),
|
||||||
),
|
),
|
||||||
|
|
||||||
// the original editor state, before we started tabbing through completions
|
// the original editor state, before we started tabbing through completions
|
||||||
@ -172,6 +173,10 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.client = MatrixClientPeg.get();
|
this.client = MatrixClientPeg.get();
|
||||||
|
|
||||||
|
this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
|
||||||
|
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
|
||||||
|
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -686,30 +691,27 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
const contentState = this.state.editorState;
|
const editorState = this.state.editorState;
|
||||||
|
|
||||||
let contentText = Plain.serialize(contentState);
|
let contentText;
|
||||||
let contentHTML;
|
let contentHTML;
|
||||||
|
|
||||||
if (contentText === '') return true;
|
// only look for commands if the first block contains simple unformatted text
|
||||||
|
// i.e. no pills or rich-text formatting.
|
||||||
|
let cmd, commandText;
|
||||||
|
const firstChild = editorState.document.nodes.get(0);
|
||||||
|
const firstGrandChild = firstChild && firstChild.nodes.get(0);
|
||||||
|
if (firstChild && firstGrandChild &&
|
||||||
|
firstChild.object === 'block' && firstGrandChild.object === 'text' &&
|
||||||
|
firstGrandChild.text[0] === '/' && firstGrandChild.text[1] !== '/')
|
||||||
|
{
|
||||||
|
commandText = this.plainWithIdPills.serialize(editorState);
|
||||||
|
cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
// Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour.
|
|
||||||
// We have to do this now as opposed to after calculating the contentText for MD
|
|
||||||
// mode because entity positions may not be maintained when using
|
|
||||||
// md.toPlaintext().
|
|
||||||
// Unfortunately this means we lose mentions in history when in MD mode. This
|
|
||||||
// would be fixed if history was stored as contentState.
|
|
||||||
contentText = this.removeMDLinks(contentState, ['@']);
|
|
||||||
|
|
||||||
// Some commands (/join) require pills to be replaced with their text content
|
|
||||||
const commandText = this.removeMDLinks(contentState, ['#']);
|
|
||||||
*/
|
|
||||||
const commandText = contentText;
|
|
||||||
const cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
|
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (!cmd.error) {
|
if (!cmd.error) {
|
||||||
this.historyManager.save(contentState, this.state.isRichtextEnabled ? 'rich' : 'markdown');
|
this.historyManager.save(editorState, this.state.isRichtextEnabled ? 'rich' : 'markdown');
|
||||||
this.setState({
|
this.setState({
|
||||||
editorState: this.createEditorState(),
|
editorState: this.createEditorState(),
|
||||||
});
|
});
|
||||||
@ -774,46 +776,31 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
shouldSendHTML = hasLink;
|
shouldSendHTML = hasLink;
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
contentText = this.plainWithPlainPills.serialize(editorState);
|
||||||
|
if (contentText === '') return true;
|
||||||
|
|
||||||
let shouldSendHTML = true;
|
let shouldSendHTML = true;
|
||||||
if (shouldSendHTML) {
|
if (shouldSendHTML) {
|
||||||
contentHTML = HtmlUtils.processHtmlForSending(
|
contentHTML = HtmlUtils.processHtmlForSending(
|
||||||
RichText.editorStateToHTML(contentState),
|
RichText.editorStateToHTML(editorState),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const sourceWithPills = this.plainWithMdPills.serialize(editorState);
|
||||||
|
if (sourceWithPills === '') return true;
|
||||||
|
|
||||||
// Use the original contentState because `contentText` has had mentions
|
const mdWithPills = new Markdown(sourceWithPills);
|
||||||
// stripped and these need to end up in contentHTML.
|
|
||||||
|
|
||||||
/*
|
|
||||||
// Replace all Entities of type `LINK` with markdown link equivalents.
|
|
||||||
// TODO: move this into `Markdown` and do the same conversion in the other
|
|
||||||
// two places (toggling from MD->RT mode and loading MD history into RT mode)
|
|
||||||
// but this can only be done when history includes Entities.
|
|
||||||
const pt = contentState.getBlocksAsArray().map((block) => {
|
|
||||||
let blockText = block.getText();
|
|
||||||
let offset = 0;
|
|
||||||
this.findPillEntities(contentState, block, (start, end) => {
|
|
||||||
const entity = contentState.getEntity(block.getEntityAt(start));
|
|
||||||
if (entity.getType() !== 'LINK') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = blockText.slice(offset + start, offset + end);
|
|
||||||
const url = entity.getData().url;
|
|
||||||
const mdLink = `[${text}](${url})`;
|
|
||||||
blockText = blockText.slice(0, offset + start) + mdLink + blockText.slice(offset + end);
|
|
||||||
offset += mdLink.length - text.length;
|
|
||||||
});
|
|
||||||
return blockText;
|
|
||||||
}).join('\n');
|
|
||||||
*/
|
|
||||||
const md = new Markdown(contentText);
|
|
||||||
// if contains no HTML and we're not quoting (needing HTML)
|
// if contains no HTML and we're not quoting (needing HTML)
|
||||||
if (md.isPlainText() && !mustSendHTML) {
|
if (mdWithPills.isPlainText() && !mustSendHTML) {
|
||||||
contentText = md.toPlaintext();
|
// N.B. toPlainText is only usable here because we know that the MD
|
||||||
|
// didn't contain any formatting in the first place...
|
||||||
|
contentText = mdWithPills.toPlaintext();
|
||||||
} else {
|
} else {
|
||||||
contentText = md.toPlaintext();
|
// to avoid ugliness clients which can't parse HTML we don't send pills
|
||||||
contentHTML = md.toHTML();
|
// in the plaintext body.
|
||||||
|
contentText = this.plainWithPlainPills.serialize(editorState);
|
||||||
|
contentHTML = mdWithPills.toHTML();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -821,11 +808,11 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
let sendTextFn = ContentHelpers.makeTextMessage;
|
let sendTextFn = ContentHelpers.makeTextMessage;
|
||||||
|
|
||||||
this.historyManager.save(
|
this.historyManager.save(
|
||||||
contentState,
|
editorState,
|
||||||
this.state.isRichtextEnabled ? 'rich' : 'markdown',
|
this.state.isRichtextEnabled ? 'rich' : 'markdown',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (contentText.startsWith('/me')) {
|
if (commandText && commandText.startsWith('/me')) {
|
||||||
if (replyingToEv) {
|
if (replyingToEv) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
|
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
|
||||||
@ -842,14 +829,16 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
sendTextFn = ContentHelpers.makeEmoteMessage;
|
sendTextFn = ContentHelpers.makeEmoteMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = contentHTML ?
|
||||||
let content = contentHTML ? sendHtmlFn(contentText, contentHTML) : sendTextFn(contentText);
|
sendHtmlFn(contentText, contentHTML) :
|
||||||
|
sendTextFn(contentText);
|
||||||
|
|
||||||
if (replyingToEv) {
|
if (replyingToEv) {
|
||||||
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
|
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
|
||||||
content = Object.assign(replyContent, content);
|
content = Object.assign(replyContent, content);
|
||||||
|
|
||||||
// Part of Replies fallback support - prepend the text we're sending with the text we're replying to
|
// Part of Replies fallback support - prepend the text we're sending
|
||||||
|
// with the text we're replying to
|
||||||
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
|
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
|
||||||
if (nestedReply) {
|
if (nestedReply) {
|
||||||
if (content.formatted_body) {
|
if (content.formatted_body) {
|
||||||
@ -1009,20 +998,26 @@ export default class MessageComposerInput extends React.Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion;
|
const {
|
||||||
|
range = null,
|
||||||
|
completion = '',
|
||||||
|
completionId = '',
|
||||||
|
href = null,
|
||||||
|
suffix = ''
|
||||||
|
} = displayedCompletion;
|
||||||
|
|
||||||
let inline;
|
let inline;
|
||||||
if (href) {
|
if (href) {
|
||||||
inline = Inline.create({
|
inline = Inline.create({
|
||||||
type: 'pill',
|
type: 'pill',
|
||||||
data: { url: href },
|
data: { url: href },
|
||||||
nodes: [Text.create(completion)],
|
nodes: [Text.create(completionId || completion)],
|
||||||
});
|
});
|
||||||
} else if (completion === '@room') {
|
} else if (completion === '@room') {
|
||||||
inline = Inline.create({
|
inline = Inline.create({
|
||||||
type: 'pill',
|
type: 'pill',
|
||||||
data: { type: Pill.TYPE_AT_ROOM_MENTION },
|
data: { type: Pill.TYPE_AT_ROOM_MENTION },
|
||||||
nodes: [Text.create(completion)],
|
nodes: [Text.create(completionId || completion)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ class MessageComposerStore extends Store {
|
|||||||
__onDispatch(payload) {
|
__onDispatch(payload) {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'editor_state':
|
case 'editor_state':
|
||||||
this._contentState(payload);
|
this._editorState(payload);
|
||||||
break;
|
break;
|
||||||
case 'on_logged_out':
|
case 'on_logged_out':
|
||||||
this.reset();
|
this.reset();
|
||||||
@ -52,7 +52,7 @@ class MessageComposerStore extends Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_contentState(payload) {
|
_editorState(payload) {
|
||||||
const editorStateMap = this._state.editorStateMap;
|
const editorStateMap = this._state.editorStateMap;
|
||||||
editorStateMap[payload.room_id] = payload.editor_state;
|
editorStateMap[payload.room_id] = payload.editor_state;
|
||||||
localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
|
localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
|
||||||
@ -61,7 +61,7 @@ class MessageComposerStore extends Store {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getContentState(roomId) {
|
getEditorState(roomId) {
|
||||||
return this._state.editorStateMap[roomId];
|
return this._state.editorStateMap[roomId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user