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 da60793..3c346c4 100644
--- a/src/js/functions.js
+++ b/src/js/functions.js
@@ -1,7 +1,9 @@
const lsm = require("./lsm.js")
function resolveMxc(url, size, method) {
- let [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}`
@@ -10,4 +12,28 @@ function resolveMxc(url, size, method) {
}
}
-module.exports = {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/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/main.js b/src/js/main.js
index a15bc7f..1bc0be0 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -1,8 +1,10 @@
+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")
+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 a4d7303..9192ae9 100644
--- a/src/js/room-picker.js
+++ b/src/js/room-picker.js
@@ -4,7 +4,7 @@ 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} = require("./functions.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,21 +168,37 @@ 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)
}
}
@@ -105,9 +214,9 @@ class Room extends ElemJS {
return canonicalAlias.content.alias
}
// if the room has no alias, use the names of its members ("heroes")
- const users = this.data.summary["m.heroes"]
+ const users = this.getHeroes()
if (users && users.length) {
- const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
+ const usernames = users.map(mxid => this.getMemberName(mxid))
return usernames.join(", ")
}
// the room is empty
@@ -115,6 +224,7 @@ class Room extends ElemJS {
}
getIcon() {
+ // if the room has a normal avatar
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
if (avatar) {
const url = avatar.content.url || avatar.content.avatar_url
@@ -122,6 +232,15 @@ class Room extends ElemJS {
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
}
@@ -155,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")
@@ -174,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()
}
@@ -234,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/store/store.js b/src/js/store/store.js
index e11905f..8ef4511 100644
--- a/src/js/store/store.js
+++ b/src/js/store/store.js
@@ -9,7 +9,8 @@ const store = {
directs: new SubscribeSet(),
activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(),
- newEvents: new Subscribable()
+ newEvents: new Subscribable(),
+ notificationsChange: new Subscribable()
}
window.store = store
diff --git a/src/js/store/subscribable.js b/src/js/store/subscribable.js
index e87bab2..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) {
diff --git a/src/js/store/subscribe_map.js b/src/js/store/subscribe_map.js
index 9ee3eac..6159597 100644
--- a/src/js/store/subscribe_map.js
+++ b/src/js/store/subscribe_map.js
@@ -1,13 +1,22 @@
const {Subscribable} = require("./subscribable.js")
-const {SubscribeValue} = require("./subscribe_value.js")
class SubscribeMap extends Subscribable {
- constructor() {
+ constructor(inner) {
super()
+ this.inner = inner
Object.assign(this.events, {
addItem: [],
+ editItem: [],
+ deleteItem: [],
changeItem: [],
- removeItem: []
+ askSet: []
+ })
+ Object.assign(this.eventDeps, {
+ addItem: ["changeItem"],
+ editItem: ["changeItem"],
+ deleteItem: ["changeItem"],
+ changeItem: [],
+ askSet: []
})
this.map = new Map()
}
@@ -20,22 +29,46 @@ class SubscribeMap extends Subscribable {
if (this.map.has(key)) {
return this.map.get(key)
} else {
- this.map.set(key, new SubscribeValue())
+ 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)
- this.broadcast("changeItem", key)
+ if (exists) {
+ this.broadcast("editItem", key)
+ } else {
+ this.broadcast("addItem", key)
+ }
} else {
- s = new SubscribeValue().set(value)
+ 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/subscribe_map_list.js b/src/js/store/subscribe_map_list.js
index 28a5ce2..794a8da 100644
--- a/src/js/store/subscribe_map_list.js
+++ b/src/js/store/subscribe_map_list.js
@@ -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)) {
diff --git a/src/js/store/subscribe_value.js b/src/js/store/subscribe_value.js
index eaa2cdd..9c71959 100644
--- a/src/js/store/subscribe_value.js
+++ b/src/js/store/subscribe_value.js
@@ -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")
diff --git a/src/js/sync/sync.js b/src/js/sync/sync.js
index c17b0e9..f8bfc52 100644
--- a/src/js/sync/sync.js
+++ b/src/js/sync/sync.js
@@ -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,64 +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 (!timeline.from) timeline.from = data.timeline.prev_batch
- 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
diff --git a/src/js/timeline.js b/src/js/timeline.js
index c03c635..0a36d14 100644
--- a/src/js/timeline.js
+++ b/src/js/timeline.js
@@ -1,16 +1,20 @@
-const {ElemJS, ejs} = require("./basic.js")
+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")
-const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
-
let sentIndex = 0
function getTxnId() {
@@ -38,68 +42,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) {
super("div")
@@ -123,6 +65,11 @@ class EventGroup extends ElemJS {
)
}
+ 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)
@@ -141,7 +88,6 @@ class EventGroup extends ElemJS {
}
}
-
/** Displays a spinner and creates an event to notify timeline to load more messages */
class LoadMore extends ElemJS {
constructor(id) {
@@ -198,25 +144,34 @@ class ReactiveTimeline extends ElemJS {
}
tryAddGroups(event, indices) {
- const success = indices.some(i => {
- if (!this.list[i]) {
- // if (printed++ < 100) console.log("tryadd success, created group")
+ 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])
- if (i === -1) {
- // here, -1 means at the start, before the first group
- i = 0 // jank but it does the trick
- }
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 (this.list[i] && this.list[i].data.sender === event.data.sender) {
+ } 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)
+ // 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) {
@@ -255,8 +210,12 @@ class Timeline extends Subscribable {
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
}
@@ -282,23 +241,27 @@ class Timeline extends Subscribable {
this.updateStateEvents(events)
for (const eventData of events) {
// set variables
- this.latest = Math.max(this.latest, eventData.origin_server_ts)
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 target = this.map.get(eventData.content["chat.carbon.message.pending_id"])
- this.map.set(id, target)
- this.map.delete(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 displaying events that we don't know how to
- if (eventData.type === "m.reaction") {
- continue
- }
// skip redacted events
if (eventData.unsigned && eventData.unsigned.redacted_by) {
continue
@@ -314,9 +277,11 @@ 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)
+ // update read receipt for sender on their own event
+ this.moveReadReceipt(eventData.sender, id)
}
}
// apply edits
@@ -335,6 +300,46 @@ class Timeline extends Subscribable {
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()
@@ -347,7 +352,7 @@ class Timeline extends Subscribable {
async loadScrollback() {
debug = true
- if (!this.from) throw new Error("Can't load scrollback, no from token")
+ 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)
@@ -368,24 +373,21 @@ class Timeline extends Subscribable {
if (root.chunk.length) {
// there are events to display
this.updateEvents(root.chunk)
- } else {
+ }
+ if (!root.chunk.length || !root.end) {
// we reached the top of the scrollback
this.reactiveTimeline.loadMore.remove()
}
this.broadcast("afterScrollbackLoad")
}
- send(body) {
+ send(type, content) {
const tx = getTxnId()
const id = `pending$${tx}`
this.pending.add(id)
- const content = {
- msgtype: "m.text",
- body,
- "chat.carbon.message.pending_id": id
- }
+ content["chat.carbon.message.pending_id"] = id
const fakeEvent = {
- type: "m.room.message",
+ type,
origin_server_ts: Date.now(),
event_id: id,
sender: lsm.get("mx_user_id"),
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/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 2a7665a..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
@@ -50,6 +51,15 @@
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
@@ -67,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
@@ -88,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/main.sass b/src/sass/main.sass
index 150af73..c050148 100644
--- a/src/sass/main.sass
+++ b/src/sass/main.sass
@@ -1,8 +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 "./loading"
+@use "./components/highlighted-code"
+@use "./components/read-marker"
+@use "./components/chat-banner"
+@use "./components/spoilers"