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
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
| 
						 | 
				
			
			@ -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…
	
	Add table
		Add a link
		
	
		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.