diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 09ce1187d5..b6a2bd0acb 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -112,6 +112,33 @@ export function charactersToImageNode(alt, useSvg, ...unicode) { />; } +export function processHtmlForSending(html: string): string { + const contentDiv = document.createElement('div'); + contentDiv.innerHTML = html; + + if (contentDiv.children.length === 0) { + return contentDiv.innerHTML; + } + + let contentHTML = ""; + for (let i=0; i < contentDiv.children.length; i++) { + const element = contentDiv.children[i]; + if (element.tagName.toLowerCase() === 'p') { + contentHTML += element.innerHTML; + // Don't add a
for the last

+ if (i !== contentDiv.children.length - 1) { + contentHTML += '
'; + } + } else { + const temp = document.createElement('div'); + temp.appendChild(element.cloneNode(true)); + contentHTML += temp.innerHTML; + } + } + + return contentHTML; +} + /* * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. @@ -141,6 +168,99 @@ export function isUrlPermitted(inputUrl) { } } +const transformTags = { // custom to matrix + // add blank targets to all hyperlinks except vector URLs + 'a': function(tagName, attribs) { + if (attribs.href) { + attribs.target = '_blank'; // by default + + let m; + // FIXME: horrible duplication with linkify-matrix + m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); + if (m) { + attribs.href = m[1]; + delete attribs.target; + } else { + m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); + if (m) { + const entity = m[1]; + switch (entity[0]) { + case '@': + attribs.href = '#/user/' + entity; + break; + case '+': + attribs.href = '#/group/' + entity; + break; + case '#': + case '!': + attribs.href = '#/room/' + entity; + break; + } + delete attribs.target; + } + } + } + attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ + return { tagName, attribs }; + }, + 'img': function(tagName, attribs) { + // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag + // because transformTags is used _before_ we filter by allowedSchemesByTag and + // we don't want to allow images with `https?` `src`s. + if (!attribs.src || !attribs.src.startsWith('mxc://')) { + return { tagName, attribs: {}}; + } + attribs.src = MatrixClientPeg.get().mxcUrlToHttp( + attribs.src, + attribs.width || 800, + attribs.height || 600, + ); + return { tagName, attribs }; + }, + 'code': function(tagName, attribs) { + if (typeof attribs.class !== 'undefined') { + // Filter out all classes other than ones starting with language- for syntax highlighting. + const classes = attribs.class.split(/\s+/).filter(function(cl) { + return cl.startsWith('language-'); + }); + attribs.class = classes.join(' '); + } + return { tagName, attribs }; + }, + '*': function(tagName, attribs) { + // Delete any style previously assigned, style is an allowedTag for font and span + // because attributes are stripped after transforming + delete attribs.style; + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper = { + 'data-mx-color': 'color', + 'data-mx-bg-color': 'background-color', + // $customAttributeKey: $cssAttributeKey + }; + + let style = ""; + Object.keys(customCSSMapper).forEach((customAttributeKey) => { + const cssAttributeKey = customCSSMapper[customAttributeKey]; + const customAttributeValue = attribs[customAttributeKey]; + if (customAttributeValue && + typeof customAttributeValue === 'string' && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";"; + delete attribs[customAttributeKey]; + } + }); + + if (style) { + attribs.style = style; + } + + return { tagName, attribs }; + }, +}; + const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring @@ -164,102 +284,14 @@ const sanitizeHtmlParams = { allowedSchemes: PERMITTED_URL_SCHEMES, allowProtocolRelative: false, + transformTags, +}; - transformTags: { // custom to matrix - // add blank targets to all hyperlinks except vector URLs - 'a': function(tagName, attribs) { - if (attribs.href) { - attribs.target = '_blank'; // by default - - let m; - // FIXME: horrible duplication with linkify-matrix - m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); - if (m) { - attribs.href = m[1]; - delete attribs.target; - } else { - m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); - if (m) { - const entity = m[1]; - switch (entity[0]) { - case '@': - attribs.href = '#/user/' + entity; - break; - case '+': - attribs.href = '#/group/' + entity; - break; - case '#': - case '!': - attribs.href = '#/room/' + entity; - break; - } - delete attribs.target; - } - } - } - attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ - return { tagName: tagName, attribs: attribs }; - }, - 'img': function(tagName, attribs) { - // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag - // because transformTags is used _before_ we filter by allowedSchemesByTag and - // we don't want to allow images with `https?` `src`s. - if (!attribs.src || !attribs.src.startsWith('mxc://')) { - return { tagName, attribs: {}}; - } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); - return { tagName: tagName, attribs: attribs }; - }, - 'code': function(tagName, attribs) { - if (typeof attribs.class !== 'undefined') { - // Filter out all classes other than ones starting with language- for syntax highlighting. - const classes = attribs.class.split(/\s+/).filter(function(cl) { - return cl.startsWith('language-'); - }); - attribs.class = classes.join(' '); - } - return { - tagName: tagName, - attribs: attribs, - }; - }, - '*': function(tagName, attribs) { - // Delete any style previously assigned, style is an allowedTag for font and span - // because attributes are stripped after transforming - delete attribs.style; - - // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS - // equivalents - const customCSSMapper = { - 'data-mx-color': 'color', - 'data-mx-bg-color': 'background-color', - // $customAttributeKey: $cssAttributeKey - }; - - let style = ""; - Object.keys(customCSSMapper).forEach((customAttributeKey) => { - const cssAttributeKey = customCSSMapper[customAttributeKey]; - const customAttributeValue = attribs[customAttributeKey]; - if (customAttributeValue && - typeof customAttributeValue === 'string' && - COLOR_REGEX.test(customAttributeValue) - ) { - style += cssAttributeKey + ":" + customAttributeValue + ";"; - delete attribs[customAttributeKey]; - } - }); - - if (style) { - attribs.style = style; - } - - return { tagName: tagName, attribs: attribs }; - }, - }, +// this is the same as the above except with less rewriting +const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); +composerSanitizeHtmlParams.transformTags = { + 'code': transformTags['code'], + '*': transformTags['*'], }; class BaseHighlighter { @@ -385,6 +417,7 @@ class TextHighlighter extends BaseHighlighter { * 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) + * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; @@ -392,6 +425,11 @@ export function bodyToHtml(content, highlights, opts={}) { const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne; let bodyHasEmoji = false; + let sanitizeParams = sanitizeHtmlParams; + if (opts.forComposerQuote) { + sanitizeParams = composerSanitizeHtmlParams; + } + let strippedBody; let safeBody; let isDisplayedWithHtml; @@ -403,10 +441,10 @@ export function bodyToHtml(content, highlights, opts={}) { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeHtmlParams); + return sanitizeHtml(highlight, sanitizeParams); }); - // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure. - sanitizeHtmlParams.textFilter = function(safeText) { + // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. + sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); }; } @@ -422,13 +460,13 @@ export function bodyToHtml(content, highlights, opts={}) { // Only generate safeBody if the message was sent as org.matrix.custom.html if (isHtmlMessage) { isDisplayedWithHtml = true; - safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams); + safeBody = sanitizeHtml(formattedBody, sanitizeParams); } else { // ... or if there are emoji, which we insert as HTML alongside the // escaped plaintext body. if (bodyHasEmoji) { isDisplayedWithHtml = true; - safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams); + safeBody = sanitizeHtml(escape(strippedBody), sanitizeParams); } } @@ -439,7 +477,7 @@ export function bodyToHtml(content, highlights, opts={}) { safeBody = unicodeToImage(safeBody); } } finally { - delete sanitizeHtmlParams.textFilter; + delete sanitizeParams.textFilter; } if (opts.returnString) { diff --git a/src/Markdown.js b/src/Markdown.js index dc0d5962fd..acfea52100 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -111,7 +111,7 @@ export default class Markdown { // you can nest them. // // Let's try sending with

s anyway for now, though. -/* + const real_paragraph = renderer.paragraph; renderer.paragraph = function(node, entering) { @@ -124,10 +124,10 @@ export default class Markdown { real_paragraph.call(this, node, entering); } }; -*/ + renderer.html_inline = html_if_tag_allowed; - + renderer.html_block = function(node) { /* // as with `paragraph`, we only insert line breaks @@ -138,7 +138,7 @@ export default class Markdown { html_if_tag_allowed.call(this, node); /* if (isMultiLine) this.cr(); -*/ +*/ }; return renderer.render(this.parsed); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 1850601d8a..a6ed136cd5 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -330,8 +330,9 @@ export default class MessageComposerInput extends React.Component { } return editorState; } else { - // ...or create a new one. - return Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); + // ...or create a new one. and explicitly focus it otherwise tab in-out issues + const base = Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); + return base.change().focus().value; } } @@ -372,6 +373,7 @@ export default class MessageComposerInput extends React.Component { break; case 'quote': { const html = HtmlUtils.bodyToHtml(payload.event.getContent(), null, { + forComposerQuote: true, returnString: true, emojiOne: false, }); @@ -502,8 +504,9 @@ export default class MessageComposerInput extends React.Component { // when in autocomplete mode and selection changes hide the autocomplete. // Selection changes when we enter text so use a heuristic to compare documents without doing it recursively if (this.autocomplete.state.completionList.length > 0 && !this.autocomplete.state.hide && - this.state.editorState.document.text === editorState.document.text && - !rangeEquals(this.state.editorState.selection, editorState.selection)) + !rangeEquals(this.state.editorState.selection, editorState.selection) && + // XXX: the heuristic failed when inlines like pills weren't taken into account. This is inideal + this.state.editorState.document.toJSON() === editorState.document.toJSON()) { this.autocomplete.hide(); } @@ -732,6 +735,7 @@ export default class MessageComposerInput extends React.Component { }[ev.keyCode]; if (ctrlCmdCommand) { + ev.preventDefault(); // to prevent clashing with Mac's minimize window return this.handleKeyCommand(ctrlCmdCommand); } } @@ -974,17 +978,28 @@ export default class MessageComposerInput extends React.Component { case 'files': return this.props.onFilesPasted(transfer.files); case 'html': { - // FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means - // that we will silently discard nested blocks (e.g. nested lists) :( - const fragment = this.html.deserialize(transfer.html); if (this.state.isRichTextEnabled) { - return change.insertFragment(fragment.document); + // FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means + // that we will silently discard nested blocks (e.g. nested lists) :( + const fragment = this.html.deserialize(transfer.html); + return change + .setOperationFlag("skip", false) + .setOperationFlag("merge", false) + .insertFragment(fragment.document); } else { - return change.insertText(this.md.serialize(fragment)); + // in MD mode we don't want the rich content pasted as the magic was annoying people so paste plain + return change + .setOperationFlag("skip", false) + .setOperationFlag("merge", false) + .insertText(transfer.text); } } case 'text': - return change.insertText(transfer.text); + // don't skip/merge so that multiple consecutive pastes can be undone individually + return change + .setOperationFlag("skip", false) + .setOperationFlag("merge", false) + .insertText(transfer.text); } }; @@ -1087,8 +1102,7 @@ export default class MessageComposerInput extends React.Component { if (contentText === '') return true; if (shouldSendHTML) { - // FIXME: should we strip out the surrounding

? - contentHTML = this.html.serialize(editorState); // HtmlUtils.processHtmlForSending(); + contentHTML = HtmlUtils.processHtmlForSending(this.html.serialize(editorState)); } } else { const sourceWithPills = this.plainWithMdPills.serialize(editorState); @@ -1537,7 +1551,7 @@ export default class MessageComposerInput extends React.Component { let {placeholder} = this.props; // XXX: workaround for placeholder being shown when there is a formatting block e.g blockquote but no text - if (isEmpty && this.state.editorState.startBlock.type !== DEFAULT_NODE) { + if (isEmpty && this.state.editorState.startBlock && this.state.editorState.startBlock.type !== DEFAULT_NODE) { placeholder = undefined; }