Rich message rendering #24

Merged
cadence merged 28 commits from rich-messages into princess 2020-11-07 10:46:48 +00:00
10 changed files with 213 additions and 65 deletions
Showing only changes of commit 72b42e7b26 - Show all commits

5
package-lock.json generated
View file

@ -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",

View file

@ -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",

3
src/js/dateFormatter.js Normal file
View file

@ -0,0 +1,3 @@
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
module.exports = {dateFormatter}

View file

@ -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]

59
src/js/events/event.js Normal file
View file

@ -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() {
Review

The original idea here was that we'd have a predicate for things like whether the method can be displayed as a message group, whether it should show a timestamp, or whatever other things could affect the display based on the class. I haven't actually put this method to any use yet.

The original idea here was that we'd have a predicate for things like whether the method can be displayed as a message group, whether it should show a timestamp, or whatever other things could affect the display based on the class. I haven't actually put this method to any use yet.
//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
}
}

Not sure how I feel about a function that creates classes. Can we inherit from a base class instead?

Not sure how I feel about a function that creates classes. Can we inherit from a base class instead?
Outdated
Review

That's a lot more verbose, which I don't really like, but sure

That's a lot more verbose, which I don't really like, but sure
function renderEvent(event) {
return new events.find(e => e.canRender(event))(event)
}
module.exports = {renderEvent, Event}

View file

@ -0,0 +1,21 @@
const {Event} = require("./event")
function createMembershipEvent(membership, text) {
return class extends Event {
render() {
super.render()
bad marked this conversation as resolved Outdated

Again here. I think a better way would be to have a base class like MembershipEvent, and then for each kind of membership we subclass it and only implement the canRender method on each.

Again here. I think a better way would be to have a base class like MembershipEvent, and then for each kind of membership we subclass it and only implement the canRender method on each.
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]

67
src/js/events/message.js Normal file
View file

@ -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'],
};

style: can we put spaces after // ?

style: can we put spaces after // ?
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]

View file

@ -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}

15
src/js/events/unknown.js Normal file
View file

@ -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]

View file

@ -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)
}