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"
|
"domelementtype": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dompurify": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-bqFOQ7XRmmozp0VsKdIEe8UwZYxj0yttz7l80GBtBqdVRY48cOpXH2J/CVO7AEkV51qY0EBVXfilec18mdmQ/w=="
|
||||||
|
},
|
||||||
"domutils": {
|
"domutils": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
|
||||||
|
|
|
@ -11,7 +11,9 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"dompurify": "^2.2.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.11.1",
|
"@babel/core": "^7.11.1",
|
||||||
"@babel/preset-env": "^7.11.0",
|
"@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
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'],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {Anchor} = require("./anchor.js")
|
||||||
const lsm = require("./lsm.js")
|
const lsm = require("./lsm.js")
|
||||||
const {resolveMxc} = require("./functions.js")
|
const {resolveMxc} = require("./functions.js")
|
||||||
|
const {renderEvent} = require("./events/renderEvent")
|
||||||
|
const {dateFormatter} = require("./dateFormatter")
|
||||||
|
|
||||||
let debug = false
|
let debug = false
|
||||||
|
|
||||||
const NO_MAX = Symbol("NO_MAX")
|
const NO_MAX = Symbol("NO_MAX")
|
||||||
|
|
||||||
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
|
|
||||||
|
|
||||||
let sentIndex = 0
|
let sentIndex = 0
|
||||||
|
|
||||||
function getTxnId() {
|
function getTxnId() {
|
||||||
|
@ -38,67 +38,6 @@ function eventSearch(list, event, min = 0, max = NO_MAX) {
|
||||||
else return eventSearch(list, event, mid + 1, 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 {
|
class Sender {
|
||||||
constructor(roomID, mxid) {
|
constructor(roomID, mxid) {
|
||||||
|
@ -345,7 +284,7 @@ class Timeline extends Subscribable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// add new event
|
// add new event
|
||||||
const event = new Event(eventData)
|
const event = renderEvent(eventData)
|
||||||
this.map.set(id, event)
|
this.map.set(id, event)
|
||||||
this.reactiveTimeline.addEvent(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.