2015-11-27 15:02:32 +00:00
|
|
|
/*
|
2016-01-07 04:06:39 +00:00
|
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
2018-02-09 12:20:05 +00:00
|
|
|
Copyright 2017, 2018 New Vector Ltd
|
2019-06-29 06:28:09 +00:00
|
|
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
2019-10-01 02:17:54 +00:00
|
|
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
2015-11-27 15:02:32 +00:00
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2019-01-31 21:26:07 +00:00
|
|
|
import React from 'react';
|
|
|
|
import sanitizeHtml from 'sanitize-html';
|
|
|
|
import * as linkify from 'linkifyjs';
|
|
|
|
import linkifyMatrix from './linkify-matrix';
|
|
|
|
import _linkifyElement from 'linkifyjs/element';
|
|
|
|
import _linkifyString from 'linkifyjs/string';
|
2016-07-04 22:34:57 +00:00
|
|
|
import classNames from 'classnames';
|
2020-07-08 07:40:58 +00:00
|
|
|
import EMOJIBASE_REGEX from 'emojibase-regex';
|
2018-02-09 12:20:05 +00:00
|
|
|
import url from 'url';
|
2016-07-04 22:34:57 +00:00
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
import {MatrixClientPeg} from './MatrixClientPeg';
|
2019-10-01 02:39:58 +00:00
|
|
|
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
2019-12-18 15:40:19 +00:00
|
|
|
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
2020-07-08 07:40:58 +00:00
|
|
|
import ReplyThread from "./components/views/elements/ReplyThread";
|
2019-01-31 21:26:07 +00:00
|
|
|
|
2019-05-19 14:23:43 +00:00
|
|
|
linkifyMatrix(linkify);
|
2016-08-09 16:10:05 +00:00
|
|
|
|
2017-09-15 10:43:55 +00:00
|
|
|
// Anything outside the basic multilingual plane will be a surrogate pair
|
|
|
|
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
|
2019-05-19 14:23:43 +00:00
|
|
|
// And there a bunch more symbol characters that emojibase has within the
|
2017-09-15 10:43:55 +00:00
|
|
|
// BMP, so this includes the ranges from 'letterlike symbols' to
|
|
|
|
// 'miscellaneous symbols and arrows' which should catch all of them
|
|
|
|
// (with plenty of false positives, but that's OK)
|
|
|
|
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
|
|
|
|
|
2019-03-06 14:53:24 +00:00
|
|
|
// Regex pattern for Zero-Width joiner unicode characters
|
2019-03-05 12:33:37 +00:00
|
|
|
const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g");
|
|
|
|
|
2019-03-06 14:53:24 +00:00
|
|
|
// Regex pattern for whitespace characters
|
|
|
|
const WHITESPACE_REGEX = new RegExp("\\s", "g");
|
|
|
|
|
2019-05-19 16:53:36 +00:00
|
|
|
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
2019-05-19 16:06:21 +00:00
|
|
|
|
2017-03-03 15:46:13 +00:00
|
|
|
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
2015-11-27 15:02:32 +00:00
|
|
|
|
2018-02-09 12:20:05 +00:00
|
|
|
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
|
|
|
|
2017-09-08 22:05:27 +00:00
|
|
|
/*
|
|
|
|
* Return true if the given string contains emoji
|
2019-05-19 14:23:43 +00:00
|
|
|
* Uses a much, much simpler regex than emojibase's so will give false
|
2017-09-08 22:05:27 +00:00
|
|
|
* positives, but useful for fast-path testing strings to see if they
|
|
|
|
* need emojification.
|
|
|
|
* unicodeToImage uses this function.
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
function mightContainEmoji(str: string) {
|
2017-09-15 10:43:55 +00:00
|
|
|
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
2017-09-08 22:05:27 +00:00
|
|
|
}
|
|
|
|
|
2019-05-17 10:52:03 +00:00
|
|
|
/**
|
|
|
|
* Returns the shortcode for an emoji character.
|
|
|
|
*
|
|
|
|
* @param {String} char The emoji character
|
|
|
|
* @return {String} The shortcode (such as :thumbup:)
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function unicodeToShortcode(char: string) {
|
2019-12-18 15:40:19 +00:00
|
|
|
const data = getEmojiFromUnicode(char);
|
2019-05-19 19:48:18 +00:00
|
|
|
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
2019-05-17 10:52:03 +00:00
|
|
|
}
|
|
|
|
|
2017-03-14 11:50:13 +00:00
|
|
|
/**
|
2019-05-19 14:23:43 +00:00
|
|
|
* Returns the unicode character for an emoji shortcode
|
2017-03-14 11:50:13 +00:00
|
|
|
*
|
2019-05-19 14:23:43 +00:00
|
|
|
* @param {String} shortcode The shortcode (such as :thumbup:)
|
2019-05-19 19:48:18 +00:00
|
|
|
* @return {String} The emoji character; null if none exists
|
2017-03-14 11:50:13 +00:00
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function shortcodeToUnicode(shortcode: string) {
|
2019-05-19 19:48:18 +00:00
|
|
|
shortcode = shortcode.slice(1, shortcode.length - 1);
|
2019-12-18 15:40:19 +00:00
|
|
|
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
2019-05-19 19:48:18 +00:00
|
|
|
return data ? data.unicode : null;
|
2017-03-14 11:50:13 +00:00
|
|
|
}
|
|
|
|
|
2019-05-20 09:10:30 +00:00
|
|
|
export function processHtmlForSending(html: string): string {
|
2018-07-18 09:48:54 +00:00
|
|
|
const contentDiv = document.createElement('div');
|
|
|
|
contentDiv.innerHTML = html;
|
|
|
|
|
|
|
|
if (contentDiv.children.length === 0) {
|
|
|
|
return contentDiv.innerHTML;
|
|
|
|
}
|
|
|
|
|
|
|
|
let contentHTML = "";
|
2020-07-08 07:40:58 +00:00
|
|
|
for (let i = 0; i < contentDiv.children.length; i++) {
|
2018-07-18 09:48:54 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2017-06-23 16:02:54 +00:00
|
|
|
/*
|
|
|
|
* Given an untrusted HTML string, return a React node with an sanitized version
|
|
|
|
* of that HTML.
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function sanitizedHtmlNode(insaneHtml: string) {
|
2017-09-13 11:04:46 +00:00
|
|
|
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
2017-06-23 16:02:54 +00:00
|
|
|
|
|
|
|
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:50:25 +00:00
|
|
|
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
|
|
|
|
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
|
|
|
const contentDiv = document.createElement("div");
|
|
|
|
contentDiv.innerHTML = saneHtml;
|
|
|
|
return contentDiv.innerText;
|
|
|
|
}
|
|
|
|
|
2018-02-09 12:20:05 +00:00
|
|
|
/**
|
|
|
|
* Tests if a URL from an untrusted source may be safely put into the DOM
|
|
|
|
* The biggest threat here is javascript: URIs.
|
|
|
|
* Note that the HTML sanitiser library has its own internal logic for
|
|
|
|
* doing this, to which we pass the same list of schemes. This is used in
|
|
|
|
* other places we need to sanitise URLs.
|
|
|
|
* @return true if permitted, otherwise false
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function isUrlPermitted(inputUrl: string) {
|
2018-02-09 12:20:05 +00:00
|
|
|
try {
|
|
|
|
const parsed = url.parse(inputUrl);
|
|
|
|
if (!parsed.protocol) return false;
|
|
|
|
// URL parser protocol includes the trailing colon
|
|
|
|
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
|
|
|
|
} catch (e) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
|
2018-07-18 09:10:42 +00:00
|
|
|
// add blank targets to all hyperlinks except vector URLs
|
2020-07-08 07:40:58 +00:00
|
|
|
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
2018-07-18 09:10:42 +00:00
|
|
|
if (attribs.href) {
|
|
|
|
attribs.target = '_blank'; // by default
|
|
|
|
|
2019-10-01 02:17:54 +00:00
|
|
|
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
|
|
|
|
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) {
|
|
|
|
attribs.href = transformed;
|
2018-07-18 09:10:42 +00:00
|
|
|
delete attribs.target;
|
|
|
|
}
|
|
|
|
}
|
2020-02-23 22:14:29 +00:00
|
|
|
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
2018-07-18 09:10:42 +00:00
|
|
|
return { tagName, attribs };
|
|
|
|
},
|
2020-07-08 07:40:58 +00:00
|
|
|
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
2018-07-18 09:10:42 +00:00
|
|
|
// 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 };
|
|
|
|
},
|
2020-07-08 07:40:58 +00:00
|
|
|
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
2018-07-18 09:10:42 +00:00
|
|
|
if (typeof attribs.class !== 'undefined') {
|
|
|
|
// Filter out all classes other than ones starting with language- for syntax highlighting.
|
2019-03-05 12:33:37 +00:00
|
|
|
const classes = attribs.class.split(/\s/).filter(function(cl) {
|
2020-07-21 16:47:40 +00:00
|
|
|
return cl.startsWith('language-') && !cl.startsWith('language-_');
|
2018-07-18 09:10:42 +00:00
|
|
|
});
|
|
|
|
attribs.class = classes.join(' ');
|
|
|
|
}
|
|
|
|
return { tagName, attribs };
|
|
|
|
},
|
2020-07-08 07:40:58 +00:00
|
|
|
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
2018-07-18 09:10:42 +00:00
|
|
|
// 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 };
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
2015-11-27 15:02:32 +00:00
|
|
|
allowedTags: [
|
2016-02-09 15:07:39 +00:00
|
|
|
'font', // custom to matrix for IRC-style font coloring
|
2015-11-28 12:44:10 +00:00
|
|
|
'del', // for markdown
|
2017-09-13 11:04:46 +00:00
|
|
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
|
2016-04-02 16:45:29 +00:00
|
|
|
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
|
2017-04-13 13:08:19 +00:00
|
|
|
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
|
2015-11-27 15:02:32 +00:00
|
|
|
],
|
|
|
|
allowedAttributes: {
|
|
|
|
// custom ones first:
|
2017-03-02 11:36:56 +00:00
|
|
|
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
|
2019-05-22 18:41:27 +00:00
|
|
|
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
|
2017-01-20 14:22:27 +00:00
|
|
|
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
|
2017-07-24 03:00:36 +00:00
|
|
|
img: ['src', 'width', 'height', 'alt', 'title'],
|
2017-04-02 10:19:50 +00:00
|
|
|
ol: ['start'],
|
2017-06-12 00:03:38 +00:00
|
|
|
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
|
2015-11-27 15:02:32 +00:00
|
|
|
},
|
|
|
|
// Lots of these won't come up by default because we don't allow them
|
2017-01-20 14:22:27 +00:00
|
|
|
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
2015-11-27 15:02:32 +00:00
|
|
|
// URL schemes we permit
|
2018-02-09 12:20:05 +00:00
|
|
|
allowedSchemes: PERMITTED_URL_SCHEMES,
|
2016-09-21 15:25:18 +00:00
|
|
|
|
2017-02-19 01:04:42 +00:00
|
|
|
allowProtocolRelative: false,
|
2018-07-18 09:10:42 +00:00
|
|
|
transformTags,
|
|
|
|
};
|
2016-08-15 20:37:26 +00:00
|
|
|
|
2018-07-18 09:10:42 +00:00
|
|
|
// this is the same as the above except with less rewriting
|
2020-07-08 07:40:58 +00:00
|
|
|
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
|
|
|
|
...sanitizeHtmlParams,
|
|
|
|
transformTags: {
|
|
|
|
'code': transformTags['code'],
|
|
|
|
'*': transformTags['*'],
|
|
|
|
},
|
2015-11-27 15:02:32 +00:00
|
|
|
};
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
abstract class BaseHighlighter<T extends React.ReactNode> {
|
|
|
|
constructor(public highlightClass: string, public highlightLink: string) {
|
2015-12-23 23:50:35 +00:00
|
|
|
}
|
|
|
|
|
2016-02-17 19:50:04 +00:00
|
|
|
/**
|
|
|
|
* apply the highlights to a section of text
|
|
|
|
*
|
|
|
|
* @param {string} safeSnippet The snippet of text to apply the highlights
|
|
|
|
* to.
|
|
|
|
* @param {string[]} safeHighlights A list of substrings to highlight,
|
|
|
|
* sorted by descending length.
|
|
|
|
*
|
|
|
|
* returns a list of results (strings for HtmlHighligher, react nodes for
|
|
|
|
* TextHighlighter).
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
2017-10-11 16:56:17 +00:00
|
|
|
let lastOffset = 0;
|
|
|
|
let offset;
|
2020-07-08 07:40:58 +00:00
|
|
|
let nodes: T[] = [];
|
2015-11-29 03:22:01 +00:00
|
|
|
|
2017-10-11 16:56:17 +00:00
|
|
|
const safeHighlight = safeHighlights[0];
|
2015-12-28 03:14:50 +00:00
|
|
|
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
2015-11-29 03:22:01 +00:00
|
|
|
// handle preamble
|
|
|
|
if (offset > lastOffset) {
|
2020-07-08 07:40:58 +00:00
|
|
|
const subSnippet = safeSnippet.substring(lastOffset, offset);
|
|
|
|
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
2015-11-29 03:22:01 +00:00
|
|
|
}
|
|
|
|
|
2016-02-17 19:50:04 +00:00
|
|
|
// do highlight. use the original string rather than safeHighlight
|
|
|
|
// to preserve the original casing.
|
2017-10-11 16:56:17 +00:00
|
|
|
const endOffset = offset + safeHighlight.length;
|
2020-07-08 07:40:58 +00:00
|
|
|
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
|
2015-11-29 03:22:01 +00:00
|
|
|
|
2016-02-17 19:50:04 +00:00
|
|
|
lastOffset = endOffset;
|
2015-11-29 03:22:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// handle postamble
|
2016-09-16 15:02:08 +00:00
|
|
|
if (lastOffset !== safeSnippet.length) {
|
2020-07-08 07:40:58 +00:00
|
|
|
const subSnippet = safeSnippet.substring(lastOffset, undefined);
|
|
|
|
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
2015-11-29 13:00:58 +00:00
|
|
|
}
|
|
|
|
return nodes;
|
2015-12-23 23:50:35 +00:00
|
|
|
}
|
2015-11-29 13:00:58 +00:00
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
2016-02-10 20:25:32 +00:00
|
|
|
if (safeHighlights[1]) {
|
2015-11-29 13:00:58 +00:00
|
|
|
// recurse into this range to check for the next set of highlight matches
|
2016-02-10 20:25:32 +00:00
|
|
|
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
2017-10-11 16:56:17 +00:00
|
|
|
} else {
|
2015-11-29 13:00:58 +00:00
|
|
|
// no more highlights to be found, just return the unhighlighted string
|
2020-07-08 07:40:58 +00:00
|
|
|
return [this.processSnippet(safeSnippet, false)];
|
2015-12-23 23:50:35 +00:00
|
|
|
}
|
|
|
|
}
|
2020-07-08 07:40:58 +00:00
|
|
|
|
|
|
|
protected abstract processSnippet(snippet: string, highlight: boolean): T;
|
2016-02-17 19:50:04 +00:00
|
|
|
}
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
class HtmlHighlighter extends BaseHighlighter<string> {
|
2016-02-17 19:50:04 +00:00
|
|
|
/* highlight the given snippet if required
|
|
|
|
*
|
|
|
|
* snippet: content of the span; must have been sanitised
|
|
|
|
* highlight: true to highlight as a search match
|
|
|
|
*
|
|
|
|
* returns an HTML string
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
protected processSnippet(snippet: string, highlight: boolean): string {
|
2016-02-17 19:50:04 +00:00
|
|
|
if (!highlight) {
|
|
|
|
// nothing required here
|
|
|
|
return snippet;
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
|
2016-02-17 19:50:04 +00:00
|
|
|
|
|
|
|
if (this.highlightLink) {
|
2020-07-08 07:40:58 +00:00
|
|
|
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
|
2016-02-17 19:50:04 +00:00
|
|
|
}
|
|
|
|
return span;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
interface IContent {
|
|
|
|
format?: string;
|
2020-08-29 00:11:08 +00:00
|
|
|
// eslint-disable-next-line camelcase
|
2020-07-08 07:40:58 +00:00
|
|
|
formatted_body?: string;
|
|
|
|
body: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IOpts {
|
|
|
|
highlightLink?: string;
|
|
|
|
disableBigEmoji?: boolean;
|
|
|
|
stripReplyFallback?: boolean;
|
|
|
|
returnString?: boolean;
|
|
|
|
forComposerQuote?: boolean;
|
|
|
|
ref?: React.Ref<any>;
|
|
|
|
}
|
2015-11-27 15:02:32 +00:00
|
|
|
|
2018-05-21 02:48:59 +00:00
|
|
|
/* 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
|
2018-07-18 09:10:42 +00:00
|
|
|
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
2019-12-08 01:01:19 +00:00
|
|
|
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
2018-05-21 02:48:59 +00:00
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
|
2018-05-17 18:12:51 +00:00
|
|
|
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
2017-09-15 11:03:32 +00:00
|
|
|
let bodyHasEmoji = false;
|
|
|
|
|
2018-07-18 09:10:42 +00:00
|
|
|
let sanitizeParams = sanitizeHtmlParams;
|
|
|
|
if (opts.forComposerQuote) {
|
|
|
|
sanitizeParams = composerSanitizeHtmlParams;
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:40:58 +00:00
|
|
|
let strippedBody: string;
|
|
|
|
let safeBody: string;
|
|
|
|
let isDisplayedWithHtml: boolean;
|
2016-09-16 15:02:08 +00:00
|
|
|
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
|
|
|
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
|
|
|
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
|
|
|
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
|
|
|
|
try {
|
|
|
|
if (highlights && highlights.length > 0) {
|
2017-10-11 16:56:17 +00:00
|
|
|
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
|
|
|
|
const safeHighlights = highlights.map(function(highlight) {
|
2018-07-18 09:10:42 +00:00
|
|
|
return sanitizeHtml(highlight, sanitizeParams);
|
2016-09-16 15:02:08 +00:00
|
|
|
});
|
2018-07-18 09:10:42 +00:00
|
|
|
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
|
|
|
|
sanitizeParams.textFilter = function(safeText) {
|
2016-09-16 15:02:08 +00:00
|
|
|
return highlighter.applyHighlights(safeText, safeHighlights).join('');
|
|
|
|
};
|
2015-11-27 15:02:32 +00:00
|
|
|
}
|
2018-03-13 17:15:16 +00:00
|
|
|
|
2019-10-10 16:36:22 +00:00
|
|
|
let formattedBody = typeof content.formatted_body === 'string' ? content.formatted_body : null;
|
2020-08-12 09:40:25 +00:00
|
|
|
const plainBody = typeof content.body === 'string' ? content.body : "";
|
2019-10-10 16:36:22 +00:00
|
|
|
|
2018-03-29 16:34:08 +00:00
|
|
|
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
|
2019-10-10 16:36:22 +00:00
|
|
|
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(plainBody) : plainBody;
|
2018-03-24 17:52:49 +00:00
|
|
|
|
2019-10-10 16:36:22 +00:00
|
|
|
bodyHasEmoji = mightContainEmoji(isHtmlMessage ? formattedBody : plainBody);
|
2018-03-13 17:15:16 +00:00
|
|
|
|
|
|
|
// Only generate safeBody if the message was sent as org.matrix.custom.html
|
2018-05-17 18:12:51 +00:00
|
|
|
if (isHtmlMessage) {
|
|
|
|
isDisplayedWithHtml = true;
|
2018-07-18 09:10:42 +00:00
|
|
|
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
|
2018-03-13 17:15:16 +00:00
|
|
|
}
|
2017-10-11 16:56:17 +00:00
|
|
|
} finally {
|
2018-07-18 09:10:42 +00:00
|
|
|
delete sanitizeParams.textFilter;
|
2016-09-16 15:02:08 +00:00
|
|
|
}
|
2016-07-04 22:34:57 +00:00
|
|
|
|
2018-05-21 02:48:59 +00:00
|
|
|
if (opts.returnString) {
|
|
|
|
return isDisplayedWithHtml ? safeBody : strippedBody;
|
|
|
|
}
|
|
|
|
|
2017-09-15 11:03:32 +00:00
|
|
|
let emojiBody = false;
|
2017-10-14 18:40:45 +00:00
|
|
|
if (!opts.disableBigEmoji && bodyHasEmoji) {
|
2019-03-05 12:33:37 +00:00
|
|
|
let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
|
|
|
|
|
|
|
|
// Ignore spaces in body text. Emojis with spaces in between should
|
|
|
|
// still be counted as purely emoji messages.
|
2019-03-06 14:53:24 +00:00
|
|
|
contentBodyTrimmed = contentBodyTrimmed.replace(WHITESPACE_REGEX, '');
|
2019-03-05 12:33:37 +00:00
|
|
|
|
|
|
|
// Remove zero width joiner characters from emoji messages. This ensures
|
|
|
|
// that emojis that are made up of multiple unicode characters are still
|
|
|
|
// presented as large.
|
|
|
|
contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, '');
|
|
|
|
|
2019-05-19 16:06:21 +00:00
|
|
|
const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed);
|
2019-05-19 15:48:15 +00:00
|
|
|
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length &&
|
2019-04-10 17:00:04 +00:00
|
|
|
// Prevent user pills expanding for users with only emoji in
|
2019-10-01 02:37:50 +00:00
|
|
|
// their username. Permalinks (links in pills) can be any URL
|
|
|
|
// now, so we just check for an HTTP-looking thing.
|
2019-05-19 15:48:15 +00:00
|
|
|
(
|
2020-04-22 22:27:33 +00:00
|
|
|
strippedBody === safeBody || // replies have the html fallbacks, account for that here
|
|
|
|
content.formatted_body === undefined ||
|
2019-10-01 03:08:34 +00:00
|
|
|
(!content.formatted_body.includes("http:") &&
|
|
|
|
!content.formatted_body.includes("https:"))
|
2019-05-19 15:48:15 +00:00
|
|
|
);
|
2017-09-15 11:03:32 +00:00
|
|
|
}
|
2015-11-27 15:02:32 +00:00
|
|
|
|
2016-09-16 15:02:08 +00:00
|
|
|
const className = classNames({
|
|
|
|
'mx_EventTile_body': true,
|
|
|
|
'mx_EventTile_bigEmoji': emojiBody,
|
2019-05-23 09:22:30 +00:00
|
|
|
'markdown-body': isHtmlMessage && !emojiBody,
|
2016-09-16 15:02:08 +00:00
|
|
|
});
|
2018-03-13 17:15:16 +00:00
|
|
|
|
2018-05-17 18:12:51 +00:00
|
|
|
return isDisplayedWithHtml ?
|
2020-08-29 00:11:08 +00:00
|
|
|
<span
|
|
|
|
key="body"
|
|
|
|
ref={opts.ref}
|
|
|
|
className={className}
|
|
|
|
dangerouslySetInnerHTML={{ __html: safeBody }}
|
|
|
|
dir="auto"
|
|
|
|
/> : <span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
|
2016-09-16 15:02:08 +00:00
|
|
|
}
|
2015-11-27 15:02:32 +00:00
|
|
|
|
2019-01-31 21:26:07 +00:00
|
|
|
/**
|
|
|
|
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
|
|
|
|
*
|
2020-03-04 21:14:03 +00:00
|
|
|
* @param {string} str string to linkify
|
|
|
|
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
|
|
|
* @returns {string} Linkified string
|
2019-01-31 21:26:07 +00:00
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
2020-03-04 21:14:03 +00:00
|
|
|
return _linkifyString(str, options);
|
2019-01-31 21:26:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
|
|
|
|
*
|
|
|
|
* @param {object} element DOM element to linkify
|
|
|
|
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
|
|
|
* @returns {object}
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
|
2019-01-31 21:26:07 +00:00
|
|
|
return _linkifyElement(element, options);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Linkify the given string and sanitize the HTML afterwards.
|
|
|
|
*
|
|
|
|
* @param {string} dirtyHtml The HTML string to sanitize and linkify
|
2020-03-04 21:14:03 +00:00
|
|
|
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
2019-01-31 21:26:07 +00:00
|
|
|
* @returns {string}
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
2020-03-04 21:14:03 +00:00
|
|
|
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
2019-01-31 21:26:07 +00:00
|
|
|
}
|
2019-07-23 07:12:24 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns if a node is a block element or not.
|
|
|
|
* Only takes html nodes into account that are allowed in matrix messages.
|
|
|
|
*
|
|
|
|
* @param {Node} node
|
|
|
|
* @returns {bool}
|
|
|
|
*/
|
2020-07-08 07:40:58 +00:00
|
|
|
export function checkBlockNode(node: Node) {
|
2019-07-23 07:12:24 +00:00
|
|
|
switch (node.nodeName) {
|
|
|
|
case "H1":
|
|
|
|
case "H2":
|
|
|
|
case "H3":
|
|
|
|
case "H4":
|
|
|
|
case "H5":
|
|
|
|
case "H6":
|
|
|
|
case "PRE":
|
|
|
|
case "BLOCKQUOTE":
|
|
|
|
case "DIV":
|
|
|
|
case "P":
|
|
|
|
case "UL":
|
|
|
|
case "OL":
|
|
|
|
case "LI":
|
|
|
|
case "HR":
|
|
|
|
case "TABLE":
|
|
|
|
case "THEAD":
|
|
|
|
case "TBODY":
|
|
|
|
case "TR":
|
|
|
|
case "TH":
|
|
|
|
case "TD":
|
|
|
|
return true;
|
|
|
|
default:
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|