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/<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");
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);
     }
 }