From 0348fed18db3adefc5d9a537c1952e0cf9233c3a 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 | 27 ++++++++++++++ package.json | 3 +- 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 | 68 +++--------------------------------- 10 files changed, 235 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 228e7ee..40cc6bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1949,6 +1949,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", @@ -2092,8 +2097,30 @@ "integrity": "sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==", "requires": { "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.0", "has-symbols": "^1.0.1", "object-keys": "^1.1.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } } } } diff --git a/package.json b/package.json index 8f2ead4..7b1fbfd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "author": "", "license": "AGPL-3.0-only", "dependencies": { - "browserify": "^17.0.0" + "browserify": "^17.0.0", + "dompurify": "^2.2.0" }, "devDependencies": { "@babel/core": "^7.11.1", 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 0819ff8..da98aa2 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) { @@ -311,7 +250,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) } @@ -393,6 +332,7 @@ class Timeline extends Subscribable { this.subscribe("afterChange", subscription) })*/ } + /* getGroupedEvents() { let currentSender = Symbol("N/A")