Merge pull request 'Rich message rendering' (#24) from rich-messages into princess
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is passing
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			Reviewed-on: #24
This commit is contained in:
		
						commit
						f6b95b2ebd
					
				
					 17 changed files with 573 additions and 629 deletions
				
			
		
							
								
								
									
										10
									
								
								build.js
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								build.js
									
										
									
									
									
								
							| 
						 | 
					@ -145,9 +145,11 @@ async function addJS(sourcePath, targetPath) {
 | 
				
			||||||
	await fs.promises.writeFile(pj(buildDir, targetPath), content)
 | 
						await fs.promises.writeFile(pj(buildDir, targetPath), content)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function addBundle(sourcePath, targetPath) {
 | 
					async function addBundle(sourcePath, targetPath, module = false) {
 | 
				
			||||||
 | 
						let opts = {}
 | 
				
			||||||
 | 
						if (module) opts.standalone = sourcePath
 | 
				
			||||||
	const content = await new Promise(resolve => {
 | 
						const content = await new Promise(resolve => {
 | 
				
			||||||
		browserify()
 | 
							browserify([], opts)
 | 
				
			||||||
			.add(pj(".", sourcePath))
 | 
								.add(pj(".", sourcePath))
 | 
				
			||||||
			.transform(file => {
 | 
								.transform(file => {
 | 
				
			||||||
				let content = ""
 | 
									let content = ""
 | 
				
			||||||
| 
						 | 
					@ -173,7 +175,6 @@ async function addBundle(sourcePath, targetPath) {
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
 | 
						const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
 | 
				
			||||||
	staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
 | 
						staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
 | 
				
			||||||
	runHint(sourcePath, content)
 | 
					 | 
				
			||||||
	await writer
 | 
						await writer
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -287,6 +288,9 @@ async function addBabel(sourcePath, targetPath) {
 | 
				
			||||||
			await addPug(item.source, item.target)
 | 
								await addPug(item.source, item.target)
 | 
				
			||||||
		} else if (item.type === "bundle") {
 | 
							} else if (item.type === "bundle") {
 | 
				
			||||||
			await addBundle(item.source, item.target)
 | 
								await addBundle(item.source, item.target)
 | 
				
			||||||
 | 
							} else if (item.type === "module") {
 | 
				
			||||||
 | 
								// Creates a standalone bundle that can be imported on runtime
 | 
				
			||||||
 | 
								await addBundle(item.source, item.target, true)
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			throw new Error("Unknown item type: "+item.type)
 | 
								throw new Error("Unknown item type: "+item.type)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										721
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										721
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							| 
						 | 
					@ -11,16 +11,18 @@
 | 
				
			||||||
  "keywords": [],
 | 
					  "keywords": [],
 | 
				
			||||||
  "author": "",
 | 
					  "author": "",
 | 
				
			||||||
  "license": "AGPL-3.0-only",
 | 
					  "license": "AGPL-3.0-only",
 | 
				
			||||||
  "dependencies": {},
 | 
					  "dependencies": {
 | 
				
			||||||
  "devDependencies": {
 | 
					 | 
				
			||||||
    "@babel/core": "^7.11.1",
 | 
					    "@babel/core": "^7.11.1",
 | 
				
			||||||
    "@babel/preset-env": "^7.11.0",
 | 
					    "@babel/preset-env": "^7.11.0",
 | 
				
			||||||
    "browserify": "^17.0.0",
 | 
					    "browserify": "^17.0.0",
 | 
				
			||||||
    "chalk": "^4.1.0",
 | 
					    "chalk": "^4.1.0",
 | 
				
			||||||
 | 
					    "dompurify": "^2.2.0",
 | 
				
			||||||
 | 
					    "highlight.js": "^10.3.2",
 | 
				
			||||||
    "http-server": "^0.12.3",
 | 
					    "http-server": "^0.12.3",
 | 
				
			||||||
    "jshint": "^2.12.0",
 | 
					    "jshint": "^2.12.0",
 | 
				
			||||||
    "node-fetch": "^2.6.0",
 | 
					    "node-fetch": "^2.6.0",
 | 
				
			||||||
    "pug": "^3.0.0",
 | 
					    "pug": "^3.0.0",
 | 
				
			||||||
    "sass": "^1.26.10"
 | 
					    "sass": "^1.26.10"
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										3
									
								
								src/js/date-formatter.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/js/date-formatter.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}
 | 
				
			||||||
							
								
								
									
										31
									
								
								src/js/events/components.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/js/events/components.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,31 @@
 | 
				
			||||||
 | 
					const {ElemJS} = require("../basic")
 | 
				
			||||||
 | 
					const {lazyLoad} = require("../lazy-load-module")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HighlightedCode extends ElemJS {
 | 
				
			||||||
 | 
						constructor(element) {
 | 
				
			||||||
 | 
							super(element)
 | 
				
			||||||
 | 
							if (this.element.tagName === "PRE" && this.element.children.length === 1 && this.element.children[0].tagName === "CODE") {
 | 
				
			||||||
 | 
								// we shouldn't nest code inside a pre. put the text in the pre directly.
 | 
				
			||||||
 | 
								const code = this.element.children[0]
 | 
				
			||||||
 | 
								this.clearChildren()
 | 
				
			||||||
 | 
								for (const child of code.childNodes) {
 | 
				
			||||||
 | 
									this.element.appendChild(child)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if (this.element.textContent.length > 80) {
 | 
				
			||||||
 | 
								/*
 | 
				
			||||||
 | 
								  no need to highlight very short code blocks:
 | 
				
			||||||
 | 
								  - content inside might not be code, some users still use code blocks
 | 
				
			||||||
 | 
								    for plaintext quotes
 | 
				
			||||||
 | 
								  - language detection will almost certainly be incorrect
 | 
				
			||||||
 | 
								  - even if it's code and the language is detected, the user will
 | 
				
			||||||
 | 
								    be able to mentally format small amounts of code themselves
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								  feel free to change the threshold number
 | 
				
			||||||
 | 
								*/
 | 
				
			||||||
 | 
								lazyLoad("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10/build/highlight.min.js").then(hljs => hljs.highlightBlock(this.element))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = {HighlightedCode}
 | 
				
			||||||
							
								
								
									
										22
									
								
								src/js/events/encrypted.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/js/events/encrypted.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,22 @@
 | 
				
			||||||
 | 
					const {MatrixEvent} = require("./event")
 | 
				
			||||||
 | 
					const {ejs} = require("../basic")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EncryptedMessage extends MatrixEvent {
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.clearChildren()
 | 
				
			||||||
 | 
							this.child(
 | 
				
			||||||
 | 
								ejs("i").text("Carbon cannot render encrypted messages yet")
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							super.render()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return eventData.type === "m.room.encrypted"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						canGroup() {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = [EncryptedMessage]
 | 
				
			||||||
							
								
								
									
										54
									
								
								src/js/events/event.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/js/events/event.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,54 @@
 | 
				
			||||||
 | 
					const {ElemJS, ejs} = require("../basic")
 | 
				
			||||||
 | 
					const {dateFormatter} = require("../date-formatter")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MatrixEvent 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 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)))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return this
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = {MatrixEvent}
 | 
				
			||||||
							
								
								
									
										55
									
								
								src/js/events/membership.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/js/events/membership.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,55 @@
 | 
				
			||||||
 | 
					const {MatrixEvent} = require("./event")
 | 
				
			||||||
 | 
					const {ejs} = require("../basic")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MembershipEvent extends MatrixEvent {
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return eventData.type === "m.room.member"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						renderText(text) {
 | 
				
			||||||
 | 
							this.clearChildren()
 | 
				
			||||||
 | 
							this.child(
 | 
				
			||||||
 | 
								ejs("i").text(text)
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							super.render()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class JoinedEvent extends MembershipEvent {
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return super.canRender(eventData) && eventData.content.membership === "join"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.renderText("joined the room")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InvitedEvent extends MembershipEvent {
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return super.canRender(eventData) && eventData.content.membership === "invite"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.renderText(`invited ${this.data.content.displayname}`)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LeaveEvent extends MembershipEvent {
 | 
				
			||||||
 | 
						static canRender(eventData) {
 | 
				
			||||||
 | 
							return super.canRender(eventData) && eventData.content.membership === "leave"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.renderText("left the room")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UnknownMembership extends MembershipEvent {
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.renderText("unknown membership event")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = [JoinedEvent, InvitedEvent, LeaveEvent, UnknownMembership]
 | 
				
			||||||
							
								
								
									
										140
									
								
								src/js/events/message.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								src/js/events/message.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,140 @@
 | 
				
			||||||
 | 
					const {ejs, ElemJS} = require("../basic")
 | 
				
			||||||
 | 
					const {HighlightedCode} = require("./components")
 | 
				
			||||||
 | 
					const DOMPurify = require("dompurify")
 | 
				
			||||||
 | 
					const {resolveMxc} = require("../functions")
 | 
				
			||||||
 | 
					const {MatrixEvent} = require("./event")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const purifier = DOMPurify()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					purifier.addHook("uponSanitizeAttribute", (node, hookevent, config) => {
 | 
				
			||||||
 | 
						// If purifier already rejected an attribute there is no point in checking it
 | 
				
			||||||
 | 
						if (hookevent.keepAttr === false) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const allowedElementAttributes = {
 | 
				
			||||||
 | 
							"FONT": ["data-mx-bg-color", "data-mx-color", "color"],
 | 
				
			||||||
 | 
							"SPAN": ["data-mx-bg-color", "data-mx-color"],
 | 
				
			||||||
 | 
							"A": ["name", "target", "href"],
 | 
				
			||||||
 | 
							"IMG": ["width", "height", "alt", "title", "src", "data-mx-emoticon"],
 | 
				
			||||||
 | 
							"OL": ["start"],
 | 
				
			||||||
 | 
							"CODE": ["class"],
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const allowedAttributes = allowedElementAttributes[node.tagName] || []
 | 
				
			||||||
 | 
						hookevent.keepAttr = allowedAttributes.indexOf(hookevent.attrName) > -1
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					purifier.addHook("uponSanitizeElement", (node, hookevent, config) => {
 | 
				
			||||||
 | 
						// Remove bad classes from our code element
 | 
				
			||||||
 | 
						if (node.tagName === "CODE") {
 | 
				
			||||||
 | 
							node.classList.forEach(c => {
 | 
				
			||||||
 | 
								if (!c.startsWith("language-")) {
 | 
				
			||||||
 | 
									node.classList.remove(c)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (node.tagName === "A") {
 | 
				
			||||||
 | 
							node.setAttribute("rel", "noopener") // prevent the opening page from accessing carbon
 | 
				
			||||||
 | 
							node.setAttribute("target", "_blank") // open in a new tab instead of replacing carbon
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return node
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function cleanHTML(html) {
 | 
				
			||||||
 | 
						const config = {
 | 
				
			||||||
 | 
							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",
 | 
				
			||||||
 | 
								// matrix tags
 | 
				
			||||||
 | 
								"mx-reply"
 | 
				
			||||||
 | 
							],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// In case we mess up in the uponSanitizeAttribute hook
 | 
				
			||||||
 | 
							ALLOWED_ATTR: [
 | 
				
			||||||
 | 
								"color", "name", "target", "href", "width", "height", "alt", "title",
 | 
				
			||||||
 | 
								"src", "start", "class", "noreferrer", "noopener",
 | 
				
			||||||
 | 
								// matrix attrs
 | 
				
			||||||
 | 
								"data-mx-emoticon", "data-mx-bg-color", "data-mx-color"
 | 
				
			||||||
 | 
							],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Return a DOM fragment instead of a string, avoids potential future mutation XSS
 | 
				
			||||||
 | 
							// should also be faster than the browser parsing HTML twice
 | 
				
			||||||
 | 
							// https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
 | 
				
			||||||
 | 
							RETURN_DOM_FRAGMENT: true,
 | 
				
			||||||
 | 
							RETURN_DOM_IMPORT: true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return purifier.sanitize(html, config)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Here we put all the processing of the messages that isn't as likely to potentially lead to security issues
 | 
				
			||||||
 | 
					function postProcessElements(rootNode) {
 | 
				
			||||||
 | 
						const element = rootNode.element
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						element.querySelectorAll("pre").forEach(n => {
 | 
				
			||||||
 | 
							new HighlightedCode(n)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						element.querySelectorAll("img").forEach(n => {
 | 
				
			||||||
 | 
							let src = n.getAttribute("src")
 | 
				
			||||||
 | 
							if (src) src = resolveMxc(src)
 | 
				
			||||||
 | 
							n.setAttribute("src", src)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						element.querySelectorAll("font, span").forEach(n => {
 | 
				
			||||||
 | 
							const color = n.getAttribute("data-mx-color") || n.getAttribute("color")
 | 
				
			||||||
 | 
							const bgColor = n.getAttribute("data-mx-bg-color")
 | 
				
			||||||
 | 
							if (color) n.style.color = color
 | 
				
			||||||
 | 
							if (bgColor) n.style.backgroundColor = bgColor
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HTMLMessage extends MatrixEvent {
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.clearChildren()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							let html = this.data.content.formatted_body
 | 
				
			||||||
 | 
							const content = ejs("div")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							const fragment = cleanHTML(html)
 | 
				
			||||||
 | 
							content.element.appendChild(fragment)
 | 
				
			||||||
 | 
							postProcessElements(content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							this.child(content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							super.render()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						static canRender(event) {
 | 
				
			||||||
 | 
							const content = event.content
 | 
				
			||||||
 | 
							return (
 | 
				
			||||||
 | 
								event.type === "m.room.message"
 | 
				
			||||||
 | 
									&& (content.msgtype === "m.text" || content.msgtype === "m.notice")
 | 
				
			||||||
 | 
									&& content.format === "org.matrix.custom.html"
 | 
				
			||||||
 | 
									&& content.formatted_body
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						canGroup() {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TextMessage extends MatrixEvent {
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.clearChildren()
 | 
				
			||||||
 | 
							this.text(this.data.content.body)
 | 
				
			||||||
 | 
							super.render()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						static canRender(event) {
 | 
				
			||||||
 | 
							return event.type === "m.room.message"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						canGroup() {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = [HTMLMessage, TextMessage]
 | 
				
			||||||
							
								
								
									
										19
									
								
								src/js/events/render-event.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/js/events/render-event.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}
 | 
				
			||||||
							
								
								
									
										19
									
								
								src/js/events/unknown.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/js/events/unknown.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,19 @@
 | 
				
			||||||
 | 
					const {MatrixEvent} = require("./event")
 | 
				
			||||||
 | 
					const {ejs} = require("../basic")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UnknownEvent extends MatrixEvent {
 | 
				
			||||||
 | 
						static canRender() {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						render() {
 | 
				
			||||||
 | 
							this.clearChildren()
 | 
				
			||||||
 | 
							this.child(
 | 
				
			||||||
 | 
								ejs("i").text(`Unknown event of type ${this.data.type}`)
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							super.render()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = [UnknownEvent]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										20
									
								
								src/js/lazy-load-module.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/js/lazy-load-module.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					// I hate this with passion
 | 
				
			||||||
 | 
					async function lazyLoad(url) {
 | 
				
			||||||
 | 
						const cache = window.lazyLoadCache || new Map()
 | 
				
			||||||
 | 
						window.lazyLoadCache = cache
 | 
				
			||||||
 | 
						if (cache.get(url)) return cache.get(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const module = loadModuleWithoutCache(url)
 | 
				
			||||||
 | 
						cache.set(url, module)
 | 
				
			||||||
 | 
						return module
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Loads the module without caching
 | 
				
			||||||
 | 
					async function loadModuleWithoutCache(url) {
 | 
				
			||||||
 | 
						const src = await fetch(url).then(r => r.text())
 | 
				
			||||||
 | 
						let module = {}
 | 
				
			||||||
 | 
						eval(src)
 | 
				
			||||||
 | 
						return module.exports
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = {lazyLoad}
 | 
				
			||||||
| 
						 | 
					@ -4,13 +4,14 @@ const {store} = require("./store/store.js")
 | 
				
			||||||
const {Anchor} = require("./anchor.js")
 | 
					const {Anchor} = require("./anchor.js")
 | 
				
			||||||
const {Sender} = require("./sender.js")
 | 
					const {Sender} = require("./sender.js")
 | 
				
			||||||
const lsm = require("./lsm.js")
 | 
					const lsm = require("./lsm.js")
 | 
				
			||||||
 | 
					const {resolveMxc} = require("./functions.js")
 | 
				
			||||||
 | 
					const {renderEvent} = require("./events/render-event")
 | 
				
			||||||
 | 
					const {dateFormatter} = require("./date-formatter")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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 +39,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 EventGroup extends ElemJS {
 | 
					class EventGroup extends ElemJS {
 | 
				
			||||||
	constructor(reactive, list) {
 | 
						constructor(reactive, list) {
 | 
				
			||||||
| 
						 | 
					@ -314,7 +254,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)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,3 +5,4 @@ $mild: #393c42
 | 
				
			||||||
$milder: #42454a
 | 
					$milder: #42454a
 | 
				
			||||||
$divider: #4b4e54
 | 
					$divider: #4b4e54
 | 
				
			||||||
$muted: #999
 | 
					$muted: #999
 | 
				
			||||||
 | 
					$link: #57bffd
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										1
									
								
								src/sass/components/highlighted-code.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/sass/components/highlighted-code.sass
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					@use "../../../node_modules/highlight.js/scss/obsidian"
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
@use "../colors" as c
 | 
					@use "../colors" as c
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.c-event-groups *
 | 
					.c-event-groups > *
 | 
				
			||||||
  overflow-anchor: none
 | 
					  overflow-anchor: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.c-message-group, .c-message-event
 | 
					.c-message-group, .c-message-event
 | 
				
			||||||
| 
						 | 
					@ -67,6 +67,33 @@
 | 
				
			||||||
    &:hover
 | 
					    &:hover
 | 
				
			||||||
      background-color: c.$darker
 | 
					      background-color: c.$darker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // message formatting rules
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  code, pre
 | 
				
			||||||
 | 
					    border-radius: 4px
 | 
				
			||||||
 | 
					    font-size: 0.9em
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pre
 | 
				
			||||||
 | 
					    background-color: c.$darkest
 | 
				
			||||||
 | 
					    padding: 8px
 | 
				
			||||||
 | 
					    border: 1px solid c.$divider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  code
 | 
				
			||||||
 | 
					    background-color: c.$darker
 | 
				
			||||||
 | 
					    padding: 2px 4px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  a
 | 
				
			||||||
 | 
					    color: c.$link
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  p, pre
 | 
				
			||||||
 | 
					    margin: 16px 0px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:first-child
 | 
				
			||||||
 | 
					      margin-top: 0px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:last-child
 | 
				
			||||||
 | 
					      margin-bottom: 0px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.c-message-event
 | 
					.c-message-event
 | 
				
			||||||
  padding-top: 10px
 | 
					  padding-top: 10px
 | 
				
			||||||
  padding-left: 6px
 | 
					  padding-left: 6px
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,4 +5,5 @@
 | 
				
			||||||
@use "./components/chat"
 | 
					@use "./components/chat"
 | 
				
			||||||
@use "./components/chat-input"
 | 
					@use "./components/chat-input"
 | 
				
			||||||
@use "./components/anchor"
 | 
					@use "./components/anchor"
 | 
				
			||||||
 | 
					@use "./components/highlighted-code"
 | 
				
			||||||
@use "./loading"
 | 
					@use "./loading"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue