Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Weblate 2018-07-19 12:49:49 +00:00
commit 173806e657
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,31 +168,7 @@ export function isUrlPermitted(inputUrl) {
} }
} }
const sanitizeHtmlParams = { const transformTags = { // custom to matrix
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
],
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false,
transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) { 'a': function(tagName, attribs) {
if (attribs.href) { if (attribs.href) {
@ -198,7 +201,7 @@ const sanitizeHtmlParams = {
} }
} }
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs: attribs }; return { tagName, attribs };
}, },
'img': function(tagName, attribs) { 'img': function(tagName, attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
@ -212,7 +215,7 @@ const sanitizeHtmlParams = {
attribs.width || 800, attribs.width || 800,
attribs.height || 600, attribs.height || 600,
); );
return { tagName: tagName, attribs: attribs }; return { tagName, attribs };
}, },
'code': function(tagName, attribs) { 'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') { if (typeof attribs.class !== 'undefined') {
@ -222,10 +225,7 @@ const sanitizeHtmlParams = {
}); });
attribs.class = classes.join(' '); attribs.class = classes.join(' ');
} }
return { return { tagName, attribs };
tagName: tagName,
attribs: attribs,
};
}, },
'*': function(tagName, attribs) { '*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span // Delete any style previously assigned, style is an allowedTag for font and span
@ -257,9 +257,41 @@ const sanitizeHtmlParams = {
attribs.style = style; attribs.style = style;
} }
return { tagName: tagName, attribs: attribs }; return { tagName, attribs };
}, },
};
const sanitizeHtmlParams = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
],
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
}, },
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false,
transformTags,
};
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
composerSanitizeHtmlParams.transformTags = {
'code': transformTags['code'],
'*': transformTags['*'],
}; };
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,7 +124,7 @@ 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;

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': {
if (this.state.isRichTextEnabled) {
// FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means // FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means
// that we will silently discard nested blocks (e.g. nested lists) :( // that we will silently discard nested blocks (e.g. nested lists) :(
const fragment = this.html.deserialize(transfer.html); const fragment = this.html.deserialize(transfer.html);
if (this.state.isRichTextEnabled) { return change
return change.insertFragment(fragment.document); .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;
} }