diff --git a/src/domain/session/room/timeline/MessageBodyBuilder.js b/src/domain/session/room/timeline/MessageBodyBuilder.js new file mode 100644 index 00000000..f1b34462 --- /dev/null +++ b/src/domain/session/room/timeline/MessageBodyBuilder.js @@ -0,0 +1,96 @@ +import { linkify } from "./linkify/linkify.js"; + +export class MessageBodyBuilder { + + constructor(message = []) { + this._root = message; + } + + fromText(text) { + const components = text.split("\n"); + components.flatMap(e => ["\n", e]).slice(1).forEach(e => { + if (e === "\n") { + this.insertNewline(); + } + else { + linkify(e, this.insert.bind(this)); + } + }); + } + + insert(text, isLink) { + if (!text.length) { + return; + } + if (isLink) { + this.insertLink(text, text); + } + else { + this.insertText(text); + } + } + + insertText(text) { + if (text.length) { + this._root.push({ type: "text", text: text }); + } + } + + insertLink(link, displayText) { + this._root.push({ type: "link", url: link, text: displayText }); + } + + insertNewline() { + this._root.push({ type: "newline" }); + } + + [Symbol.iterator]() { + return this._root.values(); + } + +} + +export function tests() { + + function linkify(text) { + const obj = new MessageBodyBuilder(); + obj.fromText(text); + return obj; + } + + function test(assert, input, output) { + output = new MessageBodyBuilder(output); + input = linkify(input); + assert.deepEqual(input, output); + } + + return { + // Tests for text + "Text only": assert => { + const input = "This is a sentence"; + const output = [{ type: "text", text: input }]; + test(assert, input, output); + }, + + "Text with newline": assert => { + const input = "This is a sentence.\nThis is another sentence."; + const output = [ + { type: "text", text: "This is a sentence." }, + { type: "newline" }, + { type: "text", text: "This is another sentence." } + ]; + test(assert, input, output); + }, + + "Text with newline & trailing newline": assert => { + const input = "This is a sentence.\nThis is another sentence.\n"; + const output = [ + { type: "text", text: "This is a sentence." }, + { type: "newline" }, + { type: "text", text: "This is another sentence." }, + { type: "newline" } + ]; + test(assert, input, output); + } + }; +} diff --git a/src/domain/session/room/timeline/linkify/linkify.js b/src/domain/session/room/timeline/linkify/linkify.js new file mode 100644 index 00000000..8cea4b28 --- /dev/null +++ b/src/domain/session/room/timeline/linkify/linkify.js @@ -0,0 +1,120 @@ +import { regex } from "./regex.js"; + +export function linkify(text, callback) { + const matches = text.matchAll(regex); + let curr = 0; + for (let match of matches) { + const precedingText = text.slice(curr, match.index); + callback(precedingText, false); + callback(match[0], true); + const len = match[0].length; + curr = match.index + len; + } + const remainingText = text.slice(curr); + callback(remainingText, false); +} + +export function tests() { + + class MockCallback { + mockCallback(text, isLink) { + if (!text.length) { + return; + } + if (!this.result) { + this.result = []; + } + const type = isLink ? "link" : "text"; + this.result.push({ type: type, text: text }); + } + } + + function test(assert, input, output) { + const m = new MockCallback; + linkify(input, m.mockCallback.bind(m)); + assert.deepEqual(output, m.result); + } + + function testLink(assert, link, expectFail = false) { + const input = link; + const output = expectFail ? [{ type: "text", text: input }] : + [{ type: "link", text: input }]; + test(assert, input, output); + } + + return { + "Link with host": assert => { + testLink(assert, "https://matrix.org"); + }, + + "Link with host & path": assert => { + testLink(assert, "https://matrix.org/docs/develop"); + }, + + "Link with host & fragment": assert => { + testLink(assert, "https://matrix.org#test"); + }, + + "Link with host & query": assert => { + testLink(assert, "https://matrix.org/?foo=bar"); + }, + + "Complex link": assert => { + const link = "https://www.foobar.com/url?sa=t&rct=j&q=&esrc=s&source" + + "=web&cd=&cad=rja&uact=8&ved=2ahUKEwjyu7DJ-LHwAhUQyzgGHc" + + "OKA70QFjAAegQIBBAD&url=https%3A%2F%2Fmatrix.org%2Fdocs%" + + "2Fprojects%2Fclient%2Felement%2F&usg=AOvVaw0xpENrPHv_R-" + + "ERkyacR2Bd"; + testLink(assert, link); + }, + + "Localhost link": assert => { + testLink(assert, "http://localhost"); + testLink(assert, "http://localhost:3000"); + }, + + "IPV4 link": assert => { + testLink(assert, "https://192.0.0.1"); + testLink(assert, "https://250.123.67.23:5924"); + }, + + "IPV6 link": assert => { + testLink(assert, "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]"); + testLink(assert, "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:7000"); + }, + + "Missing scheme must not linkify": assert => { + testLink(assert, "matrix.org/foo/bar", true); + }, + + "Punctuation at end of link must not linkify": assert => { + const link = "https://foo.bar/?nenjil=lal810"; + const end = ".,? "; + for (const char of end) { + const out = [{ type: "link", text: link }, { type: "text", text: char }]; + test(assert, link + char, out); + } + }, + + "Unicode in hostname must not linkify": assert => { + const link = "https://foo.bar\uD83D\uDE03.com"; + const out = [{ type: "link", text: "https://foo.bar" }, + { type: "text", text: "\uD83D\uDE03.com" }]; + test(assert, link, out); + }, + + "Link with unicode only after / must linkify": assert => { + testLink(assert, "https://foo.bar.com/\uD83D\uDE03"); + }, + + "Link with unicode after fragment without path must linkify": assert => { + testLink(assert, "https://foo.bar.com#\uD83D\uDE03"); + }, + + "Link ends with <": assert => { + const link = "https://matrix.org<"; + const out = [{ type: "link", text: "https://matrix.org" }, { type: "text", text: "<" }]; + test(assert, link, out); + } + }; +} diff --git a/src/domain/session/room/timeline/linkify/regex.js b/src/domain/session/room/timeline/linkify/regex.js new file mode 100644 index 00000000..2374ee23 --- /dev/null +++ b/src/domain/session/room/timeline/linkify/regex.js @@ -0,0 +1,33 @@ +/* +The regex is split into component strings; +meaning that any escapes (\) must also +be escaped. +*/ +const scheme = "(?:https|http|ftp):\\/\\/"; +const noSpaceNorPunctuation = "[^\\s.,?!]"; +const hostCharacter = "[a-zA-Z0-9:.\\[\\]-]"; + +/* +Using non-consuming group here to combine two criteria for the last character. +See point 1 below. +*/ +const host = `${hostCharacter}*(?=${hostCharacter})${noSpaceNorPunctuation}`; + +/* +Use sub groups so we accept just / or #; but if anything comes after it, +it should not end with punctuation or space. +*/ +const pathOrFragment = `(?:[\\/#](?:[^\\s]*${noSpaceNorPunctuation})?)`; + +/* +Things to keep in mind: +1. URL must not contain non-ascii characters in host but may contain + them in path or fragment components. + https://matrix.org/ - valid + https://matrix.org - invalid +2. Do not treat punctuation at the end as a part of the URL (.,?!) +3. Path/fragment is optional. +*/ +const urlRegex = `${scheme}${host}${pathOrFragment}?`; + +export const regex = new RegExp(urlRegex, "gi"); diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index 8f5265d4..88e281d2 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -15,15 +15,27 @@ limitations under the License. */ import {MessageTile} from "./MessageTile.js"; +import { MessageBodyBuilder } from "../MessageBodyBuilder.js"; export class TextTile extends MessageTile { - get text() { + + get _contentBody() { const content = this._getContent(); - const body = content && content.body; + let body = content?.body || ""; if (content.msgtype === "m.emote") { - return `* ${this.displayName} ${body}`; - } else { - return body; + body = `* ${this.displayName} ${body}`; } + return body; + } + + get body() { + const body = this._contentBody; + if (body === this._body) { + return this._message; + } + const message = new MessageBodyBuilder(); + message.fromText(body); + [this._body, this._message] = [body, message]; + return message; } } diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 675b0035..b9eda412 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -16,33 +16,32 @@ limitations under the License. import {TemplateView} from "../../../general/TemplateView.js"; import {StaticView} from "../../../general/StaticView.js"; -import {text} from "../../../general/html.js"; +import { tag, text } from "../../../general/html.js"; import {renderMessage} from "./common.js"; export class TextMessageView extends TemplateView { render(t, vm) { - const bodyView = t.mapView(vm => vm.text, text => new BodyView(text)); + const bodyView = t.mapView(vm => vm.body, body => new BodyView(body)); return renderMessage(t, vm, [t.p([bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])] ); } } +const formatFunction = { + text: (m) => text(m.text), + link: (m) => tag.a({ href: m.url, target: "_blank", rel: "noopener" }, [text(m.text)]), + newline: () => tag.br() +}; + class BodyView extends StaticView { render(t, value) { - const lines = (value || "").split("\n"); - if (lines.length === 1) { - return text(lines[0]); + const children = []; + for (const m of value) { + const f = formatFunction[m.type]; + const element = f(m); + children.push(element); } - const elements = []; - for (const line of lines) { - if (elements.length) { - elements.push(t.br()); - } - if (line.length) { - elements.push(t.span(line)); - } - } - return t.span(elements); + return t.span(children); } }