Merge pull request #408 from vector-im/formatted-messages

Formatted messages
This commit is contained in:
Bruno Windels 2021-07-16 21:47:03 +00:00 committed by GitHub
commit f2e5e34a7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1024 additions and 46 deletions

View file

@ -29,6 +29,7 @@
"@babel/preset-env": "^7.11.0",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-multi-entry": "^4.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"autoprefixer": "^10.2.6",
@ -40,6 +41,7 @@
"finalhandler": "^1.1.1",
"impunity": "^1.0.0",
"mdn-polyfills": "^5.20.0",
"node-html-parser": "^4.0.0",
"postcss": "^8.1.1",
"postcss-css-variables": "^0.17.0",
"postcss-flexbugs-fixes": "^4.2.1",
@ -52,12 +54,13 @@
"xxhashjs": "^0.2.2"
},
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"aes-js": "^3.1.2",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"bs58": "^4.0.1",
"dompurify": "^2.3.0",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"text-encoding": "^0.7.0"
}
}

View file

@ -22,6 +22,7 @@ const { fileURLToPath } = require('url');
const { dirname } = require('path');
// needed to translate commonjs modules to esm
const commonjs = require('@rollup/plugin-commonjs');
const json = require('@rollup/plugin-json');
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const projectDir = path.join(__dirname, "../");
@ -53,7 +54,7 @@ async function commonjsToESM(src, dst) {
const bundle = await rollup({
treeshake: {moduleSideEffects: false},
input: src,
plugins: [commonjs(), nodeResolve({
plugins: [commonjs(), json(), nodeResolve({
browser: true,
preferBuiltins: false,
customResolveOptions: {packageIterator}
@ -76,6 +77,18 @@ async function populateLib() {
for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) {
await fs.symlink(path.join(olmSrcDir, file), path.join(olmDstDir, file));
}
// transpile node-html-parser to esm
await fs.mkdir(path.join(libDir, "node-html-parser/"));
await commonjsToESM(
require.resolve('node-html-parser/dist/index.js'),
path.join(libDir, "node-html-parser/index.js")
);
// Symlink dompurify
await fs.mkdir(path.join(libDir, "dompurify/"));
await fs.symlink(
require.resolve('dompurify/dist/purify.es.js'),
path.join(libDir, "dompurify/index.js")
);
// transpile another-json to esm
await fs.mkdir(path.join(libDir, "another-json/"));
await commonjsToESM(

View file

@ -0,0 +1,25 @@
# Ideas for formatted messages
* Seems like a good idea to take some
inspiration from [Pandoc's AST](https://hackage.haskell.org/package/pandoc-types-1.22/docs/Text-Pandoc-Definition.html#t:Block).
Then, much like Pandoc AST, we can turn our representation into
markdown in the editor, or HTML in the view.
* This is good for both serialization and deserialization.
We can use the representation when going input -> formatted
message, which would allow other, non-web platforms
to rely on non-Markdown input types. We can also
use it going formatted message -> display, since a frontend
can then choose to use non-HTML output (GTK JS bindings?).
* As such, we represent formatting by nesting parts
(we'd have a `ItalicsPart`, `BoldPart`, etc.)
* We keep the "inline"/"block" distinction, but only
track it as a property, so that we can avoid adding
block parts to elements that cannot contain blocks
(headers, for instance, cannot contain blocks, but
lists -- themselves blocks -- can).
* When parsing, we may need some sort of "permanent" context:
if we're parsing a header, and we are inside 3 layers of other
"inline" things (italics, strikethrough, and bold, for example),
and we encounter a block, that's still not valid.
* Element seems to not care at all about the validity of what
it's parsing. Do we assume the HTML is well-formatted, then?

View file

@ -1,4 +1,5 @@
import { linkify } from "./linkify/linkify.js";
import { getIdentifierColorNumber, avatarInitials } from "../../../avatar.js";
/**
* Parse text into parts such as newline, links and text.
@ -12,7 +13,7 @@ export function parsePlainBody(body) {
// create callback outside of loop
const linkifyCallback = (text, isLink) => {
if (isLink) {
parts.push(new LinkPart(text, text));
parts.push(new LinkPart(text, [new TextPart(text)]));
} else {
parts.push(new TextPart(text));
}
@ -36,20 +37,99 @@ export function stringAsBody(body) {
return new MessageBody(body, [new TextPart(body)]);
}
class NewLinePart {
export class HeaderBlock {
constructor(level, inlines) {
this.level = level;
this.inlines = inlines;
}
get type() { return "header"; }
}
export class CodeBlock {
constructor(language, text) {
this.language = language;
this.text = text;
}
get type() { return "codeblock"; }
}
export class ListBlock {
constructor(startOffset, items) {
this.items = items;
this.startOffset = startOffset;
}
get type() { return "list"; }
}
export class TableBlock {
constructor(head, body) {
this.head = head;
this.body = body;
}
get type() { return "table"; }
}
export class RulePart {
get type( ) { return "rule"; }
}
export class NewLinePart {
get type() { return "newline"; }
}
class LinkPart {
constructor(url, text) {
export class FormatPart {
constructor(format, children) {
this.format = format.toLowerCase();
this.children = children;
}
get type() { return "format"; }
}
export class ImagePart {
constructor(src, width, height, alt, title) {
this.src = src;
this.width = width;
this.height = height;
this.alt = alt;
this.title = title;
}
get type() { return "image"; }
}
export class PillPart {
constructor(id, href, children) {
this.id = id;
this.href = href;
this.children = children;
}
get type() { return "pill"; }
get avatarColorNumber() {
return getIdentifierColorNumber(this.id);
}
get avatarInitials() {
return avatarInitials(this.id);
}
}
export class LinkPart {
constructor(url, inlines) {
this.url = url;
this.text = text;
this.inlines = inlines;
}
get type() { return "link"; }
}
class TextPart {
export class TextPart {
constructor(text) {
this.text = text;
}
@ -57,7 +137,7 @@ class TextPart {
get type() { return "text"; }
}
class MessageBody {
export class MessageBody {
constructor(sourceString, parts) {
this.sourceString = sourceString;
this.parts = parts;

View file

@ -0,0 +1,501 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
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.
*/
import { MessageBody, HeaderBlock, TableBlock, ListBlock, CodeBlock, PillPart, FormatPart, NewLinePart, RulePart, TextPart, LinkPart, ImagePart } from "./MessageBody.js"
import { linkify } from "./linkify/linkify.js";
/* At the time of writing (Jul 1 2021), Matrix Spec recommends
* allowing the following HTML tags:
* font, del, h1, h2, h3, h4, h5, h6, blockquote, p, a, ul, ol, sup, sub, li, b, i, u,
* strong, em, strike, code, hr, br, div, table, thead, tbody, tr, th, td, caption, pre, span, img
*/
/**
* Nodes that don't have any properties to them other than their tag.
* While <a> has `href`, and <img> has `src`, these have... themselves.
*/
const basicInline = ["EM", "STRONG", "CODE", "DEL", "SPAN" ];
const basicBlock = ["DIV", "BLOCKQUOTE"];
const safeSchemas = ["https", "http", "ftp", "mailto", "magnet"].map(name => `${name}://`);
const baseUrl = 'https://matrix.to';
const linkPrefix = `${baseUrl}/#/`;
class Deserializer {
constructor(result, mediaRepository) {
this.result = result;
this.mediaRepository = mediaRepository;
}
parsePillLink(link) {
if (!link.startsWith(linkPrefix)) {
return null;
}
const contents = link.substring(linkPrefix.length);
if (contents[0] === '@') {
return contents;
}
return null;
}
parseLink(node, children) {
const href = this.result.getAttributeValue(node, "href");
const lcUrl = href?.toLowerCase();
// urls should be absolute and with a safe schema, as listed in the spec
if (!lcUrl || !safeSchemas.some(schema => lcUrl.startsWith(schema))) {
return new FormatPart("span", children);
}
const pillId = this.parsePillLink(href);
if (pillId) {
return new PillPart(pillId, href, children);
}
return new LinkPart(href, children);
}
parseList(node) {
const result = this.result;
let start = null;
if (result.getNodeElementName(node) === "OL") {
// Will return 1 for, say, '1A', which may not be intended?
start = parseInt(result.getAttributeValue(node, "start")) || 1;
}
const items = [];
for (const child of result.getChildNodes(node)) {
if (result.getNodeElementName(child) !== "LI") {
continue;
}
const item = this.parseAnyNodes(result.getChildNodes(child));
items.push(item);
}
return new ListBlock(start, items);
}
_ensureElement(node, tag) {
return node &&
this.result.isElementNode(node) &&
this.result.getNodeElementName(node) === tag;
}
parseCodeBlock(node) {
const result = this.result;
let codeNode;
for (const child of result.getChildNodes(node)) {
codeNode = child;
break;
}
let language = null;
if (!this._ensureElement(codeNode, "CODE")) {
return new CodeBlock(language, this.result.getNodeText(node));
}
const cl = result.getAttributeValue(codeNode, "class") || ""
for (const clname of cl.split(" ")) {
if (clname.startsWith("language-") && !clname.startsWith("language-_")) {
language = clname.substring(9) // "language-".length
break;
}
}
return new CodeBlock(language, this.result.getNodeText(codeNode));
}
parseImage(node) {
const result = this.result;
const src = result.getAttributeValue(node, "src") || "";
const url = this.mediaRepository.mxcUrl(src);
// We just ignore non-mxc `src` attributes.
if (!url) {
return null;
}
const width = parseInt(result.getAttributeValue(node, "width")) || null;
const height = parseInt(result.getAttributeValue(node, "height")) || null;
const alt = result.getAttributeValue(node, "alt");
const title = result.getAttributeValue(node, "title");
return new ImagePart(url, width, height, alt, title);
}
parseTableRow(row, tag) {
const cells = [];
for (const node of this.result.getChildNodes(row)) {
if(!this._ensureElement(node, tag)) {
continue;
}
const children = this.result.getChildNodes(node);
const inlines = this.parseInlineNodes(children);
cells.push(inlines);
}
return cells;
}
parseTableHead(head) {
let headRow = null;
for (const node of this.result.getChildNodes(head)) {
headRow = node;
break;
}
if (this._ensureElement(headRow, "TR")) {
return this.parseTableRow(headRow, "TH");
}
return null;
}
parseTableBody(body) {
const rows = [];
for (const node of this.result.getChildNodes(body)) {
if(!this._ensureElement(node, "TR")) {
continue;
}
rows.push(this.parseTableRow(node, "TD"));
}
return rows;
}
parseTable(node) {
// We are only assuming iterable, so convert to arrary for indexing.
const children = Array.from(this.result.getChildNodes(node));
let head, body;
if (this._ensureElement(children[0], "THEAD") && this._ensureElement(children[1], "TBODY")) {
head = this.parseTableHead(children[0]);
body = this.parseTableBody(children[1]);
} else if (this._ensureElement(children[0], "TBODY")) {
head = null;
body = this.parseTableBody(children[0]);
}
return new TableBlock(head, body);
}
/** Once a node is known to be an element,
* attempt to interpret it as an inline element.
*
* @returns the inline message part, or null if the element
* is not inline or not allowed.
*/
parseInlineElement(node) {
const result = this.result;
const tag = result.getNodeElementName(node);
const children = result.getChildNodes(node);
switch (tag) {
case "A": {
const inlines = this.parseInlineNodes(children);
return this.parseLink(node, inlines);
}
case "BR":
return new NewLinePart();
default: {
if (!basicInline.includes(tag)) {
return null;
}
const inlines = this.parseInlineNodes(children);
return new FormatPart(tag, inlines);
}
}
}
/** Attempt to interpret a node as inline.
*
* @returns the inline message part, or null if the
* element is not inline or not allowed.
*/
parseInlineNode(node) {
if (this.result.isElementNode(node)) {
return this.parseInlineElement(node);
}
return null;
}
/** Once a node is known to be an element, attempt
* to interpret it as a block element.
*
* @returns the block message part, or null of the
* element is not a block or not allowed.
*/
parseBlockElement(node) {
const result = this.result;
const tag = result.getNodeElementName(node);
const children = result.getChildNodes(node);
switch (tag) {
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6": {
const inlines = this.parseInlineNodes(children);
return new HeaderBlock(parseInt(tag[1]), inlines)
}
case "UL":
case "OL":
return this.parseList(node);
case "PRE":
return this.parseCodeBlock(node);
case "HR":
return new RulePart();
case "IMG":
return this.parseImage(node);
case "P": {
const inlines = this.parseInlineNodes(children);
return new FormatPart(tag, inlines);
}
case "TABLE":
return this.parseTable(node);
default: {
if (!basicBlock.includes(tag)) {
return null;
}
const blocks = this.parseAnyNodes(children);
return new FormatPart(tag, blocks);
}
}
}
/** Attempt to parse a node as a block.
*
* @return the block message part, or null if the node
* is not a block element.
*/
parseBlockNode(node) {
if (this.result.isElementNode(node)) {
return this.parseBlockElement(node);
}
return null;
}
_parseTextParts(node, into) {
if(!this.result.isTextNode(node)) {
return false;
}
// XXX pretty much identical to `MessageBody`'s.
const linkifyCallback = (text, isLink) => {
if (isLink) {
into.push(new LinkPart(text, [new TextPart(text)]));
} else {
into.push(new TextPart(text));
}
};
linkify(this.result.getNodeText(node), linkifyCallback);
return true;
}
_parseInlineNodes(nodes, into) {
for (const htmlNode of nodes) {
if (this._parseTextParts(htmlNode, into)) {
// This was a text node, and we already
// dumped its parts into our list.
continue;
}
const node = this.parseInlineNode(htmlNode);
if (node) {
into.push(node);
continue;
}
// Node is either block or unrecognized. In
// both cases, just move on to its children.
this._parseInlineNodes(this.result.getChildNodes(htmlNode), into);
}
}
parseInlineNodes(nodes) {
const into = [];
this._parseInlineNodes(nodes, into);
return into;
}
// XXX very similar to `_parseInlineNodes`.
_parseAnyNodes(nodes, into) {
for (const htmlNode of nodes) {
if (this._parseTextParts(htmlNode, into)) {
// This was a text node, and we already
// dumped its parts into our list.
continue;
}
const node = this.parseInlineNode(htmlNode) || this.parseBlockNode(htmlNode);
if (node) {
into.push(node);
continue;
}
// Node is unrecognized. Just move on to its children.
this._parseAnyNodes(this.result.getChildNodes(htmlNode), into);
}
}
parseAnyNodes(nodes) {
const into = [];
this._parseAnyNodes(nodes, into);
return into;
}
}
export function parseHTMLBody(platform, mediaRepository, html) {
const parseResult = platform.parseHTML(html);
const deserializer = new Deserializer(parseResult, mediaRepository);
const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
return new MessageBody(html, parts);
}
import parse from '../../../../../lib/node-html-parser/index.js';
export function tests() {
class HTMLParseResult {
constructor(bodyNode) {
this._bodyNode = bodyNode;
}
get rootNodes() {
return this._bodyNode.childNodes;
}
getChildNodes(node) {
return node.childNodes;
}
getAttributeNames(node) {
return node.getAttributeNames();
}
getAttributeValue(node, attr) {
return node.getAttribute(attr);
}
isTextNode(node) {
return !node.tagName;
}
getNodeText(node) {
return node.text;
}
isElementNode(node) {
return !!node.tagName;
}
getNodeElementName(node) {
return node.tagName;
}
}
const platform = {
parseHTML: (html) => new HTMLParseResult(parse(html))
};
function test(assert, input, output) {
assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
}
return {
"Text only": assert => {
const input = "This is a sentence";
const output = [new TextPart(input)];
test(assert, input, output);
},
"Text with inline code format": assert => {
const input = "Here's <em>some</em> <code>code</code>!";
const output = [
new TextPart("Here's "),
new FormatPart("em", [new TextPart("some")]),
new TextPart(" "),
new FormatPart("code", [new TextPart("code")]),
new TextPart("!")
];
test(assert, input, output);
},
"Text with ordered list with no attributes": assert => {
const input = "<ol><li>Lorem</li><li>Ipsum</li></ol>";
const output = [
new ListBlock(1, [
[ new TextPart("Lorem") ],
[ new TextPart("Ipsum") ]
])
];
test(assert, input, output);
},
"Text with ordered list starting at 3": assert => {
const input = '<ol start="3"><li>Lorem</li><li>Ipsum</li></ol>';
const output = [
new ListBlock(3, [
[ new TextPart("Lorem") ],
[ new TextPart("Ipsum") ]
])
];
test(assert, input, output);
},
"Text with unordered list": assert => {
const input = '<ul start="3"><li>Lorem</li><li>Ipsum</li></ul>';
const output = [
new ListBlock(null, [
[ new TextPart("Lorem") ],
[ new TextPart("Ipsum") ]
])
];
test(assert, input, output);
},
"Auto-closed tags": assert => {
const input = '<p>hello<p>world</p></p>';
const output = [
new FormatPart("p", [new TextPart("hello")]),
new FormatPart("p", [new TextPart("world")])
];
test(assert, input, output);
},
"Block elements ignored inside inline elements": assert => {
const input = '<span><p><code>Hello</code></p></span>';
const output = [
new FormatPart("span", [new FormatPart("code", [new TextPart("Hello")])])
];
test(assert, input, output);
},
"Unknown tags are ignored, but their children are kept": assert => {
const input = '<span><dfn><code>Hello</code></dfn><footer><em>World</em></footer></span>';
const output = [
new FormatPart("span", [
new FormatPart("code", [new TextPart("Hello")]),
new FormatPart("em", [new TextPart("World")])
])
];
test(assert, input, output);
},
"Unknown and invalid attributes are stripped": assert => {
const input = '<em onmouseover=alert("Bad code!")>Hello</em>';
const output = [
new FormatPart("em", [new TextPart("Hello")])
];
test(assert, input, output);
},
"Text with code block but no <code> tag": assert => {
const code = 'main :: IO ()\nmain = putStrLn "Hello"'
const input = `<pre>${code}</pre>`;
const output = [
new CodeBlock(null, code)
];
test(assert, input, output);
},
"Text with code block and 'unsupported' tag": assert => {
const code = '<em>Hello, world</em>'
const input = `<pre>${code}</pre>`;
const output = [
new CodeBlock(null, code)
];
test(assert, input, output);
}
/* Doesnt work: HTML library doesn't handle <pre><code> properly.
"Text with code block": assert => {
const code = 'main :: IO ()\nmain = putStrLn "Hello"'
const input = `<pre><code>${code}</code></pre>`;
const output = [
new CodeBlock(null, code)
];
test(assert, input, output);
}
*/
};
}

View file

@ -16,31 +16,43 @@ limitations under the License.
import {BaseMessageTile} from "./BaseMessageTile.js";
import {stringAsBody} from "../MessageBody.js";
import {createEnum} from "../../../../../utils/enum.js";
export const BodyFormat = createEnum("Plain", "Html");
export class BaseTextTile extends BaseMessageTile {
constructor(options) {
super(options);
this._messageBody = null;
this._format = null
}
get shape() {
return "message";
}
_parseBody(bodyString) {
return stringAsBody(bodyString);
_parseBody(body) {
return stringAsBody(body);
}
_getBodyFormat() {
return BodyFormat.Plain;
}
get body() {
const body = this._getBodyAsString();
const body = this._getBody();
const format = this._getBodyFormat();
// body is a string, so we can check for difference by just
// doing an equality check
if (!this._messageBody || this._messageBody.sourceString !== body) {
// Even if the body hasn't changed, but the format has, we need
// to re-fill our cache.
if (!this._messageBody || this._messageBody.sourceString !== body || this._format !== format) {
// body with markup is an array of parts,
// so we should not recreate it for the same body string,
// or else the equality check in the binding will always fail.
// So cache it here.
this._messageBody = this._parseBody(body);
this._messageBody = this._parseBody(body, format);
this._format = format;
}
return this._messageBody;
}

View file

@ -33,13 +33,15 @@ export class EncryptedEventTile extends BaseTextTile {
return "message-status"
}
_getBodyAsString() {
_getBody() {
const decryptionError = this._entry.decryptionError;
const code = decryptionError?.code;
let string;
if (code === "MEGOLM_NO_SESSION") {
return this.i18n`The sender hasn't sent us the key for this message yet.`;
string = this.i18n`The sender hasn't sent us the key for this message yet.`;
} else {
return decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
string = decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
}
return string;
}
}

View file

@ -14,20 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseTextTile} from "./BaseTextTile.js";
import {BaseTextTile, BodyFormat} from "./BaseTextTile.js";
import {parsePlainBody} from "../MessageBody.js";
import {parseHTMLBody} from "../deserialize.js";
export class TextTile extends BaseTextTile {
_getBodyAsString() {
_getContentString(key) {
const content = this._getContent();
let body = content?.body || "";
let val = content?.[key] || "";
if (content.msgtype === "m.emote") {
body = `* ${this.displayName} ${body}`;
val = `* ${this.displayName} ${val}`;
}
return body;
return val;
}
_parseBody(bodyString) {
return parsePlainBody(bodyString);
_getPlainBody() {
return this._getContentString("body");
}
_getFormattedBody() {
return this._getContentString("formatted_body");
}
_getBody() {
if (this._getBodyFormat() === BodyFormat.Html) {
return this._getFormattedBody();
} else {
return this._getPlainBody();
}
}
_getBodyFormat() {
if (this._getContent()?.format === "org.matrix.custom.html") {
return BodyFormat.Html;
} else {
return BodyFormat.Plain;
}
}
_parseBody(body, format) {
if (format === BodyFormat.Html) {
return parseHTMLBody(this.platform, this._mediaRepository, body);
} else {
return parsePlainBody(body);
}
}
}

View file

@ -36,6 +36,7 @@ import {BlobHandle} from "./dom/BlobHandle.js";
import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandle.js";
import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables.js";
import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar.js";
function addScript(src) {
@ -239,6 +240,10 @@ export class Platform {
return promise;
}
parseHTML(html) {
return parseHTML(html);
}
async loadImage(blob) {
return ImageHandle.fromBlob(blob);
}

View file

@ -0,0 +1,67 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
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.
*/
import DOMPurify from "../../../../../lib/dompurify/index.js"
class HTMLParseResult {
constructor(bodyNode) {
this._bodyNode = bodyNode;
}
get rootNodes() {
return Array.from(this._bodyNode.childNodes);
}
getChildNodes(node) {
return Array.from(node.childNodes);
}
getAttributeNames(node) {
return Array.from(node.getAttributeNames());
}
getAttributeValue(node, attr) {
return node.getAttribute(attr);
}
isTextNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
getNodeText(node) {
return node.textContent;
}
isElementNode(node) {
return node.nodeType === Node.ELEMENT_NODE;
}
getNodeElementName(node) {
return node.tagName;
}
}
const sanitizeConfig = {
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx|mxc):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
}
export function parseHTML(html) {
// If DOMPurify uses DOMParser, can't we just get the built tree from it
// instead of re-parsing?
const sanitized = DOMPurify.sanitize(html, sanitizeConfig);
const bodyNode = new DOMParser().parseFromString(sanitized, "text/html").body;
return new HTMLParseResult(bodyNode);
}

View file

@ -76,3 +76,11 @@ limitations under the License.
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
}
.hydrogen .avatar.size-12 {
--avatar-size: 12px;
width: var(--avatar-size);
height: var(--avatar-size);
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
}

View file

@ -126,6 +126,8 @@ limitations under the License.
line-height: 2.2rem;
/* so the .media can grow horizontally and its spacer can grow vertically */
width: 100%;
/* Fix for pre overflow */
min-width: 0;
}
.hydrogen .Timeline_messageSender.usercolor1 { color: var(--usercolor1); }
@ -137,10 +139,32 @@ limitations under the License.
.hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); }
.hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); }
.Timeline_messageBody h1,
.Timeline_messageBody h2,
.Timeline_messageBody h3,
.Timeline_messageBody h4,
.Timeline_messageBody h5,
.Timeline_messageBody h6 {
font-weight: bold;
margin: 0.7em 0;
}
.Timeline_messageBody h1 { font-size: 1.6em; }
.Timeline_messageBody h2 { font-size: 1.5em; }
.Timeline_messageBody h3 { font-size: 1.4em; }
.Timeline_messageBody h4 { font-size: 1.3em; }
.Timeline_messageBody h5 { font-size: 1.2em; }
.Timeline_messageBody h6 { font-size: 1.1em; }
.Timeline_messageBody a {
word-break: break-all;
}
.Timeline_messageBody a.link {
color: #238cf5;
text-decoration: none;
}
.Timeline_messageBody .media {
display: grid;
margin-top: 4px;
@ -209,6 +233,73 @@ only loads when the top comes into view*/
/* don't stretch height as it is a spacer, just in case it doesn't match with image height */
align-self: start;
}
.Timeline_messageBody code, .Timeline_messageBody pre {
background-color: #f8f8f8;
font-family: monospace;
font-size: 0.9em;
}
.Timeline_messageBody code {
border-radius: 3px;
padding: .2em .3em;
margin: 0;
}
.Timeline_messageBody pre {
border: 1px solid rgb(229, 229, 229);
padding: 0.5em;
max-height: 30em;
overflow: auto;
}
.Timeline_messageBody pre > code {
background-color: unset;
border-radius: unset;
display: block;
font-size: unset;
}
.Timeline_messageBody blockquote {
margin-left: 0;
padding-left: 20px;
border-left: 4px solid rgb(229, 229, 229);
}
.Timeline_messageBody table {
border: 1px solid rgb(206, 206, 206);
border-radius: 2px;
border-spacing: 0;
}
.Timeline_messageBody thead th {
border-bottom: 1px solid rgb(206, 206, 206);
}
.Timeline_messageBody td, .Timeline_messageBody th {
padding: 2px 5px 2px 5px;
}
.Timeline_messageBody tbody tr:nth-child(2n) {
background-color: #f6f6f6;
}
.Timeline_messageBody .pill {
padding: 0px 5px;
border-radius: 15px;
background-color: #f6f6f6;
border: 1px solid rgb(206, 206, 206);
text-decoration: none;
display: inline-flex;
align-items: center;
line-height: 2rem;
vertical-align: top;
margin: 1px;
}
.Timeline_messageBody .pill div.avatar {
display: inline-block;
margin-right: 3px;
}
.Timeline_message.unsent .Timeline_messageBody {
color: #ccc;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
.RoomView_body ul {
.RoomView_body > ul {
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
@ -61,3 +61,8 @@ limitations under the License.
.GapView > :nth-child(2) {
flex: 1;
}
.Timeline_messageBody img {
max-width: 400px;
max-height: 300px;
}

View file

@ -93,8 +93,9 @@ export const SVG_NS = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
"p", "strong", "em", "span", "img", "section", "main", "article", "aside", "del", "blockquote",
"table", "thead", "tbody", "tr", "th", "td",
"pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
[SVG_NS]: ["svg", "circle"]
};

View file

@ -14,41 +14,99 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {StaticView} from "../../../general/StaticView.js";
import {tag, text} from "../../../general/html.js";
import {BaseMessageView} from "./BaseMessageView.js";
export class TextMessageView extends BaseMessageView {
renderMessageBody(t, vm) {
return t.p({
const time = t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time);
const container = t.div({
className: {
"Timeline_messageBody": true,
statusMessage: vm => vm.shape === "message-status",
},
}, [
t.mapView(vm => vm.body, body => new BodyView(body)),
t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)
]);
});
t.mapSideEffect(vm => vm.body, body => {
while (container.lastChild) {
container.removeChild(container.lastChild);
}
for (const part of body.parts) {
container.appendChild(renderPart(part));
}
container.appendChild(time);
});
return container;
}
}
function renderList(listBlock) {
const items = listBlock.items.map(item => tag.li(renderParts(item)));
const start = listBlock.startOffset;
if (start) {
return tag.ol({ start }, items);
} else {
return tag.ul(items);
}
}
function renderImage(imagePart) {
const attributes = { src: imagePart.src };
if (imagePart.width) { attributes.width = imagePart.width }
if (imagePart.height) { attributes.height = imagePart.height }
if (imagePart.alt) { attributes.alt = imagePart.alt }
if (imagePart.title) { attributes.title = imagePart.title }
return tag.img(attributes);
}
function renderPill(pillPart) {
// The classes and structure are borrowed from avatar.js;
// We don't call renderStaticAvatar because that would require
// an intermediate object that has getAvatarUrl etc.
const classes = `avatar size-12 usercolor${pillPart.avatarColorNumber}`;
const avatar = tag.div({class: classes}, text(pillPart.avatarInitials));
const children = renderParts(pillPart.children);
children.unshift(avatar);
return tag.a({class: "pill", href: pillPart.href, rel: "noopener", target: "_blank"}, children);
}
function renderTable(tablePart) {
const children = [];
if (tablePart.head) {
const headers = tablePart.head
.map(cell => tag.th(renderParts(cell)));
children.push(tag.thead(tag.tr(headers)))
}
const rows = [];
for (const row of tablePart.body) {
const data = row.map(cell => tag.td(renderParts(cell)));
rows.push(tag.tr(data));
}
children.push(tag.tbody(rows));
return tag.table(children);
}
/**
* Map from part to function that outputs DOM for the part
*/
const formatFunction = {
header: headerBlock => tag["h" + Math.min(6,headerBlock.level)](renderParts(headerBlock.inlines)),
codeblock: codeBlock => tag.pre(tag.code(text(codeBlock.text))),
table: tableBlock => renderTable(tableBlock),
code: codePart => tag.code(text(codePart.text)),
text: textPart => text(textPart.text),
link: linkPart => tag.a({ href: linkPart.url, target: "_blank", rel: "noopener" }, [linkPart.text]),
link: linkPart => tag.a({href: linkPart.url, className: "link", target: "_blank", rel: "noopener" }, renderParts(linkPart.inlines)),
pill: renderPill,
format: formatPart => tag[formatPart.format](renderParts(formatPart.children)),
list: renderList,
image: renderImage,
newline: () => tag.br()
};
class BodyView extends StaticView {
render(t, messageBody) {
const container = t.span();
for (const part of messageBody.parts) {
function renderPart(part) {
const f = formatFunction[part.type];
const element = f(part);
container.appendChild(element);
}
return container;
}
return f(part);
}
function renderParts(parts) {
return Array.from(parts, renderPart);
}

View file

@ -886,6 +886,13 @@
magic-string "^0.25.7"
resolve "^1.17.0"
"@rollup/plugin-json@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3"
integrity sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==
dependencies:
"@rollup/pluginutils" "^3.0.8"
"@rollup/plugin-multi-entry@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-multi-entry/-/plugin-multi-entry-4.0.0.tgz#8e105f16ec1bb26639eb3302c8db5665f44b9939"
@ -911,7 +918,7 @@
resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-2.0.3.tgz#0afc88d75c1e1378ab290b8e9898d4edb5be0d74"
integrity sha512-pw6ziJcyjZtntQ//bkad9qXaBx665SgEL8C8KI5wO8G5iU5MPxvdWrQyVaAvjojGm9tJoS8M9Z/EEepbqieYmw==
"@rollup/pluginutils@^3.1.0":
"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
@ -1059,7 +1066,7 @@ base64-arraybuffer@^0.2.0:
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
boolbase@~1.0.0:
boolbase@^1.0.0, boolbase@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
@ -1237,6 +1244,17 @@ cross-spawn@^7.0.2:
shebang-command "^2.0.0"
which "^2.0.1"
css-select@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.3.tgz#a70440f70317f2669118ad74ff105e65849c7067"
integrity sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA==
dependencies:
boolbase "^1.0.0"
css-what "^5.0.0"
domhandler "^4.2.0"
domutils "^2.6.0"
nth-check "^2.0.0"
css-select@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
@ -1252,6 +1270,11 @@ css-what@2.1:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
css-what@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad"
integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==
cuint@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
@ -1320,6 +1343,15 @@ dom-serializer@0:
domelementtype "^2.0.1"
entities "^2.0.0"
dom-serializer@^1.0.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==
dependencies:
domelementtype "^2.0.1"
domhandler "^4.2.0"
entities "^2.0.0"
dom-serializer@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
@ -1338,6 +1370,11 @@ domelementtype@^2.0.1:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d"
integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==
domelementtype@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
domexception@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
@ -1352,6 +1389,18 @@ domhandler@^2.3.0:
dependencies:
domelementtype "1"
domhandler@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059"
integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==
dependencies:
domelementtype "^2.2.0"
dompurify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.0.tgz#07bb39515e491588e5756b1d3e8375b5964814e2"
integrity sha512-VV5C6Kr53YVHGOBKO/F86OYX6/iLTw2yVSI721gKetxpHCK/V5TaLEf9ODjRgl1KLSWRMY6cUhAbv/c+IUnwQw==
domutils@1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@ -1368,6 +1417,15 @@ domutils@^1.5.1:
dom-serializer "0"
domelementtype "1"
domutils@^2.6.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.7.0.tgz#8ebaf0c41ebafcf55b0b72ec31c56323712c5442"
integrity sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg==
dependencies:
dom-serializer "^1.0.1"
domelementtype "^2.2.0"
domhandler "^4.2.0"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@ -1726,6 +1784,11 @@ has-symbols@^1.0.0:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
he@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
htmlparser2@^3.9.1:
version "3.10.1"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
@ -2036,6 +2099,14 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
node-html-parser@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-4.0.0.tgz#95e4fec010c48425821d5659eaf39e27b0781b6d"
integrity sha512-vuOcp3u4GrfYOcqe+FpUffQKnfVBq781MbtFEcTR6fJYnYRE2qUPdDaDk7TGdq6H9r2B3TbyU6K9Rah6/C7qvg==
dependencies:
css-select "^4.1.3"
he "1.2.0"
node-releases@^1.1.60:
version "1.1.60"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084"
@ -2051,6 +2122,13 @@ normalize-range@^0.1.2:
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
nth-check@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
dependencies:
boolbase "^1.0.0"
nth-check@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"