From 72b42e7b26b33a988e367c638c7e8e92ff6a596b Mon Sep 17 00:00:00 2001 From: BadAtNames Date: Mon, 26 Oct 2020 09:10:02 +0100 Subject: [PATCH] Initial work on rich messages --- package-lock.json | 5 +++ package.json | 4 ++- src/js/dateFormatter.js | 3 ++ src/js/events/encrypted.js | 18 ++++++++++ src/js/events/event.js | 59 +++++++++++++++++++++++++++++++ src/js/events/membership.js | 21 +++++++++++ src/js/events/message.js | 67 ++++++++++++++++++++++++++++++++++++ src/js/events/renderEvent.js | 19 ++++++++++ src/js/events/unknown.js | 15 ++++++++ src/js/timeline.js | 67 ++---------------------------------- 10 files changed, 213 insertions(+), 65 deletions(-) create mode 100644 src/js/dateFormatter.js create mode 100644 src/js/events/encrypted.js create mode 100644 src/js/events/event.js create mode 100644 src/js/events/membership.js create mode 100644 src/js/events/message.js create mode 100644 src/js/events/renderEvent.js create mode 100644 src/js/events/unknown.js diff --git a/package-lock.json b/package-lock.json index 5b9d4ec..2174afb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2013,6 +2013,11 @@ "domelementtype": "1" } }, + "dompurify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.2.0.tgz", + "integrity": "sha512-bqFOQ7XRmmozp0VsKdIEe8UwZYxj0yttz7l80GBtBqdVRY48cOpXH2J/CVO7AEkV51qY0EBVXfilec18mdmQ/w==" + }, "domutils": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", diff --git a/package.json b/package.json index ab4af6b..ebec6de 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "keywords": [], "author": "", "license": "AGPL-3.0-only", - "dependencies": {}, + "dependencies": { + "dompurify": "^2.2.0" + }, "devDependencies": { "@babel/core": "^7.11.1", "@babel/preset-env": "^7.11.0", diff --git a/src/js/dateFormatter.js b/src/js/dateFormatter.js new file mode 100644 index 0000000..c161d76 --- /dev/null +++ b/src/js/dateFormatter.js @@ -0,0 +1,3 @@ +const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"}) + +module.exports = {dateFormatter} diff --git a/src/js/events/encrypted.js b/src/js/events/encrypted.js new file mode 100644 index 0000000..32dbe19 --- /dev/null +++ b/src/js/events/encrypted.js @@ -0,0 +1,18 @@ +const {Event} = require("./event") + +class EncryptedMessage extends Event { + render() { + super.render() + return this.text("Carbon cannot render encrypted messages yet") + } + + static canRender(event) { + return event.type == "m.room.encrypted" + } + + canGroup() { + return true + } +} + +module.exports = [EncryptedMessage] diff --git a/src/js/events/event.js b/src/js/events/event.js new file mode 100644 index 0000000..ddb412d --- /dev/null +++ b/src/js/events/event.js @@ -0,0 +1,59 @@ +const {ElemJS, ejs} = require("../basic") +const {dateFormatter} = require("../dateFormatter") + +class Event extends ElemJS { + constructor(data) { + super("div") + this.class("c-message") + this.data = null + this.group = null + this.editedAt = null + this.update(data) + } + + // predicates + + canGroup() { + //return this.data.type === "m.room.message" + return false + } + + // operations + + setGroup(group) { + this.group = group + } + + setEdited(time) { + this.editedAt = time + this.render() + } + + update(data) { + this.data = data + this.render() + } + + removeEvent() { + if (this.group) this.group.removeEvent(this) + else this.remove() + } + + render() { + this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending") + if (this.editedAt) { + this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt))) + } + } + static canRender(_event) { + return false + } +} + + + +function renderEvent(event) { + return new events.find(e => e.canRender(event))(event) +} + +module.exports = {renderEvent, Event} diff --git a/src/js/events/membership.js b/src/js/events/membership.js new file mode 100644 index 0000000..97daea3 --- /dev/null +++ b/src/js/events/membership.js @@ -0,0 +1,21 @@ +const {Event} = require("./event") + +function createMembershipEvent(membership, text) { + return class extends Event { + render() { + super.render() + return this.text(text(this.data)) + } + + static canRender(event) { + return event.type == "m.room.member" && event.content.membership == membership + } + + } +} + +const JoinedEvent = createMembershipEvent("join", () => "joined the room") +const InvitedEvent = createMembershipEvent("invite", (e) => `invited ${e.content.displayname} the room`) +const LeaveEvent = createMembershipEvent("leave", () => "left the room") + +module.exports = [JoinedEvent, InvitedEvent, LeaveEvent] diff --git a/src/js/events/message.js b/src/js/events/message.js new file mode 100644 index 0000000..0218a60 --- /dev/null +++ b/src/js/events/message.js @@ -0,0 +1,67 @@ +const {ejs} = require("../basic") +const DOMPurify = require("dompurify") +const {resolveMxc} = require("../functions") +const {Event} = require("./event") + +const purifier = DOMPurify() +purifier.addHook("afterSanitizeAttributes", (node, hookevent, config) => { + if (node.tagName == "img") { + let src = node.getAttribute("src") + if (src) src = resolveMxc(src) + + node.setAttribute("src", src) + + } + if (node.tagName = "a") { + node.setAttribute("rel", "noopener") + } + return node + +}) + +function sanitize(html) { + return purifier.sanitize(html, DOMPURIFY_CONFIG) +} +const DOMPURIFY_CONFIG = { + ALLOWED_URI_REGEXP: /^mxc:\/\/[a-zA-Z0-9\.]+\/[a-zA-Z0-9]+$/, // As per the spec we only allow mxc uris + ALLOWED_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'], +}; + +class HTMLMessage extends Event { + render() { + super.render() + let html = this.data.content.formatted_body + const content = ejs("div") + html = sanitize(html) + content.html(html) + this.child(content) + } + + static canRender(event) { + const content = event.content + return event.type == "m.room.message" && content.msgtype == "m.text" && content.format == "org.matrix.custom.html" && content.formatted_body + + } + + canGroup() { + return true + } + +} + +class TextMessage extends Event { + render() { + super.render() + return this.text(this.data.content.body) + } + + static canRender(event) { + return event.type == "m.room.message" + } + + canGroup() { + return true + } +} + +module.exports = [HTMLMessage, TextMessage] diff --git a/src/js/events/renderEvent.js b/src/js/events/renderEvent.js new file mode 100644 index 0000000..e4616a2 --- /dev/null +++ b/src/js/events/renderEvent.js @@ -0,0 +1,19 @@ +const messageEvent = require("./message") +const encryptedEvent = require("./encrypted") +const membershipEvent = require("./membership") +const unknownEvent = require("./unknown") + +const events = [ + ...messageEvent, + ...encryptedEvent, + ...membershipEvent, + ...unknownEvent, +] + + +function renderEvent(eventData) { + const constructor = events.find(e => e.canRender(eventData)) + return new constructor(eventData) +} + +module.exports = {renderEvent} diff --git a/src/js/events/unknown.js b/src/js/events/unknown.js new file mode 100644 index 0000000..d644de7 --- /dev/null +++ b/src/js/events/unknown.js @@ -0,0 +1,15 @@ +const {Event} = require("./event") + +class UnknownEvent extends Event { + render() { + super.render() + this.text("Cannot render event") + } + + static canRender(_event) { + return true + } + +} + +module.exports = [UnknownEvent] diff --git a/src/js/timeline.js b/src/js/timeline.js index 270b5c8..de7e63f 100644 --- a/src/js/timeline.js +++ b/src/js/timeline.js @@ -4,13 +4,13 @@ const {store} = require("./store/store.js") const {Anchor} = require("./anchor.js") const lsm = require("./lsm.js") const {resolveMxc} = require("./functions.js") +const {renderEvent} = require("./events/renderEvent") +const {dateFormatter} = require("./dateFormatter") let debug = false const NO_MAX = Symbol("NO_MAX") -const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"}) - let sentIndex = 0 function getTxnId() { @@ -38,67 +38,6 @@ function eventSearch(list, event, min = 0, max = NO_MAX) { else return eventSearch(list, event, mid + 1, max) } -class Event extends ElemJS { - constructor(data) { - super("div") - this.class("c-message") - this.data = null - this.group = null - this.editedAt = null - this.update(data) - } - - // predicates - - canGroup() { - return this.data.type === "m.room.message" - } - - // operations - - setGroup(group) { - this.group = group - } - - setEdited(time) { - this.editedAt = time - this.render() - } - - update(data) { - this.data = data - this.render() - } - - removeEvent() { - if (this.group) this.group.removeEvent(this) - else this.remove() - } - - render() { - this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending") - if (this.data.type === "m.room.message") { - this.text(this.data.content.body) - } else if (this.data.type === "m.room.member") { - if (this.data.content.membership === "join") { - this.child(ejs("i").text("joined the room")) - } else if (this.data.content.membership === "invite") { - this.child(ejs("i").text(`invited ${this.data.content.displayname} to the room`)) - } else if (this.data.content.membership === "leave") { - this.child(ejs("i").text("left the room")) - } else { - this.child(ejs("i").text("unknown membership event")) - } - } else if (this.data.type === "m.room.encrypted") { - this.child(ejs("i").text("Carbon does not yet support encrypted messages.")) - } else { - this.child(ejs("i").text(`Unsupported event type ${this.data.type}`)) - } - if (this.editedAt) { - this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt))) - } - } -} class Sender { constructor(roomID, mxid) { @@ -345,7 +284,7 @@ class Timeline extends Subscribable { continue } // add new event - const event = new Event(eventData) + const event = renderEvent(eventData) this.map.set(id, event) this.reactiveTimeline.addEvent(event) }