Merge pull request #2070 from matrix-org/t3chguy/slate_cont2

Even More Slate Fixes :D
pull/21833/head
David Baker 2018-07-19 13:49:42 +01:00 committed by GitHub
commit f9af96d422
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 170 additions and 118 deletions

View File

@ -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 <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} 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 * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * 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 = { const sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
@ -164,102 +284,14 @@ const sanitizeHtmlParams = {
allowedSchemes: PERMITTED_URL_SCHEMES, allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false, allowProtocolRelative: false,
transformTags,
};
transformTags: { // custom to matrix // this is the same as the above except with less rewriting
// add blank targets to all hyperlinks except vector URLs const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
'a': function(tagName, attribs) { composerSanitizeHtmlParams.transformTags = {
if (attribs.href) { 'code': transformTags['code'],
attribs.target = '_blank'; // by default '*': transformTags['*'],
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 };
},
},
}; };
class BaseHighlighter { 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.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.returnString: return an HTML string rather than JSX elements
* opts.emojiOne: optional param to do emojiOne (default true) * 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={}) { export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; 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; const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
let bodyHasEmoji = false; let bodyHasEmoji = false;
let sanitizeParams = sanitizeHtmlParams;
if (opts.forComposerQuote) {
sanitizeParams = composerSanitizeHtmlParams;
}
let strippedBody; let strippedBody;
let safeBody; let safeBody;
let isDisplayedWithHtml; let isDisplayedWithHtml;
@ -403,10 +441,10 @@ export function bodyToHtml(content, highlights, opts={}) {
if (highlights && highlights.length > 0) { if (highlights && highlights.length > 0) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) { 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. // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeHtmlParams.textFilter = function(safeText) { sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join(''); 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 // Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) { if (isHtmlMessage) {
isDisplayedWithHtml = true; isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams); safeBody = sanitizeHtml(formattedBody, sanitizeParams);
} else { } else {
// ... or if there are emoji, which we insert as HTML alongside the // ... or if there are emoji, which we insert as HTML alongside the
// escaped plaintext body. // escaped plaintext body.
if (bodyHasEmoji) { if (bodyHasEmoji) {
isDisplayedWithHtml = true; 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); safeBody = unicodeToImage(safeBody);
} }
} finally { } finally {
delete sanitizeHtmlParams.textFilter; delete sanitizeParams.textFilter;
} }
if (opts.returnString) { if (opts.returnString) {

View File

@ -111,7 +111,7 @@ export default class Markdown {
// you can nest them. // you can nest them.
// //
// Let's try sending with <p/>s anyway for now, though. // Let's try sending with <p/>s anyway for now, though.
/*
const real_paragraph = renderer.paragraph; const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {
@ -124,10 +124,10 @@ export default class Markdown {
real_paragraph.call(this, node, entering); real_paragraph.call(this, node, entering);
} }
}; };
*/
renderer.html_inline = html_if_tag_allowed; renderer.html_inline = html_if_tag_allowed;
renderer.html_block = function(node) { renderer.html_block = function(node) {
/* /*
// as with `paragraph`, we only insert line breaks // as with `paragraph`, we only insert line breaks
@ -138,7 +138,7 @@ export default class Markdown {
html_if_tag_allowed.call(this, node); html_if_tag_allowed.call(this, node);
/* /*
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/ */
}; };
return renderer.render(this.parsed); return renderer.render(this.parsed);

View File

@ -330,8 +330,9 @@ export default class MessageComposerInput extends React.Component {
} }
return editorState; return editorState;
} else { } else {
// ...or create a new one. // ...or create a new one. and explicitly focus it otherwise tab in-out issues
return Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); const base = Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
return base.change().focus().value;
} }
} }
@ -372,6 +373,7 @@ export default class MessageComposerInput extends React.Component {
break; break;
case 'quote': { case 'quote': {
const html = HtmlUtils.bodyToHtml(payload.event.getContent(), null, { const html = HtmlUtils.bodyToHtml(payload.event.getContent(), null, {
forComposerQuote: true,
returnString: true, returnString: true,
emojiOne: false, emojiOne: false,
}); });
@ -502,8 +504,9 @@ export default class MessageComposerInput extends React.Component {
// when in autocomplete mode and selection changes hide the autocomplete. // 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 // 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 && 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(); this.autocomplete.hide();
} }
@ -732,6 +735,7 @@ export default class MessageComposerInput extends React.Component {
}[ev.keyCode]; }[ev.keyCode];
if (ctrlCmdCommand) { if (ctrlCmdCommand) {
ev.preventDefault(); // to prevent clashing with Mac's minimize window
return this.handleKeyCommand(ctrlCmdCommand); return this.handleKeyCommand(ctrlCmdCommand);
} }
} }
@ -974,17 +978,28 @@ export default class MessageComposerInput extends React.Component {
case 'files': case 'files':
return this.props.onFilesPasted(transfer.files); return this.props.onFilesPasted(transfer.files);
case 'html': { 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) { 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 { } 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': 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 (contentText === '') return true;
if (shouldSendHTML) { if (shouldSendHTML) {
// FIXME: should we strip out the surrounding <p></p>? contentHTML = HtmlUtils.processHtmlForSending(this.html.serialize(editorState));
contentHTML = this.html.serialize(editorState); // HtmlUtils.processHtmlForSending();
} }
} else { } else {
const sourceWithPills = this.plainWithMdPills.serialize(editorState); const sourceWithPills = this.plainWithMdPills.serialize(editorState);
@ -1537,7 +1551,7 @@ export default class MessageComposerInput extends React.Component {
let {placeholder} = this.props; let {placeholder} = this.props;
// XXX: workaround for placeholder being shown when there is a formatting block e.g blockquote but no text // 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; placeholder = undefined;
} }