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

View File

@ -111,7 +111,7 @@ export default class Markdown {
// you can nest them.
//
// Let's try sending with <p/>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);

View File

@ -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 <p></p>?
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;
}