Compare commits

...

29 Commits

Author SHA1 Message Date
cadence f6b95b2ebd Merge pull request 'Rich message rendering' (#24) from rich-messages into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: #24
2020-11-07 10:46:47 +00:00
Cadence Ember 951a46d8ec
Add back highlight.js for SCSS imports
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-07 23:45:20 +13:00
bad 6583c192ce Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details
2020-11-05 17:05:09 +00:00
Cadence Ember 34af1be7d1
Use dependencies instead of devDependencies
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2020-11-05 18:14:17 +13:00
Cadence Ember 1fa7da9ebb
Use JSDelivr CDN for highlight.js
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- downside: is somebody else's CDN
- upside: changes hljs download size from >1MB to 33k

Feel free to debate this.
2020-11-05 18:03:25 +13:00
Cadence Ember b74f0cc0dd
Don't highlight very short code blocks 2020-11-05 17:57:27 +13:00
Cadence Ember 1aebc2c100
Only hint modules once
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 17:48:13 +13:00
Cadence Ember 017f30be65
Also format m.notice
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 17:39:21 +13:00
Cadence Ember a7165fe633
Fix purify and highlight
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- purify: apply target=_blank to links
- purify: remove ALLOWED_URI_REGEXP - this breaks external links in
  anchor elements
- purify: return a DOM fragment instead of a string
- postprocess: only highlight pre
- postprocess: remove nested code inside pre
- better style messages with css
2020-11-05 17:37:00 +13:00
Cadence Ember 8ba9d73b33
Small refactors
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- "event" -> "eventData"
- create renderText method
- italics
2020-11-05 16:44:22 +13:00
Cadence Ember 9cf0952d3a
Change files to kebab-case
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2020-11-05 16:34:10 +13:00
Cadence Ember 714147b980
Fix lazy loading cache
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 16:32:42 +13:00
Bad 1bf1712684 Fix dynamic import with relative paths
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-31 18:24:05 +01:00
Bad 0738ce4cb1 Rename dateFormatter.js to date-formatter.js
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-31 18:21:04 +01:00
Bad 20e94f05e7 Lazy load highlight.js
This significantly reduces the bundle size(over 1MiB!) but it also uses
some hacks to dynamically load browserify modules on runtime(see
lazy-load-modules.js
2020-10-31 18:17:34 +01:00
Bad 4d59b1a9ac Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-30 23:06:34 +01:00
Bad 20bacce068 Remove the simple event shorthand
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-29 11:31:08 +01:00
Bad f80bf36991 Style fixes 2020-10-29 11:09:15 +01:00
Bad 217a815750 Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-29 11:02:51 +01:00
Bad bd9623578f Add hljs and improve sanitization
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is failing Details
2020-10-29 10:42:17 +01:00
Bad c144d75c99 Remove debug console.logs 2020-10-29 10:38:12 +01:00
Bad 66ecf44048 Remove console.log from membership 2020-10-29 10:36:38 +01:00
Bad e08b895694 Create a simple event shorthand
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-26 23:16:47 +01:00
Bad d983385e16 Fix compiler warnings 2020-10-26 22:58:38 +01:00
Bad f46f9abe6e Improve rich text rendering to more closely match the recommendations from the spec 2020-10-26 22:55:54 +01:00
Bad 1a8427925c Add unknown memberships 2020-10-26 22:55:27 +01:00
a 098ea88f5d Rebase rich-messages on princess
continuous-integration/drone/push Build is passing Details
2020-10-26 21:09:36 +01:00
BadAtNames 72b42e7b26 Initial work on rich messages 2020-10-26 21:04:08 +01:00
BadAtNames 0348fed18d Initial work on rich messages
continuous-integration/drone/push Build is passing Details
2020-10-26 09:10:02 +01:00
17 changed files with 573 additions and 629 deletions

View File

@ -145,9 +145,11 @@ async function addJS(sourcePath, targetPath) {
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 => {
browserify()
browserify([], opts)
.add(pj(".", sourcePath))
.transform(file => {
let content = ""
@ -173,7 +175,6 @@ async function addBundle(sourcePath, targetPath) {
})
const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
runHint(sourcePath, content)
await writer
}
@ -287,6 +288,9 @@ async function addBabel(sourcePath, targetPath) {
await addPug(item.source, item.target)
} else if (item.type === "bundle") {
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 {
throw new Error("Unknown item type: "+item.type)
}

721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,16 +11,18 @@
"keywords": [],
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {},
"devDependencies": {
"dependencies": {
"@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0",
"browserify": "^17.0.0",
"chalk": "^4.1.0",
"dompurify": "^2.2.0",
"highlight.js": "^10.3.2",
"http-server": "^0.12.3",
"jshint": "^2.12.0",
"node-fetch": "^2.6.0",
"pug": "^3.0.0",
"sass": "^1.26.10"
}
},
"devDependencies": {}
}

3
src/js/date-formatter.js Normal file
View File

@ -0,0 +1,3 @@
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
module.exports = {dateFormatter}

View 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}

View 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
View 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}

View 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
View 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]

View 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
View 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]

View 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}

View File

@ -4,13 +4,14 @@ const {store} = require("./store/store.js")
const {Anchor} = require("./anchor.js")
const {Sender} = require("./sender.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
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 +39,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 EventGroup extends ElemJS {
constructor(reactive, list) {
@ -314,7 +254,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)
}

View File

@ -5,3 +5,4 @@ $mild: #393c42
$milder: #42454a
$divider: #4b4e54
$muted: #999
$link: #57bffd

View File

@ -0,0 +1 @@
@use "../../../node_modules/highlight.js/scss/obsidian"

View File

@ -1,6 +1,6 @@
@use "../colors" as c
.c-event-groups *
.c-event-groups > *
overflow-anchor: none
.c-message-group, .c-message-event
@ -67,6 +67,33 @@
&:hover
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
padding-top: 10px
padding-left: 6px

View File

@ -5,4 +5,5 @@
@use "./components/chat"
@use "./components/chat-input"
@use "./components/anchor"
@use "./components/highlighted-code"
@use "./loading"