Rich message rendering #24
10 changed files with 235 additions and 65 deletions
27
package-lock.json
generated
27
package-lock.json
generated
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
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 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")
|
||||
|
|
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.