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
deleted file mode 100644
index efc3ccf..0000000
--- a/src/js/events/encrypted.js
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 040d94e..0000000
--- a/src/js/events/event.js
+++ /dev/null
@@ -1,72 +0,0 @@
-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
deleted file mode 100644
index 372bb72..0000000
--- a/src/js/events/hidden.js
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 1a32bcf..0000000
--- a/src/js/events/image.js
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index 5617d3e..0000000
--- a/src/js/events/membership.js
+++ /dev/null
@@ -1,127 +0,0 @@
-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
deleted file mode 100644
index ad885ff..0000000
--- a/src/js/events/message.js
+++ /dev/null
@@ -1,164 +0,0 @@
-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
deleted file mode 100644
index 620da3e..0000000
--- a/src/js/events/render-event.js
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index 5133aa8..0000000
--- a/src/js/events/unknown.js
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 2413484..0000000
--- a/src/js/focus.js
+++ /dev/null
@@ -1,11 +0,0 @@
-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 3c346c4..da60793 100644
--- a/src/js/functions.js
+++ b/src/js/functions.js
@@ -1,9 +1,7 @@
const lsm = require("./lsm.js")
function resolveMxc(url, size, method) {
- const match = url.match(/^mxc:\/\/([^/]+)\/(.*)/)
- if (!match) return url
- let [server, id] = match.slice(1)
+ let [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1)
id = id.replace(/#.*$/, "")
if (size && method) {
return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
@@ -12,28 +10,4 @@ function resolveMxc(url, size, method) {
}
}
-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
-}
+module.exports = {resolveMxc}
diff --git a/src/js/lazy-load-module.js b/src/js/lazy-load-module.js
deleted file mode 100644
index efe642d..0000000
--- a/src/js/lazy-load-module.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// 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
index 4ae9eae..216b12d 100644
--- a/src/js/login.js
+++ b/src/js/login.js
@@ -1,36 +1,78 @@
+const tippy = require("tippy.js");
const {q, ElemJS, ejs} = require("./basic.js")
+const {S_RE_DOMAIN, S_RE_IPV6, S_RE_IPV4} = require("./re.js")
const password = q("#password")
-const homeserver = q("#homeserver")
+
+// A regex matching a lossy MXID
+// Groups:
+// 1: username/localpart
+// MAYBE WITH
+// 2: hostname/serverpart
+// 3: domain
+// OR
+// 4: IP address
+// 5: IPv4 address
+// OR
+// 6: IPv6 address
+// MAYBE WITH
+// 7: port number
+const RE_LOSSY_MXID = new RegExp(`^@?([a-z0-9._=-]+?)(?::((?:(${S_RE_DOMAIN})|((${S_RE_IPV4})|(\\[${S_RE_IPV6}])))(?::(\\d+))?))?$`)
+
+window.RE_LOSSY_MXID = RE_LOSSY_MXID
class Username extends ElemJS {
- constructor() {
+ constructor(homeserver) {
super(q("#username"))
+ this.homeserver = homeserver;
+
this.on("change", this.updateServer.bind(this))
}
isValid() {
- return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/)
+ return !!this.element.value.match(RE_LOSSY_MXID)
}
getUsername() {
- return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1]
+ return this.element.value.match(RE_LOSSY_MXID)[1]
}
getServer() {
- const server = this.element.value.match(/^@?[a-z0-9._=\?-]+:([a-zA-Z0-9.:\[\]-]+)$/)
- if (server && server[1]) return server[1]
+ const server = this.element.value.match(RE_LOSSY_MXID)
+ if (server && server[2]) return server[2]
else return null
}
updateServer() {
if (!this.isValid()) return
- if (this.getServer()) homeserver.value = this.getServer()
+ if (this.getServer()) this.homeserver.suggest(this.getServer())
}
}
-const username = new Username()
+class Homeserver extends ElemJS {
+ constructor() {
+ super(q("#homeserver"));
+
+ this.tippy = tippy.default(q(".homeserver-question"), {
+ content: q("#homeserver-popup-template").innerHTML,
+ allowHTML: true,
+ interactive: true,
+ interactiveBorder: 10,
+ trigger: "focus mouseenter",
+ theme: "carbon",
+ arrow: tippy.roundArrow,
+ })
+ }
+
+ suggest(value) {
+ this.element.placeholder = value
+ }
+
+ getServer() {
+ return this.element.value || this.element.placeholder;
+ }
+}
class Feedback extends ElemJS {
constructor() {
@@ -54,19 +96,20 @@ class Feedback extends ElemJS {
this.removeClass("form-feedback")
this.removeClass("form-error")
if (content) this.class("form-feedback")
- if(isError) this.class("form-error")
+ if (isError) this.class("form-error")
this.messageSpan.text(content)
}
}
-const feedback = new Feedback()
-
class Form extends ElemJS {
- constructor() {
+ constructor(username, feedback, homeserver) {
super(q("#form"))
this.processing = false
+ this.username = username
+ this.feedback = feedback
+ this.homeserver = homeserver
this.on("submit", this.submit.bind(this))
}
@@ -74,13 +117,13 @@ class Form extends ElemJS {
async submit() {
if (this.processing) return
this.processing = true
- if (!username.isValid()) return this.cancel("Username is not valid.")
+ if (!this.username.isValid()) return this.cancel("Username is not valid.")
// Resolve homeserver address
let domain
try {
- domain = await this.findHomeserver(homeserver.value)
- } catch(e) {
+ domain = await this.findHomeserver(this.homeserver.getServer())
+ } catch (e) {
return this.cancel(e.message)
}
@@ -90,7 +133,7 @@ class Form extends ElemJS {
method: "POST",
body: JSON.stringify({
type: "m.login.password",
- user: username.getUsername(),
+ user: this.username.getUsername(),
password: password.value
})
}).then(res => res.json())
@@ -117,16 +160,16 @@ class Form extends ElemJS {
//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`)
@@ -134,11 +177,11 @@ class Form extends ElemJS {
const versions = await versionsReq.json()
if (Array.isArray(versions.versions)) return address
}
- } catch(e) {}
-
+ } 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)
+ console.error(e)
throw new Error(`Failed to look up server ${address}`)
})
@@ -153,15 +196,21 @@ class Form extends ElemJS {
}
status(message) {
- feedback.setLoading(true)
- feedback.message(message)
+ this.feedback.setLoading(true)
+ this.feedback.message(message)
}
cancel(message) {
this.processing = false
- feedback.setLoading(false)
- feedback.message(message, true)
+ this.feedback.setLoading(false)
+ this.feedback.message(message, true)
}
}
-const form = new Form()
+const homeserver = new Homeserver()
+
+const username = new Username(homeserver)
+
+const feedback = new Feedback()
+
+const form = new Form(username, feedback, homeserver)
diff --git a/src/js/main.js b/src/js/main.js
index 1bc0be0..a15bc7f 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -1,10 +1,8 @@
-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")
+const chat = require("./chat.js")
if (!localStorage.getItem("access_token")) {
location.assign("./login/")
diff --git a/src/js/re.js b/src/js/re.js
new file mode 100644
index 0000000..57b3daa
--- /dev/null
+++ b/src/js/re.js
@@ -0,0 +1,38 @@
+// A valid internet domain, according to https://stackoverflow.com/a/20046959 (cleaned)
+const S_RE_DOMAIN = "(?:[a-zA-Z]|[a-zA-Z][a-zA-Z]|[a-zA-Z]\\d|\\d[a-zA-Z]|[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9])\\.(?:[a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\\.[a-zA-Z]{2,3})"
+
+// A valid ipv4 address, one that doesn't check for valid numbers (e.g. not 999) and one that does
+// const S_RE_IPV4_NO_CHECK = "(?:(?:\\d{1,3}\\.){3}\\d{1,3})"
+const S_RE_IPV4_HAS_CHECK = "(?:(?:25[0-5]|(?:2[0-4]|1{0,1}\\d){0,1}\\d)\\.){3}(?:25[0-5]|(?:2[0-4]|1{0,1}\\d){0,1}\\d)"
+
+const S_RE_IPV6_SEG = "[a-fA-F\\d]{1,4}"
+// Yes, this is an ipv6 address.
+const S_RE_IPV6 = `
+(?:
+(?:${S_RE_IPV6_SEG}:){7}(?:${S_RE_IPV6_SEG}|:)|
+(?:${S_RE_IPV6_SEG}:){6}(?:${S_RE_IPV4_HAS_CHECK}|:${S_RE_IPV6_SEG}|:)|
+(?:${S_RE_IPV6_SEG}:){5}(?::${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,2}|:)|
+(?:${S_RE_IPV6_SEG}:){4}(?:(?::${S_RE_IPV6_SEG}){0,1}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,3}|:)|
+(?:${S_RE_IPV6_SEG}:){3}(?:(?::${S_RE_IPV6_SEG}){0,2}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,4}|:)|
+(?:${S_RE_IPV6_SEG}:){2}(?:(?::${S_RE_IPV6_SEG}){0,3}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,5}|:)|
+(?:${S_RE_IPV6_SEG}:){1}(?:(?::${S_RE_IPV6_SEG}){0,4}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,6}|:)|
+(?::(?:(?::${S_RE_IPV6_SEG}){0,5}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,7}|:))
+)(?:%[0-9a-zA-Z]{1,})?`
+ .replace(/\s*\/\/.*$/gm, '')
+ .replace(/\n/g, '')
+ .trim();
+
+const RE_DOMAIN_EXACT = new RegExp(`^${S_RE_DOMAIN}$`)
+const RE_IPV4_EXACT = new RegExp(`^${S_RE_IPV4_HAS_CHECK}$`)
+const RE_IPV6_EXACT = new RegExp(`^${S_RE_IPV6}$`)
+const RE_IP_ADDR_EXACT = new RegExp(`^${S_RE_IPV6}|${S_RE_IPV4_HAS_CHECK}$`)
+
+module.exports = {
+ S_RE_DOMAIN,
+ S_RE_IPV6,
+ S_RE_IPV4: S_RE_IPV4_HAS_CHECK,
+ RE_DOMAIN_EXACT,
+ RE_IPV4_EXACT,
+ RE_IPV6_EXACT,
+ RE_IP_ADDR_EXACT
+}
diff --git a/src/js/read-marker.js b/src/js/read-marker.js
deleted file mode 100644
index 53b4658..0000000
--- a/src/js/read-marker.js
+++ /dev/null
@@ -1,149 +0,0 @@
-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 9192ae9..a4d7303 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, extractLocalpart, extractDisplayName} = require("./functions.js")
+const {resolveMxc} = require("./functions.js")
class ActiveGroupMarker extends ElemJS {
constructor() {
@@ -25,43 +25,12 @@ 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(
@@ -69,7 +38,6 @@ 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)
)
@@ -88,73 +56,12 @@ 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)
@@ -168,37 +75,21 @@ class Room extends ElemJS {
}
get order() {
- let string = ""
- if (this.number.state.notifications) {
- string += "N"
- } else if (this.number.state.unreads) {
- string += "U"
- } else {
- string += "_"
- }
if (this.group) {
- string += this.name
+ 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
} 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)
+ return -this.timeline.latest
}
}
@@ -214,9 +105,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.getHeroes()
+ const users = this.data.summary["m.heroes"]
if (users && users.length) {
- const usernames = users.map(mxid => this.getMemberName(mxid))
+ const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
return usernames.join(", ")
}
// the room is empty
@@ -224,7 +115,6 @@ 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
@@ -232,15 +122,6 @@ 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
}
@@ -274,7 +155,6 @@ 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")
@@ -294,7 +174,6 @@ 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()
}
@@ -355,12 +234,8 @@ 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 8ef4511..e11905f 100644
--- a/src/js/store/store.js
+++ b/src/js/store/store.js
@@ -9,8 +9,7 @@ const store = {
directs: new SubscribeSet(),
activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(),
- newEvents: new Subscribable(),
- notificationsChange: new Subscribable()
+ newEvents: new Subscribable()
}
window.store = store
diff --git a/src/js/store/subscribable.js b/src/js/store/subscribable.js
index 56bf971..e87bab2 100644
--- a/src/js/store/subscribable.js
+++ b/src/js/store/subscribable.js
@@ -20,8 +20,6 @@ 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 6159597..9ee3eac 100644
--- a/src/js/store/subscribe_map.js
+++ b/src/js/store/subscribe_map.js
@@ -1,22 +1,13 @@
const {Subscribable} = require("./subscribable.js")
+const {SubscribeValue} = require("./subscribe_value.js")
class SubscribeMap extends Subscribable {
- constructor(inner) {
+ constructor() {
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: []
+ removeItem: []
})
this.map = new Map()
}
@@ -29,46 +20,22 @@ class SubscribeMap extends Subscribable {
if (this.map.has(key)) {
return this.map.get(key)
} else {
- const item = new this.inner()
- this.map.set(key, item)
- return item
+ this.map.set(key, new SubscribeValue())
}
}
- 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)
- }
+ this.broadcast("changeItem", key)
} else {
- s = new this.inner().set(value)
+ s = new SubscribeValue().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 794a8da..28a5ce2 100644
--- a/src/js/store/subscribe_map_list.js
+++ b/src/js/store/subscribe_map_list.js
@@ -54,15 +54,6 @@ 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
@@ -71,17 +62,6 @@ 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 9c71959..eaa2cdd 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()) {
- this.data = f(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 f8bfc52..c17b0e9 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: 1
+ limit: 5
},
// members are not currently needed
state: {
@@ -37,88 +37,64 @@ function sync() {
function manageSync(root) {
try {
let newEvents = false
- let notificationsChange = false
// set up directs
- 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
- 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
- }
- })
- }
- }
-
- // set up groups
- 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)
- }
- })
- store.newEvents.broadcast("changeSelf") // trigger a room list update
- })
- }
- })
- ).then(() => {
- store.rooms.sort()
+ 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)
+ }
+ 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)
+ }
+ }
+ 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
+ })
+ }
+ })
+ ).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 0a36d14..c03c635 100644
--- a/src/js/timeline.js
+++ b/src/js/timeline.js
@@ -1,20 +1,16 @@
-const {ElemJS, ejs, q} = require("./basic.js")
+const {ElemJS, ejs} = 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() {
@@ -42,6 +38,68 @@ 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")
@@ -65,11 +123,6 @@ 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)
@@ -88,6 +141,7 @@ class EventGroup extends ElemJS {
}
}
+
/** Displays a spinner and creates an event to notify timeline to load more messages */
class LoadMore extends ElemJS {
constructor(id) {
@@ -144,34 +198,25 @@ class ReactiveTimeline extends ElemJS {
}
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 success = indices.some(i => {
+ if (!this.list[i]) {
+ // if (printed++ < 100) console.log("tryadd success, created group")
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 (event.canGroup() && this.list[i] && this.list[i].canGroup() && this.list[i].data.sender === event.data.sender) {
+ } else if (this.list[i] && 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])
+ if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data)
}
removeGroup(group) {
@@ -210,12 +255,8 @@ 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
}
@@ -241,27 +282,23 @@ 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 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)
- }
+ 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"])
}
// 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
@@ -277,11 +314,9 @@ class Timeline extends Subscribable {
continue
}
// add new event
- const event = renderEvent(eventData)
+ const event = new Event(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
@@ -300,46 +335,6 @@ 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()
@@ -352,7 +347,7 @@ class Timeline extends Subscribable {
async loadScrollback() {
debug = true
- if (!this.from) return // no more scrollback for this timeline
+ if (!this.from) throw new Error("Can't load scrollback, no from token")
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)
@@ -373,21 +368,24 @@ class Timeline extends Subscribable {
if (root.chunk.length) {
// there are events to display
this.updateEvents(root.chunk)
- }
- if (!root.chunk.length || !root.end) {
+ } else {
// we reached the top of the scrollback
this.reactiveTimeline.loadMore.remove()
}
this.broadcast("afterScrollbackLoad")
}
- send(type, content) {
+ send(body) {
const tx = getTxnId()
const id = `pending$${tx}`
this.pending.add(id)
- content["chat.carbon.message.pending_id"] = id
+ const content = {
+ msgtype: "m.text",
+ body,
+ "chat.carbon.message.pending_id": id
+ }
const fakeEvent = {
- type,
+ type: "m.room.message",
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
deleted file mode 100644
index b6208ef..0000000
--- a/src/js/typing.js
+++ /dev/null
@@ -1,69 +0,0 @@
-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 470ee82..918e157 100644
--- a/src/login.pug
+++ b/src/login.pug
@@ -15,18 +15,28 @@ html
.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
+ input(type="text" name="username" autocomplete="username" placeholder="@username:server.com" 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
+ .homeserver-label
+ label(for="homeserver") Homeserver
+ span.homeserver-question(tabindex=0) (What's this?)
+ input(type="text" name="homeserver" placeholder="matrix.org")#homeserver
#feedback
.form-input-container
input(type="submit" value="Log in")#submit
+ template#homeserver-popup-template
+ .homeserver-popup
+ p
+ | Homeserver is the place where your account lives.
+ | It's usually the website where you registered.
+ p
+ | Need help finding a homeserver?
+ a(href='#') Click here
diff --git a/src/sass/base.sass b/src/sass/base.sass
index dfb9f7a..0232c2e 100644
--- a/src/sass/base.sass
+++ b/src/sass/base.sass
@@ -21,39 +21,3 @@ 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 a603edd..278fc02 100644
--- a/src/sass/colors.sass
+++ b/src/sass/colors.sass
@@ -5,5 +5,3 @@ $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
deleted file mode 100644
index f17acce..0000000
--- a/src/sass/components/chat-banner.sass
+++ /dev/null
@@ -1,50 +0,0 @@
-@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 bb82dde..892fee6 100644
--- a/src/sass/components/chat-input.sass
+++ b/src/sass/components/chat-input.sass
@@ -6,14 +6,11 @@
-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 c8bb391..5ca48e0 100644
--- a/src/sass/components/chat.sass
+++ b/src/sass/components/chat.sass
@@ -2,12 +2,11 @@
.c-chat
display: grid
- grid-template-rows: 0 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
+ grid-template-rows: 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 6eca6ec..e91f4cc 100644
--- a/src/sass/components/groups.sass
+++ b/src/sass/components/groups.sass
@@ -36,13 +36,11 @@ $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
@@ -64,29 +62,6 @@ $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
deleted file mode 100644
index 1ec7290..0000000
--- a/src/sass/components/highlighted-code.sass
+++ /dev/null
@@ -1 +0,0 @@
-@use "../../../node_modules/highlight.js/scss/obsidian"
diff --git a/src/sass/components/messages.sass b/src/sass/components/messages.sass
index 060dec4..2a7665a 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,8 +9,7 @@
border-top: 1px solid c.$divider
.c-message-group
- display: grid
- grid-template-columns: auto 1fr
+ display: flex
&__avatar
flex-shrink: 0
@@ -51,15 +50,6 @@
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
@@ -77,70 +67,18 @@
&: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
- // closer spacing than normal messages
- padding-top: 2px
+ padding-top: 10px
padding-left: 6px
- margin-bottom: -4px
- line-height: 1.2
&__inner
- 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%)
+ display: flex
+ align-items: center
&__icon
margin-right: 8px
-
- &__avatar
- width: 16px
- height: 16px
- border-radius: 50%
- margin: 0px 6px
+ position: relative
+ top: 1px
.c-message-notice
padding: 12px
@@ -150,37 +88,3 @@
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
deleted file mode 100644
index 7e1c572..0000000
--- a/src/sass/components/read-marker.sass
+++ /dev/null
@@ -1,42 +0,0 @@
-@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 670d9ba..462e13c 100644
--- a/src/sass/components/rooms.sass
+++ b/src/sass/components/rooms.sass
@@ -43,23 +43,3 @@ $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
deleted file mode 100644
index e8a1784..0000000
--- a/src/sass/components/spoilers.sass
+++ /dev/null
@@ -1,8 +0,0 @@
-.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
deleted file mode 100644
index dad8d90..0000000
--- a/src/sass/components/typing.sass
+++ /dev/null
@@ -1,21 +0,0 @@
-@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/login.sass b/src/sass/login.sass
index 74d08ac..d8385dd 100644
--- a/src/sass/login.sass
+++ b/src/sass/login.sass
@@ -1,6 +1,7 @@
@use "./base"
-@use "./loading.sass"
-@use "./colors.sass" as c
+@use "./loading"
+@use "./colors" as c
+@use "./tippy"
.main
@@ -43,6 +44,39 @@
.form-error
color: red
+.homeserver-question
+ font-size: 0.8em
+
+.homeserver-label
+ display: flex
+ justify-content: space-between
+ align-items: flex-end
+
+.homeserver-popup
+ p
+ margin: 0.2em
+ a
+ &, &:hover, &:active
+ color: white
+ text-decoration: none
+ &:hover
+ color: #f00 //Placeholder
+
+
+
+@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
input, button
font-family: inherit
@@ -67,6 +101,7 @@ button, input[type="submit"]
padding: 7px
&:hover
+ cursor: pointer
background-color: c.$milder
label
diff --git a/src/sass/main.sass b/src/sass/main.sass
index c050148..150af73 100644
--- a/src/sass/main.sass
+++ b/src/sass/main.sass
@@ -1,13 +1,8 @@
@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"
+@use "./loading"
diff --git a/src/sass/tippy.sass b/src/sass/tippy.sass
new file mode 100644
index 0000000..bc97399
--- /dev/null
+++ b/src/sass/tippy.sass
@@ -0,0 +1,10 @@
+@use "../../node_modules/tippy.js/dist/tippy.css"
+@use "../../node_modules/tippy.js/dist/svg-arrow.css"
+@use "./colors.sass" as c
+
+.tippy-box[data-theme~="carbon"]
+ background-color: c.$milder
+ border: 2px solid c.$divider
+
+ .tippy-svg-arrow
+ fill: c.$milder