diff --git a/package.json b/package.json index a8b19b7c..5acbbe77 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/post-install.js b/scripts/post-install.js index 150807c3..fe8743b6 100644 --- a/scripts/post-install.js +++ b/scripts/post-install.js @@ -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( diff --git a/src/domain/session/room/timeline/FORMATTED.md b/src/domain/session/room/timeline/FORMATTED.md new file mode 100644 index 00000000..576d2729 --- /dev/null +++ b/src/domain/session/room/timeline/FORMATTED.md @@ -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? diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index 42f3c951..d6a61e69 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -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; diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js new file mode 100644 index 00000000..d7f81316 --- /dev/null +++ b/src/domain/session/room/timeline/deserialize.js @@ -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 has `href`, and 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 some 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 = "
  1. Lorem
  2. Ipsum
"; + 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 = '
  1. Lorem
  2. Ipsum
'; + const output = [ + new ListBlock(3, [ + [ new TextPart("Lorem") ], + [ new TextPart("Ipsum") ] + ]) + ]; + test(assert, input, output); + }, + "Text with unordered list": assert => { + const input = ''; + const output = [ + new ListBlock(null, [ + [ new TextPart("Lorem") ], + [ new TextPart("Ipsum") ] + ]) + ]; + test(assert, input, output); + }, + "Auto-closed tags": assert => { + const input = '

hello

world

'; + 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 = '

Hello

'; + 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 = 'Hello
World
'; + 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 = 'Hello'; + const output = [ + new FormatPart("em", [new TextPart("Hello")]) + ]; + test(assert, input, output); + }, + "Text with code block but no tag": assert => { + const code = 'main :: IO ()\nmain = putStrLn "Hello"' + const input = `
${code}
`; + const output = [ + new CodeBlock(null, code) + ]; + test(assert, input, output); + }, + "Text with code block and 'unsupported' tag": assert => { + const code = 'Hello, world' + const input = `
${code}
`; + const output = [ + new CodeBlock(null, code) + ]; + test(assert, input, output); + } + /* Doesnt work: HTML library doesn't handle
 properly.
+        "Text with code block": assert => {
+            const code = 'main :: IO ()\nmain = putStrLn "Hello"'
+            const input = `
${code}
`; + const output = [ + new CodeBlock(null, code) + ]; + test(assert, input, output); + } + */ + }; +} diff --git a/src/domain/session/room/timeline/tiles/BaseTextTile.js b/src/domain/session/room/timeline/tiles/BaseTextTile.js index 401fd719..fb61cb4b 100644 --- a/src/domain/session/room/timeline/tiles/BaseTextTile.js +++ b/src/domain/session/room/timeline/tiles/BaseTextTile.js @@ -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; } diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js index 155ce4a9..07dd763b 100644 --- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js +++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js @@ -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; } } diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index 07e7ce8f..ffd06c1a 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -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); + } } } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 49a90dd5..f1410106 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -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); } diff --git a/src/platform/web/parsehtml.js b/src/platform/web/parsehtml.js new file mode 100644 index 00000000..b86ab4d9 --- /dev/null +++ b/src/platform/web/parsehtml.js @@ -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); +} diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index d369f85f..143ea899 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -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); +} diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index fef9598b..29e2fbf0 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -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; diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 60af23d9..af2d6c14 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -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; +} diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.js index 19b670d7..4be81e3b 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.js @@ -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"] }; diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 88d97b3f..5a1ac374 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -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) { - const f = formatFunction[part.type]; - const element = f(part); - container.appendChild(element); - } - return container; - } +function renderPart(part) { + const f = formatFunction[part.type]; + return f(part); +} + +function renderParts(parts) { + return Array.from(parts, renderPart); } diff --git a/yarn.lock b/yarn.lock index c1e14fe4..843a19bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"