From 7de45f8b7beb9e7069b643b02f8b9c904cb5aab4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 21 May 2018 03:48:59 +0100 Subject: [PATCH] make quoting work --- src/HtmlUtils.js | 32 ++++-- .../views/context_menus/MessageContextMenu.js | 2 +- .../views/rooms/MessageComposerInput.js | 107 +++++++++++------- 3 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 4c1564297d..607686d46d 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -403,19 +403,22 @@ class TextHighlighter extends BaseHighlighter { } - /* turn a matrix event body into html - * - * content: 'content' of the MatrixEvent - * - * highlights: optional list of words to highlight, ordered by longest word first - * - * opts.highlightLink: optional href to add to highlighted words - * opts.disableBigEmoji: optional argument to disable the big emoji class. - * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing - */ +/* turn a matrix event body into html + * + * content: 'content' of the MatrixEvent + * + * highlights: optional list of words to highlight, ordered by longest word first + * + * opts.highlightLink: optional href to add to highlighted words + * opts.disableBigEmoji: optional argument to disable the big emoji class. + * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing + * opts.returnString: return an HTML string rather than JSX elements + * opts.emojiOne: optional param to do emojiOne (default true) + */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; + const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne; let bodyHasEmoji = false; let strippedBody; @@ -441,8 +444,9 @@ export function bodyToHtml(content, highlights, opts={}) { if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody); strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; - bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); - + if (doEmojiOne) { + bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); + } // Only generate safeBody if the message was sent as org.matrix.custom.html if (isHtmlMessage) { @@ -467,6 +471,10 @@ export function bodyToHtml(content, highlights, opts={}) { delete sanitizeHtmlParams.textFilter; } + if (opts.returnString) { + return isDisplayedWithHtml ? safeBody : strippedBody; + } + let emojiBody = false; if (!opts.disableBigEmoji && bodyHasEmoji) { EMOJI_REGEX.lastIndex = 0; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 99ec493ced..22c6f2aa70 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -179,7 +179,7 @@ module.exports = React.createClass({ onQuoteClick: function() { dis.dispatch({ action: 'quote', - text: this.props.eventTileOps.getInnerText(), + event: this.props.mxEvent, }); this.closeMenu(); }, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 1f0a544246..eb4edfcfcb 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -21,7 +21,7 @@ import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; import { Editor } from 'slate-react'; import { getEventTransfer } from 'slate-react'; -import { Value, Document, Event, Inline, Text, Range, Node } from 'slate'; +import { Value, Document, Event, Block, Inline, Text, Range, Node } from 'slate'; import Html from 'slate-html-serializer'; import Md from 'slate-md-serializer'; @@ -342,37 +342,44 @@ export default class MessageComposerInput extends React.Component { }); } break; -/* - case 'quote': { // old quoting, whilst rich quoting is in labs - /// 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(`
${body}
`); - if (!this.state.isRichTextEnabled) { - content = ContentState.createFromText(RichText.stateToMarkdown(content)); - } + case 'quote': { + const html = HtmlUtils.bodyToHtml(payload.event.getContent(), null, { + returnString: true, + emojiOne: false, + }); + const fragment = this.html.deserialize(html); + // FIXME: do we want to put in a permalink to the original quote here? + // If so, what should be the format, and how do we differentiate it from replies? - 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'); + const quote = Block.create('block-quote'); + if (this.state.isRichTextEnabled) { + let change = editorState.change(); + if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) { + // replace the current block rather than split the block + change = change.replaceNodeByKey(editorState.anchorBlock.key, quote); } - let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); - editorState = EditorState.moveSelectionToEnd(editorState); - this.onEditorContentChanged(editorState); - editor.focus(); + else { + // insert it into the middle of the block (splitting it) + change = change.insertBlock(quote); + } + change = change.insertFragmentByKey(quote.key, 0, fragment.document) + .focus(); + this.onChange(change); + } + else { + let fragmentChange = fragment.change(); + fragmentChange.moveToRangeOf(fragment.document) + .wrapBlock(quote); + + // FIXME: handle pills and use commonmark rather than md-serialize + const md = this.md.serialize(fragmentChange.value); + let change = editorState.change() + .insertText(md + '\n\n') + .focus(); + this.onChange(change); } } break; -*/ } }; @@ -555,7 +562,7 @@ export default class MessageComposerInput extends React.Component { } } - if (this.props.onInputStateChanged) { + if (this.props.onInputStateChanged && editorState.blocks.size > 0) { let blockType = editorState.blocks.first().type; // console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks); @@ -740,17 +747,31 @@ export default class MessageComposerInput extends React.Component { .unwrapBlock('numbered-list'); return change; } - else if (editorState.anchorOffset == 0 && - (this.hasBlock('block-quote') || - this.hasBlock('heading1') || - this.hasBlock('heading2') || - this.hasBlock('heading3') || - this.hasBlock('heading4') || - this.hasBlock('heading5') || - this.hasBlock('heading6') || - this.hasBlock('code-block'))) - { - return change.setBlocks(DEFAULT_NODE); + else if (editorState.anchorOffset == 0 && editorState.isCollapsed) { + // turn blocks back into paragraphs + if ((this.hasBlock('block-quote') || + this.hasBlock('heading1') || + this.hasBlock('heading2') || + this.hasBlock('heading3') || + this.hasBlock('heading4') || + this.hasBlock('heading5') || + this.hasBlock('heading6') || + this.hasBlock('code-block'))) + { + return change.setBlocks(DEFAULT_NODE); + } + + // remove paragraphs entirely if they're nested + const parent = editorState.document.getParent(editorState.anchorBlock.key); + if (editorState.anchorOffset == 0 && + this.hasBlock('paragraph') && + parent.nodes.size == 1 && + parent.object !== 'document') + { + return change.replaceNodeByKey(editorState.anchorBlock.key, editorState.anchorText) + .collapseToEndOf(parent) + .focus(); + } } } return; @@ -1013,7 +1034,7 @@ export default class MessageComposerInput extends React.Component { if (!shouldSendHTML) { shouldSendHTML = !!editorState.document.findDescendant(node => { // N.B. node.getMarks() might be private? - return ((node.object === 'block' && node.type !== 'line') || + return ((node.object === 'block' && node.type !== 'paragraph') || (node.object === 'inline') || (node.object === 'text' && node.getMarks().size > 0)); }); @@ -1131,13 +1152,13 @@ export default class MessageComposerInput extends React.Component { // heuristic to handle tall emoji, pills, etc pushing the cursor away from the top // or bottom of the page. // XXX: is this going to break on large inline images or top-to-bottom scripts? - const EDGE_THRESHOLD = 8; + const EDGE_THRESHOLD = 15; let navigateHistory = false; if (up) { const scrollCorrection = editorNode.scrollTop; const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection; - //console.log(`Cursor distance from editor top is ${distanceFromTop}`); + console.log(`Cursor distance from editor top is ${distanceFromTop}`); if (distanceFromTop < EDGE_THRESHOLD) { navigateHistory = true; } @@ -1146,7 +1167,7 @@ export default class MessageComposerInput extends React.Component { const scrollCorrection = editorNode.scrollHeight - editorNode.clientHeight - editorNode.scrollTop; const distanceFromBottom = editorRect.bottom - cursorRect.bottom + scrollCorrection; - //console.log(`Cursor distance from editor bottom is ${distanceFromBottom}`); + console.log(`Cursor distance from editor bottom is ${distanceFromBottom}`); if (distanceFromBottom < EDGE_THRESHOLD) { navigateHistory = true; }