Merge pull request #345 from MidhunSureshR/linkify
Render URLs as clickable links in timeline
This commit is contained in:
commit
93c08c16c1
5 changed files with 280 additions and 20 deletions
96
src/domain/session/room/timeline/MessageBodyBuilder.js
Normal file
96
src/domain/session/room/timeline/MessageBodyBuilder.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
120
src/domain/session/room/timeline/linkify/linkify.js
Normal file
120
src/domain/session/room/timeline/linkify/linkify.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
33
src/domain/session/room/timeline/linkify/regex.js
Normal file
33
src/domain/session/room/timeline/linkify/regex.js
Normal file
|
@ -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/<smiley> - valid
|
||||||
|
https://matrix.org<smiley> - 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");
|
|
@ -15,15 +15,27 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {MessageTile} from "./MessageTile.js";
|
import {MessageTile} from "./MessageTile.js";
|
||||||
|
import { MessageBodyBuilder } from "../MessageBodyBuilder.js";
|
||||||
|
|
||||||
export class TextTile extends MessageTile {
|
export class TextTile extends MessageTile {
|
||||||
get text() {
|
|
||||||
|
get _contentBody() {
|
||||||
const content = this._getContent();
|
const content = this._getContent();
|
||||||
const body = content && content.body;
|
let body = content?.body || "";
|
||||||
if (content.msgtype === "m.emote") {
|
if (content.msgtype === "m.emote") {
|
||||||
return `* ${this.displayName} ${body}`;
|
body = `* ${this.displayName} ${body}`;
|
||||||
} else {
|
}
|
||||||
return 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,33 +16,32 @@ limitations under the License.
|
||||||
|
|
||||||
import {TemplateView} from "../../../general/TemplateView.js";
|
import {TemplateView} from "../../../general/TemplateView.js";
|
||||||
import {StaticView} from "../../../general/StaticView.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";
|
import {renderMessage} from "./common.js";
|
||||||
|
|
||||||
export class TextMessageView extends TemplateView {
|
export class TextMessageView extends TemplateView {
|
||||||
render(t, vm) {
|
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,
|
return renderMessage(t, vm,
|
||||||
[t.p([bodyView, t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)])]
|
[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 {
|
class BodyView extends StaticView {
|
||||||
render(t, value) {
|
render(t, value) {
|
||||||
const lines = (value || "").split("\n");
|
const children = [];
|
||||||
if (lines.length === 1) {
|
for (const m of value) {
|
||||||
return text(lines[0]);
|
const f = formatFunction[m.type];
|
||||||
|
const element = f(m);
|
||||||
|
children.push(element);
|
||||||
}
|
}
|
||||||
const elements = [];
|
return t.span(children);
|
||||||
for (const line of lines) {
|
|
||||||
if (elements.length) {
|
|
||||||
elements.push(t.br());
|
|
||||||
}
|
|
||||||
if (line.length) {
|
|
||||||
elements.push(t.span(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t.span(elements);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue