Rich message rendering #24
10 changed files with 213 additions and 65 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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
3
src/js/dateFormatter.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
|
||||
|
||||
module.exports = {dateFormatter}
|
18
src/js/events/encrypted.js
Normal file
18
src/js/events/encrypted.js
Normal 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
59
src/js/events/event.js
Normal 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() {
|
||||
|
||||
//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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cadence
commented
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?
bad
commented
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}
|
21
src/js/events/membership.js
Normal file
21
src/js/events/membership.js
Normal 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
cadence
commented
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
67
src/js/events/message.js
Normal 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'],
|
||||
};
|
||||
cadence
commented
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]
|
19
src/js/events/renderEvent.js
Normal file
19
src/js/events/renderEvent.js
Normal 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
15
src/js/events/unknown.js
Normal 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]
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue
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.