inside . put the text in directly.
+ const code = this.element.children[0]
+ this.clearChildren()
+ while (code.firstChild) {
+ this.element.appendChild(code.firstChild)
+ }
+ }
+ let shouldHighlight = (
+ // if there are child _elements_, it's already formatted, we shouldn't mess that up
+ this.element.children.length === 0
+ /*
+ 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
+ */
+ && this.element.textContent.length > 80
+ )
+ if (shouldHighlight) {
+ lazyLoad("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10/build/highlight.min.js").then(hljs => hljs.highlightBlock(this.element))
+ }
+ }
+}
+
+module.exports = {HighlightedCode}
diff --git a/src/js/events/encrypted.js b/src/js/events/encrypted.js
new file mode 100644
index 0000000..efc3ccf
--- /dev/null
+++ b/src/js/events/encrypted.js
@@ -0,0 +1,18 @@
+const {GroupableEvent} = require("./event")
+const {ejs} = require("../basic")
+
+class EncryptedMessage extends GroupableEvent {
+ 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"
+ }
+}
+
+module.exports = [EncryptedMessage]
diff --git a/src/js/events/event.js b/src/js/events/event.js
new file mode 100644
index 0000000..040d94e
--- /dev/null
+++ b/src/js/events/event.js
@@ -0,0 +1,72 @@
+const {ElemJS, ejs} = require("../basic")
+const {dateFormatter} = require("../date-formatter")
+const {SubscribeSet} = require("../store/subscribe_set.js")
+
+class MatrixEvent extends ElemJS {
+ constructor(data) {
+ super("div")
+ this.data = null
+ this.group = null
+ this.editedAt = null
+ this.readBy = new SubscribeSet()
+ 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
+ }
+}
+
+class GroupableEvent extends MatrixEvent {
+ constructor(data) {
+ super(data)
+ this.class("c-message")
+ }
+
+ canGroup() {
+ return true
+ }
+}
+
+class UngroupableEvent extends MatrixEvent {
+}
+
+module.exports = {
+ GroupableEvent,
+ UngroupableEvent
+}
diff --git a/src/js/events/hidden.js b/src/js/events/hidden.js
new file mode 100644
index 0000000..372bb72
--- /dev/null
+++ b/src/js/events/hidden.js
@@ -0,0 +1,18 @@
+const {UngroupableEvent} = require("./event")
+
+class HiddenEvent extends UngroupableEvent {
+ constructor(data) {
+ super(data)
+ this.class("c-hidden-event")
+ this.clearChildren()
+ }
+
+ static canRender(eventData) {
+ return ["m.reaction", "m.call.candidates"].includes(eventData.type)
+ }
+
+ render() {
+ }
+}
+
+module.exports = [HiddenEvent]
diff --git a/src/js/events/image.js b/src/js/events/image.js
new file mode 100644
index 0000000..1a32bcf
--- /dev/null
+++ b/src/js/events/image.js
@@ -0,0 +1,48 @@
+const {ejs, ElemJS} = require("../basic")
+const {resolveMxc} = require("../functions")
+const {GroupableEvent} = require("./event")
+
+class Image extends GroupableEvent {
+ render() {
+ this.clearChildren()
+ this.class("c-message--media")
+ const image = (
+ ejs("img")
+ .class("c-message__image")
+ .attribute("src", resolveMxc(this.data.content.url))
+ )
+ const info = this.data.content.info
+ if (info && info.w && info.h) {
+ image.attribute("width", info.w)
+ image.attribute("height", info.h)
+ }
+ const wrapper = ejs("div").class("c-media__wrapper").child(
+ image
+ )
+ if (this.data.content.body && this.data.content.body.startsWith("SPOILER")) {
+ wrapper.attribute("tabindex", 0)
+ wrapper.class("c-media--spoiler")
+ const wall = ejs("div").class("c-media__spoiler").text("Spoiler")
+ wrapper.child(wall)
+ const toggle = () => {
+ wrapper.element.classList.toggle("c-media--shown")
+ }
+ wrapper.on("click", toggle)
+ wrapper.on("keydown", event => {
+ if (event.key === "Enter") toggle()
+ })
+ }
+ this.child(wrapper)
+ super.render()
+ }
+
+ static canRender(event) {
+ return event.type === "m.room.message" && event.content.msgtype === "m.image"
+ }
+
+ canGroup() {
+ return true
+ }
+}
+
+module.exports = [Image]
diff --git a/src/js/events/membership.js b/src/js/events/membership.js
new file mode 100644
index 0000000..5617d3e
--- /dev/null
+++ b/src/js/events/membership.js
@@ -0,0 +1,127 @@
+const {UngroupableEvent} = require("./event")
+const {ejs} = require("../basic")
+const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
+
+class MembershipEvent extends UngroupableEvent {
+ constructor(data) {
+ super(data)
+ this.class("c-message-event")
+ this.senderName = extractDisplayName(data)
+ if (data.content.avatar_url) {
+ this.smallAvatar = ejs("img")
+ .attribute("width", "32")
+ .attribute("height", "32")
+ .attribute("src", resolveMxc(data.content.avatar_url, 32, "crop"))
+ .class("c-message-event__avatar")
+ } else {
+ this.smallAvatar = ""
+ }
+ this.render()
+ }
+
+ static canRender(eventData) {
+ return eventData.type === "m.room.member"
+ }
+
+ renderInner(iconURL, elements) {
+ this.clearChildren()
+ this.child(
+ ejs("div").class("c-message-event__inner").child(
+ iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
+ ...elements
+ )
+ )
+ super.render()
+ }
+}
+
+
+class JoinedEvent extends MembershipEvent {
+ static canRender(eventData) {
+ return super.canRender(eventData) && eventData.content.membership === "join"
+ }
+
+ render() {
+ const changes = []
+ const prev = this.data.unsigned.prev_content
+ if (prev && prev.membership === "join") {
+ if (prev.avatar_url !== this.data.content.avatar_url) {
+ changes.push("changed their avatar")
+ }
+ if (prev.displayname !== this.data.content.displayname) {
+ changes.push(`changed their display name (was ${this.data.unsigned.prev_content.displayname})`)
+ }
+ }
+ let message
+ let iconURL
+ if (changes.length) {
+ message = " " + changes.join(", ")
+ iconURL = "static/profile-event.svg"
+ } else {
+ message = " joined the room"
+ iconURL = "static/join-event.svg"
+ }
+ this.renderInner(iconURL, [
+ this.smallAvatar,
+ this.senderName,
+ message
+ ])
+ }
+}
+
+class InvitedEvent extends MembershipEvent {
+ static canRender(eventData) {
+ return super.canRender(eventData) && eventData.content.membership === "invite"
+ }
+
+ render() {
+ this.renderInner("static/invite-event.svg", [
+ this.smallAvatar,
+ `${extractLocalpart(this.data.sender)} invited ${this.data.state_key}` // full mxid for clarity
+ ])
+ }
+}
+
+class LeaveEvent extends MembershipEvent {
+ static canRender(eventData) {
+ return super.canRender(eventData) && eventData.content.membership === "leave"
+ }
+
+ render() {
+ this.renderInner("static/leave-event.svg", [
+ this.smallAvatar,
+ this.senderName,
+ " left the room"
+ ])
+ }
+}
+
+class BanEvent extends MembershipEvent {
+ static canRender(eventData) {
+ return super.canRender(eventData) && eventData.content.membership === "ban"
+ }
+
+ render() {
+ let message =
+ ` left (banned by ${this.data.sender}`
+ + (this.data.content.reason ? `, reason: ${this.data.content.reason}` : "")
+ + ")"
+ this.renderInner("static/leave-event.svg", [
+ this.smallAvatar,
+ this.senderName,
+ message
+ ])
+ }
+}
+
+class UnknownMembership extends MembershipEvent {
+ render() {
+ this.renderInner("", [
+ this.smallAvatar,
+ this.senderName,
+ ejs("i").text(" unknown membership event")
+ ])
+ }
+}
+
+module.exports = [JoinedEvent, InvitedEvent, LeaveEvent, BanEvent, UnknownMembership]
diff --git a/src/js/events/message.js b/src/js/events/message.js
new file mode 100644
index 0000000..ad885ff
--- /dev/null
+++ b/src/js/events/message.js
@@ -0,0 +1,164 @@
+const {ejs, ElemJS} = require("../basic")
+const {HighlightedCode} = require("./components")
+const DOMPurify = require("dompurify")
+const {resolveMxc} = require("../functions")
+const {GroupableEvent} = 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", "data-mx-spoiler"],
+ "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", "data-mx-spoiler"
+ ],
+
+ // 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(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
+ })
+
+ element.querySelectorAll("[data-mx-spoiler]").forEach(spoiler => {
+ spoiler.classList.add("mx-spoiler")
+ spoiler.setAttribute("tabindex", 0)
+ function toggle() {
+ spoiler.classList.toggle("mx-spoiler--shown")
+ }
+ spoiler.addEventListener("click", toggle)
+ spoiler.addEventListener("keydown", event => {
+ if (event.key === "Enter") toggle()
+ })
+ })
+}
+
+
+class HTMLMessage extends GroupableEvent {
+ render() {
+ this.clearChildren()
+
+ let html = this.data.content.formatted_body
+
+ const fragment = cleanHTML(html)
+ postProcessElements(fragment)
+
+ this.child(ejs(fragment))
+
+ 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
+ )
+ }
+}
+
+function autoLinkText(text) {
+ const fragment = ejs(new DocumentFragment())
+ let lastIndex = 0
+ text.replace(/https?:\/\/(?:[A-Za-z-]+\.)+[A-Za-z]{1,10}(?::[0-9]{1,6})?(?:\/[^ ]*)?/g, (url, index) => {
+ // add text before URL
+ fragment.addText(text.slice(lastIndex, index))
+ // add URL
+ fragment.child(
+ ejs("a")
+ .attribute("target", "_blank")
+ .attribute("noopener", "")
+ .attribute("href", url)
+ .addText(url)
+ )
+ // update state
+ lastIndex = index + url.length
+ })
+ // add final text
+ fragment.addText(text.slice(lastIndex))
+ return fragment
+}
+
+class TextMessage extends GroupableEvent {
+ render() {
+ this.clearChildren()
+ this.class("c-message--plain")
+ const fragment = autoLinkText(this.data.content.body)
+ this.child(fragment)
+ super.render()
+ }
+
+ static canRender(event) {
+ return event.type === "m.room.message"
+ }
+}
+
+module.exports = [HTMLMessage, TextMessage]
diff --git a/src/js/events/render-event.js b/src/js/events/render-event.js
new file mode 100644
index 0000000..620da3e
--- /dev/null
+++ b/src/js/events/render-event.js
@@ -0,0 +1,24 @@
+const imageEvent = require("./image")
+const messageEvent = require("./message")
+const encryptedEvent = require("./encrypted")
+const membershipEvent = require("./membership")
+const unknownEvent = require("./unknown")
+const callEvent = require("./call")
+const hiddenEvent = require("./hidden")
+
+const events = [
+ ...imageEvent,
+ ...messageEvent,
+ ...encryptedEvent,
+ ...membershipEvent,
+ ...callEvent,
+ ...hiddenEvent,
+ ...unknownEvent,
+]
+
+function renderEvent(eventData) {
+ const constructor = events.find(e => e.canRender(eventData))
+ return new constructor(eventData)
+}
+
+module.exports = {renderEvent}
diff --git a/src/js/events/unknown.js b/src/js/events/unknown.js
new file mode 100644
index 0000000..5133aa8
--- /dev/null
+++ b/src/js/events/unknown.js
@@ -0,0 +1,19 @@
+const {GroupableEvent} = require("./event")
+const {ejs} = require("../basic")
+
+class UnknownEvent extends GroupableEvent {
+ static canRender() {
+ return true
+ }
+
+ render() {
+ this.clearChildren()
+ this.child(
+ ejs("i").text(`Unknown event of type ${this.data.type}`)
+ )
+ super.render()
+ }
+}
+
+module.exports = [UnknownEvent]
+
diff --git a/src/js/focus.js b/src/js/focus.js
new file mode 100644
index 0000000..2413484
--- /dev/null
+++ b/src/js/focus.js
@@ -0,0 +1,11 @@
+document.body.classList.remove("show-focus")
+
+document.addEventListener("mousedown", () => {
+ document.body.classList.remove("show-focus")
+})
+
+document.addEventListener("keydown", event => {
+ if (event.key === "Tab") {
+ document.body.classList.add("show-focus")
+ }
+})
diff --git a/src/js/functions.js b/src/js/functions.js
index 299d8a9..3c346c4 100644
--- a/src/js/functions.js
+++ b/src/js/functions.js
@@ -1,7 +1,10 @@
-import * as lsm from $to_relative "/js/lsm.js"
+const lsm = require("./lsm.js")
function resolveMxc(url, size, method) {
- const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1)
+ const match = url.match(/^mxc:\/\/([^/]+)\/(.*)/)
+ if (!match) return url
+ let [server, id] = match.slice(1)
+ id = id.replace(/#.*$/, "")
if (size && method) {
return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
} else {
@@ -9,4 +12,28 @@ function resolveMxc(url, size, method) {
}
}
-export {resolveMxc}
+function extractLocalpart(mxid) {
+ // try to extract the localpart from the mxid
+ let match = mxid.match(/^@([^:]+):/)
+ if (match) {
+ return match[1]
+ }
+ // localpart extraction failed, use the whole mxid
+ return mxid
+}
+
+function extractDisplayName(stateEvent) {
+ const mxid = stateEvent.state_key
+ // see if a display name is set
+ if (stateEvent.content.displayname) {
+ return stateEvent.content.displayname
+ }
+ // fall back to the mxid
+ return extractLocalpart(mxid)
+}
+
+module.exports = {
+ resolveMxc,
+ extractLocalpart,
+ extractDisplayName
+}
diff --git a/src/js/groups.js b/src/js/groups.js
index e49ade4..38b6706 100644
--- a/src/js/groups.js
+++ b/src/js/groups.js
@@ -1,4 +1,4 @@
-import {q} from $to_relative "/js/basic.js"
+const {q} = require("./basic.js")
let state = "CLOSED"
diff --git a/src/js/lazy-load-module.js b/src/js/lazy-load-module.js
new file mode 100644
index 0000000..efe642d
--- /dev/null
+++ b/src/js/lazy-load-module.js
@@ -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}
diff --git a/src/js/login.js b/src/js/login.js
new file mode 100644
index 0000000..4ae9eae
--- /dev/null
+++ b/src/js/login.js
@@ -0,0 +1,167 @@
+const {q, ElemJS, ejs} = require("./basic.js")
+
+const password = q("#password")
+const homeserver = q("#homeserver")
+
+class Username extends ElemJS {
+ constructor() {
+ super(q("#username"))
+
+ this.on("change", this.updateServer.bind(this))
+ }
+
+ isValid() {
+ return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/)
+ }
+
+ getUsername() {
+ return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1]
+ }
+
+ getServer() {
+ const server = this.element.value.match(/^@?[a-z0-9._=\?-]+:([a-zA-Z0-9.:\[\]-]+)$/)
+ if (server && server[1]) return server[1]
+ else return null
+ }
+
+ updateServer() {
+ if (!this.isValid()) return
+ if (this.getServer()) homeserver.value = this.getServer()
+ }
+}
+
+const username = new Username()
+
+class Feedback extends ElemJS {
+ constructor() {
+ super(q("#feedback"))
+ this.loading = false
+ this.loadingIcon = ejs("span").class("loading-icon")
+ this.messageSpan = ejs("span")
+ this.child(this.messageSpan)
+ }
+
+ setLoading(state) {
+ if (this.loading && !state) {
+ this.loadingIcon.remove()
+ } else if (!this.loading && state) {
+ this.childAt(0, this.loadingIcon)
+ }
+ this.loading = state
+ }
+
+ message(content, isError) {
+ this.removeClass("form-feedback")
+ this.removeClass("form-error")
+ if (content) this.class("form-feedback")
+ if(isError) this.class("form-error")
+
+ this.messageSpan.text(content)
+ }
+}
+
+const feedback = new Feedback()
+
+class Form extends ElemJS {
+ constructor() {
+ super(q("#form"))
+
+ this.processing = false
+
+ this.on("submit", this.submit.bind(this))
+ }
+
+ async submit() {
+ if (this.processing) return
+ this.processing = true
+ if (!username.isValid()) return this.cancel("Username is not valid.")
+
+ // Resolve homeserver address
+ let domain
+ try {
+ domain = await this.findHomeserver(homeserver.value)
+ } catch(e) {
+ return this.cancel(e.message)
+ }
+
+ // Request access token
+ this.status("Logging in...")
+ const root = await fetch(`${domain}/_matrix/client/r0/login`, {
+ method: "POST",
+ body: JSON.stringify({
+ type: "m.login.password",
+ user: username.getUsername(),
+ password: password.value
+ })
+ }).then(res => res.json())
+
+ if (!root.access_token) {
+ if (root.error) {
+ this.cancel(`Server said: ${root.error}`)
+ } else {
+ this.cancel("Login mysteriously failed.")
+ console.error(root)
+ }
+ return
+ }
+
+ localStorage.setItem("mx_user_id", root.user_id)
+ localStorage.setItem("domain", domain)
+ localStorage.setItem("access_token", root.access_token)
+
+ location.assign("../")
+ }
+
+ async findHomeserver(address, maxDepth = 5) {
+
+ //Protects from servers sending us on a redirect loop
+ maxDepth--
+ if (maxDepth <= 0) throw new Error(`Failed to look up homeserver, maximum search depth reached`)
+
+ //Normalise the address
+ if (!address.match(/^https?:\/\//)) {
+ console.warn(`${address} doesn't specify the protocol, assuming https`)
+ address = "https://" + address
+ }
+ address = address.replace(/\/*$/, "")
+
+ this.status(`Looking up homeserver... trying ${address}`)
+
+ // Check if we found the actual matrix server
+ try {
+ const versionsReq = await fetch(`${address}/_matrix/client/versions`)
+ if (versionsReq.ok) {
+ const versions = await versionsReq.json()
+ if (Array.isArray(versions.versions)) return address
+ }
+ } catch(e) {}
+
+ // Find the next matrix server in the chain
+ const root = await fetch(`${address}/.well-known/matrix/client`).then(res => res.json()).catch(e => {
+ console.error(e)
+ throw new Error(`Failed to look up server ${address}`)
+ })
+
+ let nextAddress = root["m.homeserver"].base_url
+ nextAddress = nextAddress.replace(/\/*$/, "")
+
+ if (address === nextAddress) {
+ throw new Error(`Failed to look up server ${address}, /.well-known/matrix/client found a redirect loop`);
+ }
+
+ return this.findHomeserver(nextAddress, maxDepth)
+ }
+
+ status(message) {
+ feedback.setLoading(true)
+ feedback.message(message)
+ }
+
+ cancel(message) {
+ this.processing = false
+ feedback.setLoading(false)
+ feedback.message(message, true)
+ }
+}
+
+const form = new Form()
diff --git a/src/js/lsm.js b/src/js/lsm.js
index 7338343..7e9ad4d 100644
--- a/src/js/lsm.js
+++ b/src/js/lsm.js
@@ -8,4 +8,4 @@ function set(name, value) {
window.lsm = {get, set}
-export {get, set}
+module.exports = {get, set}
diff --git a/src/js/main.js b/src/js/main.js
new file mode 100644
index 0000000..1bc0be0
--- /dev/null
+++ b/src/js/main.js
@@ -0,0 +1,11 @@
+require("./focus.js")
+const groups = require("./groups.js")
+const chat_input = require("./chat-input.js")
+const room_picker = require("./room-picker.js")
+const sync = require("./sync/sync.js")
+const chat = require("./chat.js")
+require("./typing.js")
+
+if (!localStorage.getItem("access_token")) {
+ location.assign("./login/")
+}
diff --git a/src/js/read-marker.js b/src/js/read-marker.js
new file mode 100644
index 0000000..53b4658
--- /dev/null
+++ b/src/js/read-marker.js
@@ -0,0 +1,149 @@
+const {ElemJS, ejs, q} = require("./basic.js")
+const {store} = require("./store/store.js")
+const lsm = require("./lsm.js")
+
+function markFullyRead(roomID, eventID) {
+ return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, {
+ method: "POST",
+ body: JSON.stringify({
+ "m.fully_read": eventID,
+ "m.read": eventID
+ })
+ })
+}
+
+class ReadBanner extends ElemJS {
+ constructor() {
+ super(q("#c-chat-banner"))
+
+ this.newMessages = ejs("span")
+ this.child(
+ ejs("div").class("c-chat-banner__inner").child(
+ ejs("button").class("c-chat-banner__part").on("click", this.jumpTo.bind(this)).child(
+ ejs("div").class("c-chat-banner__part-inner")
+ .child(this.newMessages)
+ .addText(" new messages")
+ ),
+ ejs("button").class("c-chat-banner__part", "c-chat-banner__last").on("click", this.markRead.bind(this)).child(
+ ejs("div").class("c-chat-banner__part-inner").text("Mark as read")
+ )
+ )
+ )
+
+ store.activeRoom.subscribe("changeSelf", this.render.bind(this))
+ store.notificationsChange.subscribe("changeSelf", this.render.bind(this))
+ this.render()
+ }
+
+ async jumpTo() {
+ if (!store.activeRoom.exists()) return
+ const timeline = store.activeRoom.value().timeline
+ const readMarker = timeline.readMarker
+ while (true) {
+ if (readMarker.attached) {
+ readMarker.element.scrollIntoView({behavior: "smooth", block: "center"})
+ return
+ } else {
+ q("#c-chat-messages").scrollTo({
+ top: 0,
+ left: 0,
+ behavior: "smooth"
+ })
+ await new Promise(resolve => {
+ const unsubscribe = timeline.subscribe("afterScrollbackLoad", () => {
+ unsubscribe()
+ resolve()
+ })
+ })
+ }
+ }
+ }
+
+ markRead() {
+ if (!store.activeRoom.exists()) return
+ const timeline = store.activeRoom.value().timeline
+ markFullyRead(timeline.id, timeline.latestEventID)
+ }
+
+ render() {
+ let count = 0
+ if (store.activeRoom.exists()) {
+ count = store.activeRoom.value().number.state.unreads
+ }
+ if (count !== 0) {
+ this.newMessages.text(count)
+ this.class("c-chat-banner--active")
+ } else {
+ this.removeClass("c-chat-banner--active")
+ }
+ }
+}
+const readBanner = new ReadBanner()
+
+class ReadMarker extends ElemJS {
+ constructor(timeline) {
+ super("div")
+
+ this.class("c-read-marker")
+ this.loadingIcon = ejs("div")
+ .class("c-read-marker__loading", "loading-icon")
+ .style("display", "none")
+ this.child(
+ ejs("div").class("c-read-marker__inner").child(
+ ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New")
+ )
+ )
+
+ let processing = false
+ const observer = new IntersectionObserver(entries => {
+ const entry = entries[0]
+ if (!entry.isIntersecting) return
+ if (processing) return
+ processing = true
+ this.loadingIcon.style("display", "")
+ markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => {
+ this.loadingIcon.style("display", "none")
+ processing = false
+ })
+ }, {
+ root: document.getElementById("c-chat-messages"),
+ rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read
+ threshold: 0.01
+ })
+ observer.observe(this.element)
+
+ this.attached = false
+ this.timeline = timeline
+ this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => {
+ // read marker updated, attach to it
+ const event = this.timeline.map.get(eventID)
+ this.attach(event)
+ })
+ this.timeline.subscribe("afterChange", () => {
+ // timeline has new events, attach to last read one
+ const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value()
+ const event = this.timeline.map.get(eventID)
+ this.attach(event)
+ })
+ }
+
+ attach(event) {
+ if (event && event.data.origin_server_ts !== this.timeline.latest) {
+ this.class("c-read-marker--attached")
+ event.element.insertAdjacentElement("beforeend", this.element)
+ this.attached = true
+ } else {
+ this.removeClass("c-read-marker--attached")
+ this.attached = false
+ }
+ if (store.activeRoom.value() === this.timeline.room) {
+ readBanner.render()
+ }
+ }
+}
+
+module.exports = {
+ ReadMarker,
+ readBanner,
+ markFullyRead
+}
diff --git a/src/js/room-picker.js b/src/js/room-picker.js
index 8e696de..9192ae9 100644
--- a/src/js/room-picker.js
+++ b/src/js/room-picker.js
@@ -1,10 +1,10 @@
-import {q, ElemJS, ejs} from $to_relative "/js/basic.js"
-import {store} from $to_relative "/js/store/store.js"
-import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
-import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
-import {Timeline} from $to_relative "/js/Timeline.js"
-import * as lsm from $to_relative "/js/lsm.js"
-import {resolveMxc} from $to_relative "/js/functions.js"
+const {q, ElemJS, ejs} = require("./basic.js")
+const {store} = require("./store/store.js")
+const {SubscribeMapList} = require("./store/subscribe_map_list.js")
+const {SubscribeValue} = require("./store/subscribe_value.js")
+const {Timeline} = require("./timeline.js")
+const lsm = require("./lsm.js")
+const {resolveMxc, extractLocalpart, extractDisplayName} = require("./functions.js")
class ActiveGroupMarker extends ElemJS {
constructor() {
@@ -25,12 +25,43 @@ class ActiveGroupMarker extends ElemJS {
const activeGroupMarker = new ActiveGroupMarker()
+class GroupNotifier extends ElemJS {
+ constructor() {
+ super("div")
+
+ this.class("c-group__number")
+ this.state = {}
+ this.render()
+ }
+
+ update(state) {
+ Object.assign(this.state, state)
+ this.render()
+ }
+
+ clear() {
+ this.state = {}
+ this.render()
+ }
+
+ render() {
+ let total = Object.values(this.state).reduce((a, c) => a + c, 0)
+ if (total > 0) {
+ this.text(total)
+ this.class("c-group__number--active")
+ } else {
+ this.removeClass("c-group__number--active")
+ }
+ }
+}
+
class Group extends ElemJS {
constructor(key, data) {
super("div")
this.data = data
this.order = this.data.order
+ this.number = new GroupNotifier()
this.class("c-group")
this.child(
@@ -38,6 +69,7 @@ class Group extends ElemJS {
? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
: ejs("div").class("c-group__icon")
),
+ this.number,
ejs("div").class("c-group__name").text(this.data.name)
)
@@ -56,12 +88,73 @@ class Group extends ElemJS {
}
}
+class RoomNotifier extends ElemJS {
+ constructor(room) {
+ super("div")
+
+ this.class("c-room__number")
+
+ this.room = room
+ this.classes = [
+ "notifications",
+ "unreads",
+ "none"
+ ]
+ this.state = {
+ notifications: 0,
+ unreads: 0
+ }
+ this.render()
+ }
+
+ /**
+ * @param {object} state
+ * @param {number} [state.notifications]
+ * @param {number} [state.unreads]
+ */
+ update(state) {
+ Object.assign(this.state, state)
+ this.informGroup()
+ this.render()
+ }
+
+ informGroup() {
+ this.room.getGroup().number.update({[this.room.id]: (
+ this.state.notifications || (this.state.unreads ? 1 : 0)
+ )})
+ }
+
+ render() {
+ const display = {
+ number: this.state.notifications || this.state.unreads,
+ kind: this.state.notifications ? "notifications" : "unreads"
+ }
+ // set number
+ if (display.number) {
+ this.text(display.number)
+ } else {
+ this.text("")
+ display.kind = "none"
+ }
+ // set class
+ this.classes.forEach(c => {
+ const name = "c-room__number--" + c
+ if (c === display.kind) {
+ this.class(name)
+ } else {
+ this.removeClass(name)
+ }
+ })
+ }
+}
+
class Room extends ElemJS {
constructor(id, data) {
super("div")
this.id = id
this.data = data
+ this.number = new RoomNotifier(this)
this.timeline = new Timeline(this)
this.group = null
this.members = new SubscribeMapList(SubscribeValue)
@@ -75,43 +168,80 @@ class Room extends ElemJS {
}
get order() {
- if (this.group) {
- let chars = 36
- let total = 0
- const name = this.getName()
- for (let i = 0; i < name.length; i++) {
- const c = name[i]
- let d = 0
- if (c >= "A" && c <= "Z") d = c.charCodeAt(0) - 65 + 10
- else if (c >= "a" && c <= "z") d = c.charCodeAt(0) - 97 + 10
- else if (c >= "0" && c <= "9") d = +c
- total += d * chars ** (-i)
- }
- return total
+ let string = ""
+ if (this.number.state.notifications) {
+ string += "N"
+ } else if (this.number.state.unreads) {
+ string += "U"
} else {
- return -this.timeline.latest
+ string += "_"
+ }
+ if (this.group) {
+ string += this.name
+ } else {
+ string += (4000000000000 - this.timeline.latest) // good until 2065 :)
+ }
+ return string
+ }
+
+ getMemberName(mxid) {
+ if (this.members.has(mxid)) {
+ const state = this.members.get(mxid).value()
+ return extractDisplayName(state)
+ } else {
+ return extractLocalpart(mxid)
+ }
+ }
+
+ getHeroes() {
+ if (this.data.summary) {
+ return this.data.summary["m.heroes"]
+ } else {
+ const me = lsm.get("mx_user_id")
+ return this.data.state.events.filter(e => e.type === "m.room.member" && e.content.membership === "join" && e.state_key !== me).map(e => e.state_key)
}
}
getName() {
+ // if the room has a name
let name = this.data.state.events.find(e => e.type === "m.room.name")
- if (name) {
- name = name.content.name
- } else {
- const users = this.data.summary["m.heroes"]
- const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
- name = usernames.join(", ")
+ if (name && name.content.name) {
+ return name.content.name
}
- return name
+ // if the room has no name, use its canonical alias
+ let canonicalAlias = this.data.state.events.find(e => e.type === "m.room.canonical_alias")
+ if (canonicalAlias && canonicalAlias.content.alias) {
+ return canonicalAlias.content.alias
+ }
+ // if the room has no alias, use the names of its members ("heroes")
+ const users = this.getHeroes()
+ if (users && users.length) {
+ const usernames = users.map(mxid => this.getMemberName(mxid))
+ return usernames.join(", ")
+ }
+ // the room is empty
+ return "Empty room"
}
getIcon() {
+ // if the room has a normal avatar
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
if (avatar) {
- return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop")
- } else {
- return null
+ const url = avatar.content.url || avatar.content.avatar_url
+ if (url) {
+ return resolveMxc(url, 32, "crop")
+ }
}
+ // if the room has no avatar set, use a member's avatar
+ const users = this.getHeroes()
+ if (users && users[0] && this.members.has(users[0])) {
+ // console.log(users[0], this.members.get(users[0]))
+ const userAvatar = this.members.get(users[0]).value().content.avatar_url
+ if (userAvatar) {
+ return resolveMxc(userAvatar, 32, "crop")
+ }
+ }
+ return null
}
isDirect() {
@@ -144,6 +274,7 @@ class Room extends ElemJS {
this.child(ejs("div").class("c-room__icon", "c-room__icon--no-icon"))
}
this.child(ejs("div").class("c-room__name").text(this.getName()))
+ this.child(this.number)
// active
const active = store.activeRoom.value() === this
this.element.classList[active ? "add" : "remove"]("c-room--active")
@@ -163,6 +294,7 @@ class Rooms extends ElemJS {
store.activeGroup.subscribe("changeSelf", this.render.bind(this))
store.directs.subscribe("changeItem", this.render.bind(this))
store.newEvents.subscribe("changeSelf", this.sort.bind(this))
+ store.notificationsChange.subscribe("changeSelf", this.sort.bind(this))
this.render()
}
@@ -223,8 +355,12 @@ class Groups extends ElemJS {
render() {
this.clearChildren()
store.groups.forEach((key, item) => {
+ item.value().number.clear()
this.child(item.value())
})
+ store.rooms.forEach((id, room) => {
+ room.value().number.informGroup() // update group notification number
+ })
}
}
const groups = new Groups()
diff --git a/src/js/sender.js b/src/js/sender.js
new file mode 100644
index 0000000..55943dc
--- /dev/null
+++ b/src/js/sender.js
@@ -0,0 +1,120 @@
+const {ElemJS, ejs} = require("./basic.js")
+const {store} = require("./store/store.js")
+const {resolveMxc} = require("./functions.js")
+
+function nameToColor(str) {
+ // code from element's react sdk
+ const colors = ["#55a7f0", "#da55ff", "#1bc47c", "#ea657e", "#fd8637", "#22cec6", "#8c8de3", "#71bf22"]
+ let hash = 0
+ let i
+ let chr
+ if (str.length === 0) {
+ return hash
+ }
+ for (i = 0; i < str.length; i++) {
+ chr = str.charCodeAt(i)
+ hash = ((hash << 5) - hash) + chr
+ hash |= 0
+ }
+ hash = Math.abs(hash) % 8
+ return colors[hash]
+}
+
+class Avatar extends ElemJS {
+ constructor() {
+ super("div")
+ this.class("c-message-group__avatar")
+
+ this.mxc = undefined
+ this.image = null
+
+ this.update(null)
+ }
+
+ update(mxc) {
+ if (mxc === this.mxc) return
+ this.mxc = mxc
+ this.hasImage = !!mxc
+ if (this.hasImage) {
+ const size = 96
+ const url = resolveMxc(mxc, size, "crop")
+ this.image = ejs("img").class("c-message-group__icon").attribute("src", url).attribute("width", size).attribute("height", size)
+ this.image.on("error", this.onError.bind(this))
+ }
+ this.render()
+ }
+
+ onError() {
+ this.hasImage = false
+ this.render()
+ }
+
+ render() {
+ this.clearChildren()
+ if (this.hasImage) {
+ this.child(this.image)
+ } else {
+ this.child(
+ ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
+ )
+ }
+ }
+}
+
+/** Must update at least once to render. */
+class Name extends ElemJS {
+ constructor() {
+ super("div")
+ this.class("c-message-group__name")
+
+ /**
+ * Keeps track of whether we have the proper display name or not.
+ * If we do, then we shoudn't override it with the mxid if the name becomes unavailable.
+ */
+ this.hasName = false
+ this.name = ""
+ this.mxid = ""
+ }
+
+ update(event) {
+ this.mxid = event.state_key
+ if (event.content.displayname) {
+ this.hasName = true
+ this.name = event.content.displayname
+ } else if (!this.hasName) {
+ this.name = this.mxid
+ }
+ this.render()
+ }
+
+ render() {
+ // set text
+ this.text(this.name)
+ // set color
+ this.style("color", nameToColor(this.mxid))
+ }
+}
+
+class Sender {
+ constructor(roomID, mxid) {
+ this.sender = store.rooms.get(roomID).value().members.get(mxid)
+ this.name = new Name()
+ this.avatar = new Avatar()
+ this.sender.subscribe("changeSelf", this.update.bind(this))
+ this.update()
+ }
+
+ update() {
+ if (this.sender.exists()) {
+ // name
+ this.name.update(this.sender.value())
+
+ // avatar
+ this.avatar.update(this.sender.value().content.avatar_url)
+ }
+ }
+}
+
+module.exports = {
+ Sender
+}
diff --git a/src/js/store/SubscribeMap.js b/src/js/store/SubscribeMap.js
deleted file mode 100644
index 8b0dc0c..0000000
--- a/src/js/store/SubscribeMap.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import {Subscribable} from $to_relative "/js/store/Subscribable.js"
-import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
-
-class SubscribeMap extends Subscribable {
- constructor() {
- super()
- Object.assign(this.events, {
- addItem: [],
- changeItem: [],
- removeItem: []
- })
- this.map = new Map()
- }
-
- has(key) {
- return this.map.has(key) && this.map.get(key).exists()
- }
-
- get(key) {
- if (this.map.has(key)) {
- return this.map.get(key)
- } else {
- this.map.set(key, new SubscribeValue())
- }
- }
-
- set(key, value) {
- let s
- if (this.map.has(key)) {
- s = this.map.get(key).set(value)
- this.broadcast("changeItem", key)
- } else {
- s = new SubscribeValue().set(value)
- this.map.set(key, s)
- this.broadcast("addItem", key)
- }
- return s
- }
-}
-
-export {SubscribeMap}
diff --git a/src/js/store/store.js b/src/js/store/store.js
index 1c0552d..8ef4511 100644
--- a/src/js/store/store.js
+++ b/src/js/store/store.js
@@ -1,7 +1,7 @@
-import {Subscribable} from $to_relative "/js/store/Subscribable.js"
-import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
-import {SubscribeSet} from $to_relative "/js/store/SubscribeSet.js"
-import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
+const {Subscribable} = require("./subscribable.js")
+const {SubscribeMapList} = require("./subscribe_map_list.js")
+const {SubscribeSet} = require("./subscribe_set.js")
+const {SubscribeValue} = require("./subscribe_value.js")
const store = {
groups: new SubscribeMapList(SubscribeValue),
@@ -9,9 +9,10 @@ const store = {
directs: new SubscribeSet(),
activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(),
- newEvents: new Subscribable()
+ newEvents: new Subscribable(),
+ notificationsChange: new Subscribable()
}
window.store = store
-export {store}
+module.exports = {store}
diff --git a/src/js/store/Subscribable.js b/src/js/store/subscribable.js
similarity index 86%
rename from src/js/store/Subscribable.js
rename to src/js/store/subscribable.js
index 6c7640e..56bf971 100644
--- a/src/js/store/Subscribable.js
+++ b/src/js/store/subscribable.js
@@ -20,6 +20,8 @@ class Subscribable {
} else {
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
}
+ // return a function we can call to easily unsubscribe
+ return () => this.unsubscribe(event, callback)
}
unsubscribe(event, callback) {
@@ -35,4 +37,4 @@ class Subscribable {
}
}
-export {Subscribable}
+module.exports = {Subscribable}
diff --git a/src/js/store/subscribe_map.js b/src/js/store/subscribe_map.js
new file mode 100644
index 0000000..6159597
--- /dev/null
+++ b/src/js/store/subscribe_map.js
@@ -0,0 +1,74 @@
+const {Subscribable} = require("./subscribable.js")
+
+class SubscribeMap extends Subscribable {
+ constructor(inner) {
+ super()
+ this.inner = inner
+ Object.assign(this.events, {
+ addItem: [],
+ editItem: [],
+ deleteItem: [],
+ changeItem: [],
+ askSet: []
+ })
+ Object.assign(this.eventDeps, {
+ addItem: ["changeItem"],
+ editItem: ["changeItem"],
+ deleteItem: ["changeItem"],
+ changeItem: [],
+ askSet: []
+ })
+ this.map = new Map()
+ }
+
+ has(key) {
+ return this.map.has(key) && this.map.get(key).exists()
+ }
+
+ get(key) {
+ if (this.map.has(key)) {
+ return this.map.get(key)
+ } else {
+ const item = new this.inner()
+ this.map.set(key, item)
+ return item
+ }
+ }
+
+ forEach(f) {
+ for (const entry of this.map.entries()) {
+ f(entry[0], entry[1])
+ }
+ }
+
+ askSet(key, value) {
+ this.broadcast("askSet", {key, value})
+ }
+
+ set(key, value) {
+ let s
+ if (this.map.has(key)) {
+ const exists = this.map.get(key).exists()
+ s = this.map.get(key).set(value)
+ if (exists) {
+ this.broadcast("editItem", key)
+ } else {
+ this.broadcast("addItem", key)
+ }
+ } else {
+ s = new this.inner().set(value)
+ this.map.set(key, s)
+ this.broadcast("addItem", key)
+ }
+ return s
+ }
+
+ delete(key) {
+ if (this.backing.has(key)) {
+ this.backing.delete(key)
+ this.broadcast("deleteItem", key)
+ }
+ }
+}
+
+module.exports = {SubscribeMap}
diff --git a/src/js/store/SubscribeMapList.js b/src/js/store/subscribe_map_list.js
similarity index 72%
rename from src/js/store/SubscribeMapList.js
rename to src/js/store/subscribe_map_list.js
index 1883303..794a8da 100644
--- a/src/js/store/SubscribeMapList.js
+++ b/src/js/store/subscribe_map_list.js
@@ -1,5 +1,5 @@
-import {Subscribable} from $to_relative "/js/store/Subscribable.js"
-import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
+const {Subscribable} = require("./subscribable.js")
+const {SubscribeValue} = require("./subscribe_value.js")
class SubscribeMapList extends Subscribable {
constructor(inner) {
@@ -54,6 +54,15 @@ class SubscribeMapList extends Subscribable {
}
sort() {
+ const key = this.list[0]
+ if (typeof this.map.get(key).value().order === "number") {
+ this.sortByNumber()
+ } else {
+ this.sortByString()
+ }
+ }
+
+ sortByNumber() {
this.list.sort((a, b) => {
const orderA = this.map.get(a).value().order
const orderB = this.map.get(b).value().order
@@ -62,6 +71,17 @@ class SubscribeMapList extends Subscribable {
this.broadcast("changeItem")
}
+ sortByString() {
+ this.list.sort((a, b) => {
+ const orderA = this.map.get(a).value().order
+ const orderB = this.map.get(b).value().order
+ if (orderA < orderB) return -1
+ else if (orderA > orderB) return 1
+ else return 0
+ })
+ this.broadcast("changeItem")
+ }
+
_add(key, value, start) {
let s
if (this.map.has(key)) {
@@ -83,4 +103,4 @@ class SubscribeMapList extends Subscribable {
}
}
-export {SubscribeMapList}
+module.exports = {SubscribeMapList}
diff --git a/src/js/store/SubscribeSet.js b/src/js/store/subscribe_set.js
similarity index 88%
rename from src/js/store/SubscribeSet.js
rename to src/js/store/subscribe_set.js
index 789aaaf..32c758c 100644
--- a/src/js/store/SubscribeSet.js
+++ b/src/js/store/subscribe_set.js
@@ -1,4 +1,4 @@
-import {Subscribable} from $to_relative "/js/store/Subscribable.js"
+const {Subscribable} = require("./subscribable.js")
class SubscribeSet extends Subscribable {
constructor() {
@@ -47,4 +47,4 @@ class SubscribeSet extends Subscribable {
}
}
-export {SubscribeSet}
+module.exports = {SubscribeSet}
diff --git a/src/js/store/SubscribeValue.js b/src/js/store/subscribe_value.js
similarity index 85%
rename from src/js/store/SubscribeValue.js
rename to src/js/store/subscribe_value.js
index 6657e27..9c71959 100644
--- a/src/js/store/SubscribeValue.js
+++ b/src/js/store/subscribe_value.js
@@ -1,4 +1,4 @@
-import {Subscribable} from $to_relative "/js/store/Subscribable.js"
+const {Subscribable} = require("./subscribable.js")
class SubscribeValue extends Subscribable {
constructor() {
@@ -30,7 +30,7 @@ class SubscribeValue extends Subscribable {
edit(f) {
if (this.exists()) {
- f(this.data)
+ this.data = f(this.data)
this.set(this.data)
} else {
throw new Error("Tried to edit a SubscribeValue that had no value")
@@ -44,4 +44,4 @@ class SubscribeValue extends Subscribable {
}
}
-export {SubscribeValue}
+module.exports = {SubscribeValue}
diff --git a/src/js/sync/sync.js b/src/js/sync/sync.js
index bbd6b11..f8bfc52 100644
--- a/src/js/sync/sync.js
+++ b/src/js/sync/sync.js
@@ -1,6 +1,6 @@
-import {store} from $to_relative "/js/store/store.js"
-import * as lsm from $to_relative "/js/lsm.js"
-import {resolveMxc} from $to_relative "/js/functions.js"
+const {store} = require("../store/store.js")
+const lsm = require("../lsm.js")
+const {resolveMxc} = require("../functions.js")
let lastBatch = null
@@ -11,7 +11,7 @@ function sync() {
room: {
// pulling more from the timeline massively increases download size
timeline: {
- limit: 5
+ limit: 1
},
// members are not currently needed
state: {
@@ -37,63 +37,88 @@ function sync() {
function manageSync(root) {
try {
let newEvents = false
+ let notificationsChange = false
// set up directs
- const directs = root.account_data.events.find(e => e.type === "m.direct")
- if (directs) {
- Object.values(directs.content).forEach(ids => {
- ids.forEach(id => store.directs.add(id))
- })
+ if (root.account_data) {
+ const directs = root.account_data.events.find(e => e.type === "m.direct")
+ if (directs) {
+ Object.values(directs.content).forEach(ids => {
+ ids.forEach(id => store.directs.add(id))
+ })
+ }
}
// set up rooms
- Object.entries(root.rooms.join).forEach(([id, data]) => {
- if (!store.rooms.has(id)) {
- store.rooms.askAdd(id, data)
+ if (root.rooms) {
+ if (root.rooms.join) {
+ Object.entries(root.rooms.join).forEach(([id, data]) => {
+ if (!store.rooms.has(id)) {
+ store.rooms.askAdd(id, data)
+ }
+ const room = store.rooms.get(id).value()
+ const timeline = room.timeline
+ if (data.state && data.state.events) timeline.updateStateEvents(data.state.events)
+ if (data.timeline && data.timeline.events) {
+ if (!timeline.from) timeline.from = data.timeline.prev_batch
+ if (data.timeline.events.length) {
+ newEvents = true
+ timeline.updateEvents(data.timeline.events)
+ }
+ }
+ if (data.ephemeral && data.ephemeral.events) timeline.updateEphemeral(data.ephemeral.events)
+ if (data.unread_notifications) {
+ timeline.updateNotificationCount(data.unread_notifications.notification_count)
+ notificationsChange = true
+ }
+ if (data["org.matrix.msc2654.unread_count"] != undefined) {
+ timeline.updateUnreadCount(data["org.matrix.msc2654.unread_count"])
+ notificationsChange = true
+ }
+ })
}
- const room = store.rooms.get(id).value()
- const timeline = room.timeline
- if (data.timeline.events.length) newEvents = true
- timeline.updateStateEvents(data.state.events)
- timeline.updateEvents(data.timeline.events)
- })
+ }
// set up groups
- Promise.all(
- Object.keys(root.groups.join).map(id => {
- if (!store.groups.has(id)) {
- return Promise.all(["profile", "rooms"].map(path => {
- const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
- url.searchParams.append("access_token", lsm.get("access_token"))
- return fetch(url.toString()).then(res => res.json())
- })).then(([profile, rooms]) => {
- rooms = rooms.chunk
- let order = 999
- let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
- if (orderEvent) {
- if (orderEvent.content.tags.includes(id)) {
- order = orderEvent.content.tags.indexOf(id)
+ if (root.groups) {
+ Promise.all(
+ Object.keys(root.groups.join).map(id => {
+ if (!store.groups.has(id)) {
+ return Promise.all(["profile", "rooms"].map(path => {
+ const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
+ url.searchParams.append("access_token", lsm.get("access_token"))
+ return fetch(url.toString()).then(res => res.json())
+ })).then(([profile, rooms]) => {
+ rooms = rooms.chunk
+ let order = 999
+ let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
+ if (orderEvent) {
+ if (orderEvent.content.tags.includes(id)) {
+ order = orderEvent.content.tags.indexOf(id)
+ }
}
- }
- const data = {
- name: profile.name,
- icon: resolveMxc(profile.avatar_url, 96, "crop"),
- order
- }
- store.groups.askAdd(id, data)
- rooms.forEach(groupRoom => {
- if (store.rooms.has(groupRoom.room_id)) {
- store.rooms.get(groupRoom.room_id).value().setGroup(id)
+ const data = {
+ name: profile.name,
+ icon: resolveMxc(profile.avatar_url, 96, "crop"),
+ order
}
+ store.groups.askAdd(id, data)
+ rooms.forEach(groupRoom => {
+ if (store.rooms.has(groupRoom.room_id)) {
+ store.rooms.get(groupRoom.room_id).value().setGroup(id)
+ }
+ })
+ store.newEvents.broadcast("changeSelf") // trigger a room list update
})
- store.newEvents.broadcast("changeSelf") // trigger a room list update
- })
- }
+ }
+ })
+ ).then(() => {
+ store.rooms.sort()
})
- ).then(() => {
- store.rooms.sort()
- })
+ }
+
if (newEvents) store.newEvents.broadcast("changeSelf")
+ if (notificationsChange) store.notificationsChange.broadcast("changeSelf")
} catch (e) {
console.error(root)
throw e
@@ -121,4 +146,6 @@ function syncLoop() {
store.activeGroup.set(store.groups.get("directs").value())
-syncLoop()
+if (lsm.get("access_token")) {
+ syncLoop()
+}
diff --git a/src/js/timeline.js b/src/js/timeline.js
new file mode 100644
index 0000000..0a36d14
--- /dev/null
+++ b/src/js/timeline.js
@@ -0,0 +1,408 @@
+const {ElemJS, ejs, q} = require("./basic.js")
+const {Subscribable} = require("./store/subscribable.js")
+const {SubscribeValue} = require("./store/subscribe_value.js")
+const {SubscribeMap} = require("./store/subscribe_map.js")
+const {store} = require("./store/store.js")
+const {Anchor} = require("./anchor.js")
+const {Sender} = require("./sender.js")
+const {ReadMarker, markFullyRead} = require("./read-marker.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")
+
+let sentIndex = 0
+
+function getTxnId() {
+ return Date.now() + (sentIndex++)
+}
+
+function eventSearch(list, event, min = 0, max = NO_MAX) {
+ if (list.length === 0) return {success: false, i: 0}
+
+ if (max === NO_MAX) max = list.length - 1
+ let mid = Math.floor((max + min) / 2)
+ // success condition
+ if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
+ // failed condition
+ if (min >= max) {
+ while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
+ return {
+ success: false,
+ i: mid + 1
+ }
+ }
+ // recurse (below)
+ if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid - 1)
+ // recurse (above)
+ else return eventSearch(list, event, mid + 1, max)
+}
+
+class EventGroup extends ElemJS {
+ constructor(reactive, list) {
+ super("div")
+ this.class("c-message-group")
+ this.reactive = reactive
+ this.list = list
+ this.data = {
+ sender: list[0].data.sender,
+ origin_server_ts: list[0].data.origin_server_ts
+ }
+ this.sender = new Sender(this.reactive.id, this.data.sender)
+ this.child(
+ this.sender.avatar,
+ this.messages = ejs("div").class("c-message-group__messages").child(
+ ejs("div").class("c-message-group__intro").child(
+ this.sender.name,
+ ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts))
+ ),
+ ...this.list
+ )
+ )
+ }
+
+ canGroup() {
+ if (this.list.length) return this.list[0].canGroup()
+ else return true
+ }
+
+ addEvent(event) {
+ const index = eventSearch(this.list, event).i
+ event.setGroup(this)
+ this.list.splice(index, 0, event)
+ this.messages.childAt(index + 1, event)
+ }
+
+ removeEvent(event) {
+ const search = eventSearch(this.list, event)
+ if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
+ const index = search.i
+ // actually remove the event
+ this.list.splice(index, 1)
+ event.remove() // should get everything else
+ if (this.list.length === 0) this.reactive.removeGroup(this)
+ }
+}
+
+/** Displays a spinner and creates an event to notify timeline to load more messages */
+class LoadMore extends ElemJS {
+ constructor(id) {
+ super("div")
+ this.class("c-message-notice")
+ this.id = id
+
+ this.child(
+ ejs("div").class("c-message-notice__inner").child(
+ ejs("span").class("loading-icon"),
+ ejs("span").text("Loading more...")
+ )
+ )
+ const intersection_observer = new IntersectionObserver(e => this.intersectionHandler(e))
+ intersection_observer.observe(this.element)
+ }
+
+ intersectionHandler(e) {
+ if (e.some(e => e.isIntersecting)) {
+ store.rooms.get(this.id).value().timeline.loadScrollback()
+ }
+ }
+}
+
+class ReactiveTimeline extends ElemJS {
+ constructor(id, list) {
+ super("div")
+ this.class("c-event-groups")
+ this.id = id
+ this.list = list
+ this.loadMore = new LoadMore(this.id)
+ this.render()
+ }
+
+ addEvent(event) {
+ this.loadMore.remove()
+ // if (debug) console.log("running search", this.list, event)
+ // if (debug) debugger;
+ const search = eventSearch(this.list, event)
+ // console.log(search, this.list.map(l => l.data.sender), event.data)
+ if (!search.success) {
+ if (search.i >= 1) {
+ // add at end
+ this.tryAddGroups(event, [search.i - 1, search.i])
+ } else {
+ // add at start
+ this.tryAddGroups(event, [0, -1])
+ }
+ } else {
+ this.tryAddGroups(event, [search.i])
+ }
+ this.loadMore = new LoadMore(this.id)
+ this.childAt(0, this.loadMore)
+ }
+
+ tryAddGroups(event, indices) {
+ const createGroupAt = i => {
+ // if (printed++ < 100) console.log("tryadd success, created group")
+ if (i === -1) {
+ // here, -1 means at the start, before the first group
+ i = 0 // jank but it does the trick
+ }
+ if (event.canGroup()) {
+ const group = new EventGroup(this, [event])
+ this.list.splice(i, 0, group)
+ this.childAt(i, group)
+ event.setGroup(group)
+ } else {
+ this.list.splice(i, 0, event)
+ this.childAt(i, event)
+ }
+ }
+ const success = indices.some(i => {
+ if (!this.list[i]) {
+ createGroupAt(i)
+ return true
+ } else if (event.canGroup() && this.list[i] && this.list[i].canGroup() && this.list[i].data.sender === event.data.sender) {
+ // if (printed++ < 100) console.log("tryadd success, using existing group")
+ this.list[i].addEvent(event)
+ return true
+ }
+ })
+ // if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data) // I believe all the bugs are now fixed. Lol.
+ if (!success) createGroupAt(indices[0])
+ }
+
+ removeGroup(group) {
+ const index = this.list.indexOf(group)
+ this.list.splice(index, 1)
+ group.remove() // should get everything else
+ }
+
+ render() {
+ this.clearChildren()
+ this.child(this.loadMore)
+ this.list.forEach(group => this.child(group))
+ this.anchor = new Anchor()
+ this.child(this.anchor)
+ }
+}
+
+class Timeline extends Subscribable {
+ constructor(room) {
+ super()
+ Object.assign(this.events, {
+ beforeChange: [],
+ afterChange: [],
+ beforeScrollbackLoad: [],
+ afterScrollbackLoad: [],
+ })
+ Object.assign(this.eventDeps, {
+ beforeChange: [],
+ afterChange: [],
+ beforeScrollbackLoad: [],
+ afterScrollbackLoad: [],
+ })
+ this.room = room
+ this.id = this.room.id
+ this.list = []
+ this.map = new Map()
+ this.reactiveTimeline = new ReactiveTimeline(this.id, [])
+ this.latest = 0
+ this.latestEventID = null
+ this.pending = new Set()
+ this.pendingEdits = []
+ this.typing = new SubscribeValue().set([])
+ this.userReads = new SubscribeMap(SubscribeValue)
+ this.readMarker = new ReadMarker(this)
+ this.from = null
+ }
+
+ updateStateEvents(events) {
+ for (const eventData of events) {
+ let id = eventData.event_id
+ if (eventData.type === "m.room.member") {
+ // update members
+ if (eventData.membership !== "leave") {
+ const member = this.room.members.get(eventData.state_key)
+ // only use the latest state
+ if (!member.exists() || eventData.origin_server_ts > member.data.origin_server_ts) {
+ member.set(eventData)
+ }
+ }
+ }
+ }
+ }
+
+ updateEvents(events) {
+ this.broadcast("beforeChange")
+ // handle state events
+ this.updateStateEvents(events)
+ for (const eventData of events) {
+ // set variables
+ let id = eventData.event_id
+ if (eventData.origin_server_ts > this.latest) {
+ this.latest = eventData.origin_server_ts
+ this.latestEventID = id
+ }
+ // handle local echoes
+ if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
+ const pendingID = eventData.content["chat.carbon.message.pending_id"]
+ if (id !== pendingID) {
+ const target = this.map.get(pendingID)
+ this.map.set(id, target)
+ this.map.delete(pendingID)
+ // update fully read marker - assume we have fully read up to messages we send
+ markFullyRead(this.id, id)
+ }
+ }
+ // handle timeline events
+ if (this.map.has(id)) {
+ // update existing event
+ this.map.get(id).update(eventData)
+ } else {
+ // skip redacted events
+ if (eventData.unsigned && eventData.unsigned.redacted_by) {
+ continue
+ }
+ // handle redactions
+ if (eventData.type === "m.room.redaction") {
+ if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent()
+ continue
+ }
+ // handle edits
+ if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") {
+ this.pendingEdits.push(eventData)
+ continue
+ }
+ // add new event
+ const event = renderEvent(eventData)
+ this.map.set(id, event)
+ this.reactiveTimeline.addEvent(event)
+ // update read receipt for sender on their own event
+ this.moveReadReceipt(eventData.sender, id)
+ }
+ }
+ // apply edits
+ this.pendingEdits = this.pendingEdits.filter(eventData => {
+ const replaces = eventData.content["m.relates_to"].event_id
+ if (this.map.has(replaces)) {
+ const event = this.map.get(replaces)
+ event.data.content = eventData.content["m.new_content"]
+ event.setEdited(eventData.origin_server_ts)
+ event.update(event.data)
+ return false // handled; remove from list
+ } else {
+ return true // we don't have the event it edits yet; keep in list
+ }
+ })
+ this.broadcast("afterChange")
+ }
+
+ updateEphemeral(events) {
+ for (const eventData of events) {
+ if (eventData.type === "m.typing") {
+ this.typing.set(eventData.content.user_ids)
+ }
+ if (eventData.type === "m.receipt") {
+ for (const eventID of Object.keys(eventData.content)) {
+ for (const user of Object.keys(eventData.content[eventID]["m.read"])) {
+ this.moveReadReceipt(user, eventID)
+ }
+ }
+ // console.log("Updated read receipts:", this.userReads)
+ }
+ }
+ }
+
+ moveReadReceipt(user, eventID) {
+ // check for a previous event to move from
+ const prev = this.userReads.get(user)
+ if (prev.exists()) {
+ const prevID = prev.value()
+ if (this.map.has(prevID) && this.map.has(eventID)) {
+ // ensure new message came later
+ if (this.map.get(eventID).data.origin_server_ts < this.map.get(prevID).data.origin_server_ts) return
+ this.map.get(prevID).readBy.delete(user)
+ }
+ }
+ // set on new message
+ this.userReads.set(user, eventID)
+ if (this.map.has(eventID)) this.map.get(eventID).readBy.add(user)
+ }
+
+ updateUnreadCount(count) {
+ this.room.number.update({unreads: count})
+ }
+
+ updateNotificationCount(count) {
+ this.room.number.update({notifications: count})
+ }
+
+ removeEvent(id) {
+ if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
+ this.map.get(id).removeEvent()
+ this.map.delete(id)
+ }
+
+ getTimeline() {
+ return this.reactiveTimeline
+ }
+
+ async loadScrollback() {
+ debug = true
+ if (!this.from) return // no more scrollback for this timeline
+ const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/messages`)
+ url.searchParams.set("access_token", lsm.get("access_token"))
+ url.searchParams.set("from", this.from)
+ url.searchParams.set("dir", "b")
+ url.searchParams.set("limit", "20")
+ const filter = {
+ lazy_load_members: true
+ }
+ url.searchParams.set("filter", JSON.stringify(filter))
+
+ const root = await fetch(url.toString()).then(res => res.json())
+
+ this.broadcast("beforeScrollbackLoad")
+
+ this.from = root.end
+ // console.log(this.updateEvents, root.chunk)
+ if (root.state) this.updateStateEvents(root.state)
+ if (root.chunk.length) {
+ // there are events to display
+ this.updateEvents(root.chunk)
+ }
+ if (!root.chunk.length || !root.end) {
+ // we reached the top of the scrollback
+ this.reactiveTimeline.loadMore.remove()
+ }
+ this.broadcast("afterScrollbackLoad")
+ }
+
+ send(type, content) {
+ const tx = getTxnId()
+ const id = `pending$${tx}`
+ this.pending.add(id)
+ content["chat.carbon.message.pending_id"] = id
+ const fakeEvent = {
+ type,
+ origin_server_ts: Date.now(),
+ event_id: id,
+ sender: lsm.get("mx_user_id"),
+ content,
+ pending: true
+ }
+ this.updateEvents([fakeEvent])
+ return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
+ method: "PUT",
+ body: JSON.stringify(content),
+ headers: {
+ "Content-Type": "application/json"
+ }
+ })
+ }
+}
+
+module.exports = {Timeline}
diff --git a/src/js/typing.js b/src/js/typing.js
new file mode 100644
index 0000000..b6208ef
--- /dev/null
+++ b/src/js/typing.js
@@ -0,0 +1,69 @@
+const {ElemJS, ejs, q} = require("./basic")
+const {store} = require("./store/store")
+const lsm = require("./lsm")
+
+/**
+ * Maximum number of typing users to display all names for.
+ * More will be shown as "X users are typing".
+ */
+const maxUsers = 4
+
+function getMemberName(mxid) {
+ return store.activeRoom.value().getMemberName(mxid)
+}
+
+class Typing extends ElemJS {
+ constructor() {
+ super(q("#c-typing"))
+
+ this.typingUnsubscribe = null
+
+ this.message = ejs("span")
+ this.child(this.message)
+
+ store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
+ }
+
+ changeRoom() {
+ if (this.typingUnsubscribe) {
+ this.typingUnsubscribe()
+ this.typingUnsubscribe = null
+ }
+ if (!store.activeRoom.exists()) return
+ const room = store.activeRoom.value()
+ this.typingUnsubscribe = room.timeline.typing.subscribe("changeSelf", this.render.bind(this))
+ this.render()
+ }
+
+ render() {
+ if (!store.activeRoom.exists()) return
+ const room = store.activeRoom.value()
+ let users = [...room.timeline.typing.value()]
+ // don't show own typing status
+ users = users.filter(u => u !== lsm.get("mx_user_id"))
+ if (users.length === 0) {
+ // nobody is typing
+ this.removeClass("c-typing--typing")
+ } else {
+ let message = ""
+ if (users.length === 1) {
+ message = `${getMemberName(users[0])} is typing...`
+ } else if (users.length <= maxUsers) {
+ // feel free to rewrite this loop if you know a better way
+ for (let i = 0; i < users.length; i++) {
+ if (i < users.length-1) {
+ message += `${getMemberName(users[i])}, `
+ } else {
+ message += `and ${getMemberName(users[i])} are typing...`
+ }
+ }
+ } else {
+ message = `${users.length} people are typing...`
+ }
+ this.class("c-typing--typing")
+ this.message.text(message)
+ }
+ }
+}
+
+new Typing()
diff --git a/src/login.pug b/src/login.pug
index 105b6bd..470ee82 100644
--- a/src/login.pug
+++ b/src/login.pug
@@ -1,21 +1,32 @@
doctype html
html
- head
- meta(charset="utf-8")
- link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
- title Carbon
- body
- main.main
- form
- div
- label(for="login") Username
- input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login
- div
- label(for="password") Password
- input(type="text" name="password" autocomplete="current-password" required)#password
- div
+ head
+ meta(charset="utf-8")
+ title Carbon
+ meta(name="viewport" content="width=device-width, initial-scale=1")
+ link(rel="stylesheet" type="text/css" href=getStatic("/sass/login.sass"))
+ script(type="module" src=getStatic("/js/login.js"))
+
+ body
+ main.main
+ .center-login-container
+ h1 Welcome to Carbon!
+ form.login-form(method="post" onsubmit="return false")#form
+ .data-input
+ .form-input-container
+ label(for="username") Username
+ input(type="text" name="username" autocomplete="username" placeholder="@username:server.tld" pattern="^@?[a-z0-9._=/-]+(?::[a-zA-Z0-9.:\\[\\]-]+)?$" required)#username
+
+ .form-input-container
+ label(for="password") Password
+ input(name="password" autocomplete="current-password" type="password" required)#password
+
+ .form-input-container
+ label(for="homeserver") Homeserver
+ input(type="text" name="homeserver" value="matrix.org" placeholder="matrix.org" required)#homeserver
+
+ #feedback
+
+ .form-input-container
+ input(type="submit" value="Log in")#submit
- label(for="homeserver") Homeserver
- input(type="text" name="homeserver" value="matrix.org" required)#homeserver
- div
- input(type="submit" value="Login")
diff --git a/src/sass/base.sass b/src/sass/base.sass
index 0232c2e..dfb9f7a 100644
--- a/src/sass/base.sass
+++ b/src/sass/base.sass
@@ -21,3 +21,39 @@ body
.main
height: 100vh
display: flex
+
+button
+ appearance: none
+ border: none
+ background: none
+ color: inherit
+ font-family: inherit
+ font-size: inherit
+ font-style: inherit
+ font-weight: inherit
+ padding: 0
+ margin: 0
+ line-height: inherit
+ cursor: inherit
+
+// focus resets
+
+:focus
+ outline: none
+
+:-moz-focusring
+ outline: none
+
+::-moz-focus-inner
+ border: 0
+
+select:-moz-focusring
+ color: transparent
+ text-shadow: 0 0 0 #ddd
+
+body.show-focus
+ a, select, button, input, video, div, span
+ outline-color: #fff
+
+ &:focus
+ outline: 2px dotted
diff --git a/src/sass/colors.sass b/src/sass/colors.sass
index 278fc02..a603edd 100644
--- a/src/sass/colors.sass
+++ b/src/sass/colors.sass
@@ -5,3 +5,5 @@ $mild: #393c42
$milder: #42454a
$divider: #4b4e54
$muted: #999
+$link: #57bffd
+$notify-highlight: #ffac4b
diff --git a/src/sass/components/chat-banner.sass b/src/sass/components/chat-banner.sass
new file mode 100644
index 0000000..f17acce
--- /dev/null
+++ b/src/sass/components/chat-banner.sass
@@ -0,0 +1,50 @@
+@use "../colors" as c
+
+.c-chat-banner
+ position: sticky
+ z-index: 1
+ top: 0
+ left: 0
+ right: 0
+ margin-right: 12px
+ outline-color: #000
+ opacity: 0
+ transform: translateY(-40px)
+ transition: transform 0.2s ease, opacity 0.2s ease-out
+
+ &--active
+ opacity: 1
+ transform: translateY(0px)
+
+ &__inner
+ display: grid
+ grid-template-columns: 1fr auto
+ background: c.$notify-highlight
+ color: #000
+ margin: 0px 12px
+ padding: 0px 12px
+ border-radius: 0px 0px 10px 10px
+ line-height: 1
+ box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.1)
+ cursor: pointer
+
+ &:hover
+ box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.6)
+
+ &__part
+ padding: 6px 0px 8px
+
+ &:hover
+ text-decoration: underline
+
+ &__part-inner
+ display: block
+ width: 100% // yes, really.
+ text-align: left
+
+ &__last
+ margin-left: 8px
+
+ &__last &__part-inner
+ border-left: 1px solid #222
+ padding-left: 8px
diff --git a/src/sass/components/chat-input.sass b/src/sass/components/chat-input.sass
index 892fee6..bb82dde 100644
--- a/src/sass/components/chat-input.sass
+++ b/src/sass/components/chat-input.sass
@@ -6,11 +6,14 @@
-webkit-appearance: $value
.c-chat-input
+ position: relative
width: 100%
border-top: 2px solid c.$divider
background-color: c.$dark
&__textarea
+ position: relative
+ z-index: 1
width: calc(100% - 40px)
height: 16px + (16px * 1.45)
box-sizing: border-box
diff --git a/src/sass/components/chat.sass b/src/sass/components/chat.sass
index 5ca48e0..c8bb391 100644
--- a/src/sass/components/chat.sass
+++ b/src/sass/components/chat.sass
@@ -2,11 +2,12 @@
.c-chat
display: grid
- grid-template-rows: 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
+ grid-template-rows: 0 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
align-items: end
flex: 1
&__messages
+ position: relative
height: 100%
overflow-y: scroll
scrollbar-color: c.$darkest c.$darker
diff --git a/src/sass/components/groups.sass b/src/sass/components/groups.sass
index e91f4cc..6eca6ec 100644
--- a/src/sass/components/groups.sass
+++ b/src/sass/components/groups.sass
@@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
box-sizing: border-box
.c-group
+ position: relative
display: flex
align-items: center
padding: $icon-padding / 2 $icon-padding
cursor: pointer
border-radius: 8px
+ background-color: c.$darkest
&:hover
background-color: c.$darker
@@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
overflow: hidden
text-overflow: ellipsis
+ &__number
+ position: absolute
+ right: 240px
+ bottom: 0px
+ background: #ddd
+ color: #000
+ font-size: 14px
+ line-height: 1
+ padding: 3px 4px
+ border-radius: 7px
+ border: 3px solid c.$darkest
+ opacity: 0
+ transform: translate(6px, 6px)
+ transition: transform 0.15s ease-out, opacity 0.15s ease-out
+ pointer-events: none
+
+ @at-root .c-group:hover &
+ border-color: c.$darker
+
+ &--active
+ opacity: 1
+ transform: translate(0px, 0px)
+
.c-group-marker
position: absolute
top: 5px
diff --git a/src/sass/components/highlighted-code.sass b/src/sass/components/highlighted-code.sass
new file mode 100644
index 0000000..1ec7290
--- /dev/null
+++ b/src/sass/components/highlighted-code.sass
@@ -0,0 +1 @@
+@use "../../../node_modules/highlight.js/scss/obsidian"
diff --git a/src/sass/components/messages.sass b/src/sass/components/messages.sass
index 7f56834..060dec4 100644
--- a/src/sass/components/messages.sass
+++ b/src/sass/components/messages.sass
@@ -1,6 +1,6 @@
@use "../colors" as c
-.c-event-groups *
+.c-event-groups > *
overflow-anchor: none
.c-message-group, .c-message-event
@@ -9,7 +9,8 @@
border-top: 1px solid c.$divider
.c-message-group
- display: flex
+ display: grid
+ grid-template-columns: auto 1fr
&__avatar
flex-shrink: 0
@@ -23,7 +24,7 @@
border-radius: 50%
&--no-icon
- background-color: #48d
+ background-color: #bbb
&__intro
display: flex
@@ -46,9 +47,19 @@
.c-message
margin-top: 4px
+ overflow-wrap: anywhere
opacity: 1
transition: opacity 0.2s ease-out
+ &--plain
+ white-space: pre-wrap
+
+ &--media
+ // fix whitespace
+ font-size: 0
+ margin-top: 8px
+ display: flex
+
&--pending
opacity: 0.5
@@ -66,18 +77,70 @@
&:hover
background-color: c.$darker
+ &__image
+ width: auto
+ height: auto
+ max-width: 400px
+ max-height: 300px
+
+ // message formatting rules
+
+ code, pre
+ border-radius: 4px
+ font-size: 0.9em
+
+ pre
+ background-color: c.$darkest
+ padding: 8px
+ border: 1px solid c.$divider
+ white-space: pre-wrap
+
+ code
+ background-color: c.$darker
+ padding: 2px 4px
+
+ a
+ color: c.$link
+
+ blockquote
+ margin-left: 8px
+ border-left: 4px solid c.$muted
+ padding: 2px 0px 2px 12px
+
+ p, pre, blockquote
+ margin: 16px 0px
+
+ &:first-child
+ margin-top: 0px
+
+ &:last-child
+ margin-bottom: 0px
+
.c-message-event
- padding-top: 10px
+ // closer spacing than normal messages
+ padding-top: 2px
padding-left: 6px
+ margin-bottom: -4px
+ line-height: 1.2
&__inner
- display: flex
- align-items: center
+ text-indent: -36px
+ margin-left: 36px
+
+ img
+ // let me know if there's a smarter way to line this shit up
+ position: relative
+ top: -5px
+ transform: translateY(50%)
&__icon
margin-right: 8px
- position: relative
- top: 1px
+
+ &__avatar
+ width: 16px
+ height: 16px
+ border-radius: 50%
+ margin: 0px 6px
.c-message-notice
padding: 12px
@@ -87,3 +150,37 @@
padding: 12px
background-color: c.$milder
border-radius: 8px
+
+.c-media
+ &__wrapper
+ overflow: hidden
+ position: relative
+
+ &--spoiler
+ cursor: pointer
+
+ img
+ filter: blur(40px)
+
+ &--shown img
+ filter: none
+
+ &__spoiler
+ position: absolute
+ top: 0
+ bottom: 0
+ left: 0
+ right: 0
+ display: flex
+ align-items: center
+ justify-content: center
+ font-size: 18px
+ font-weight: 500
+ color: #fff
+ text-transform: uppercase
+ background: rgba(0, 0, 0, 0.3)
+ cursor: pointer
+ pointer-events: none
+
+ &--shown &__spoiler
+ display: none
diff --git a/src/sass/components/read-marker.sass b/src/sass/components/read-marker.sass
new file mode 100644
index 0000000..7e1c572
--- /dev/null
+++ b/src/sass/components/read-marker.sass
@@ -0,0 +1,42 @@
+@use "../colors" as c
+
+.c-read-marker
+ display: none
+ position: relative
+
+ &--attached
+ display: block
+
+ &__inner
+ position: absolute
+ left: -64px
+ right: 0px
+ height: 2px
+ top: 0px
+ background-color: c.$notify-highlight
+
+ @at-root .c-message:last-child &
+ top: 11px
+
+ @at-root .c-message-event &
+ top: 7px
+
+ &__text
+ position: absolute
+ right: -14px
+ top: -9px
+ display: flex
+ align-items: center
+ background-color: c.$notify-highlight
+ color: #000
+ font-size: 12px
+ font-weight: 600
+ line-height: 1
+ padding: 4px
+ border-radius: 5px
+ text-transform: uppercase
+
+ &__loading
+ background-color: #000
+ width: 10px
+ height: 10px
diff --git a/src/sass/components/rooms.sass b/src/sass/components/rooms.sass
index 462e13c..670d9ba 100644
--- a/src/sass/components/rooms.sass
+++ b/src/sass/components/rooms.sass
@@ -43,3 +43,23 @@ $icon-padding: 8px
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
+ flex: 1
+
+ &__number
+ flex-shrink: 0
+ line-height: 1
+ padding: 4px 5px
+ border-radius: 5px
+ font-size: 14px
+ pointer-events: none
+
+ &--none
+ display: none
+
+ &--unreads
+ background-color: #ddd
+ color: #111
+
+ &--notifications
+ background-color: #ffac4b
+ color: #000
diff --git a/src/sass/components/spoilers.sass b/src/sass/components/spoilers.sass
new file mode 100644
index 0000000..e8a1784
--- /dev/null
+++ b/src/sass/components/spoilers.sass
@@ -0,0 +1,8 @@
+.mx-spoiler
+ color: #331911
+ background-color: #331911
+ outline-color: #fff !important
+ cursor: pointer
+
+ &--shown
+ color: inherit
diff --git a/src/sass/components/typing.sass b/src/sass/components/typing.sass
new file mode 100644
index 0000000..dad8d90
--- /dev/null
+++ b/src/sass/components/typing.sass
@@ -0,0 +1,21 @@
+@use "../colors" as c
+
+.c-typing
+ height: 39px
+ background: c.$divider
+ position: absolute
+ right: 0
+ left: 0
+ top: 0
+ z-index: 0
+ margin: 20px
+ border-radius: 8px
+ padding: 0px 12px
+ font-size: 14px
+ line-height: 19px
+ transform: translateY(0px)
+ transition: transform 0.15s ease
+ color: #fff
+
+ &--typing
+ transform: translateY(-21px)
diff --git a/src/sass/loading.sass b/src/sass/loading.sass
new file mode 100644
index 0000000..9705bbe
--- /dev/null
+++ b/src/sass/loading.sass
@@ -0,0 +1,13 @@
+@keyframes spin
+ 0%
+ transform: rotate(0deg)
+ 100%
+ transform: rotate(180deg)
+
+.loading-icon
+ display: inline-block
+ background-color: #ccc
+ width: 12px
+ height: 12px
+ margin-right: 6px
+ animation: spin 0.7s infinite
diff --git a/src/sass/login.sass b/src/sass/login.sass
new file mode 100644
index 0000000..74d08ac
--- /dev/null
+++ b/src/sass/login.sass
@@ -0,0 +1,73 @@
+@use "./base"
+@use "./loading.sass"
+@use "./colors.sass" as c
+
+
+.main
+ justify-content: center
+ align-items: center
+
+.center-login-container
+ display: flex
+ flex-flow: column
+ justify-content: center
+ align-items: center
+ width: min(100vw, 450px)
+ padding: max(1rem,3vw) 2rem
+ margin: 8px
+ box-shadow: 0px 2px 10px c.$darkest
+ background-color: c.$darker
+ border-radius: 5px
+
+.login-form
+ align-items: center
+ flex: 1 1 auto
+ width: 100%
+ display: flex
+ justify-content: space-around
+ flex-flow: column
+
+.data-input
+ width: 100%
+
+.form-input-container
+ width: 100%
+ display: flex
+ flex-direction: column
+ margin: 1em 0
+
+.form-feedback
+ width: 100%
+ margin: -0.5em 0 -0.8em
+
+.form-error
+ color: red
+
+
+input, button
+ font-family: inherit
+ font-size: 17px
+ background-color: c.$mild
+ color: #eee
+ width: 100%
+ border-radius: 5px
+ box-sizing: border-box
+ transition: background-color 0.15s ease-out, border-color 0.15s ease-out
+ padding: 4px 9px
+ border: 0px
+
+input[type="text"],input[type="password"]
+ border: 3px solid transparent
+ margin: 0.4em 0px
+
+ &:hover, &:focus
+ border-color: c.$milder
+
+button, input[type="submit"]
+ padding: 7px
+
+ &:hover
+ background-color: c.$milder
+
+label
+ font-size: 18px
diff --git a/src/sass/main.sass b/src/sass/main.sass
index d342bbb..c050148 100644
--- a/src/sass/main.sass
+++ b/src/sass/main.sass
@@ -1,7 +1,13 @@
@use "./base"
+@use "./loading"
@use "./components/groups"
@use "./components/rooms"
@use "./components/messages"
@use "./components/chat"
@use "./components/chat-input"
+@use "./components/typing"
@use "./components/anchor"
+@use "./components/highlighted-code"
+@use "./components/read-marker"
+@use "./components/chat-banner"
+@use "./components/spoilers"