Compare commits

...

2 commits

Author SHA1 Message Date
0e084c0a68
Display ghost messages that are being sent 2020-10-20 00:43:46 +13:00
f9662e31a2
Revert "Add prettier"
This reverts commit 6ef4552fd0.
2020-10-20 00:43:33 +13:00
30 changed files with 1430 additions and 1425 deletions

View file

@ -1 +0,0 @@
.gitignore

View file

@ -1 +0,0 @@
{}

451
build.js
View file

@ -1,280 +1,245 @@
const pug = require("pug"); const pug = require("pug")
const sass = require("sass"); const sass = require("sass")
const fs = require("fs").promises; const fs = require("fs").promises
const os = require("os"); const os = require("os")
const crypto = require("crypto"); const crypto = require("crypto")
const path = require("path"); const path = require("path")
const pj = path.join; const pj = path.join
const babel = require("@babel/core"); const babel = require("@babel/core")
const fetch = require("node-fetch"); const fetch = require("node-fetch")
const chalk = require("chalk"); const chalk = require("chalk")
const hint = require("jshint").JSHINT; const hint = require("jshint").JSHINT
process.chdir(pj(__dirname, "src")); process.chdir(pj(__dirname, "src"))
const buildDir = "../build"; const buildDir = "../build"
const validationQueue = []; const validationQueue = []
const validationHost = const validationHost = os.hostname() === "future" ? "http://localhost:8888/" : "http://validator.w3.org/nu/"
os.hostname() === "future" const static = new Map()
? "http://localhost:8888/" const links = new Map()
: "http://validator.w3.org/nu/"; const pugLocals = {static, links}
const static = new Map();
const links = new Map();
const pugLocals = { static, links };
const spec = require("./spec.js"); const spec = require("./spec.js")
function hash(buffer) { function hash(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 10); return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 10)
} }
function validate(filename, body, type) { function validate(filename, body, type) {
const promise = fetch(validationHost + "?out=json", { const promise = fetch(validationHost+"?out=json", {
method: "POST", method: "POST",
body, body,
headers: { headers: {
"content-type": `text/${type}; charset=UTF-8`, "content-type": `text/${type}; charset=UTF-8`
}, }
}) }).then(res => res.json()).then(root => {
.then((res) => res.json()) return function cont() {
.then((root) => { let concerningMessages = 0
return function cont() { for (const message of root.messages) {
let concerningMessages = 0; if (message.hiliteStart) {
for (const message of root.messages) { let type = message.type
if (message.hiliteStart) { if (message.type === "error") {
let type = message.type; type = chalk.red("error")
if (message.type === "error") { } else if (message.type === "warning") {
type = chalk.red("error"); type = chalk.yellow("warning")
} else if (message.type === "warning") { } else {
type = chalk.yellow("warning"); continue // don't care about info
} else { }
continue; // don't care about info let match
} if (match = message.message.match(/Property “([\w-]+)” doesn't exist.$/)) {
let match; // allow these properties specifically
if ( if (["scrollbar-width", "scrollbar-color", "overflow-anchor"].includes(match[1])) {
(match = message.message.match( continue
/Property “([\w-]+)” doesn't exist.$/ }
)) }
) { concerningMessages++
// allow these properties specifically console.log(`validation: ${type} in ${filename}`)
if ( console.log(` ${message.message}`)
[ const text = message.extract.replace(/\n/g, "⏎").replace(/\t/g, " ")
"scrollbar-width", console.log(chalk.grey(
"scrollbar-color", " "
"overflow-anchor", + text.slice(0, message.hiliteStart)
].includes(match[1]) + chalk.inverse(text.substr(message.hiliteStart, message.hiliteLength))
) { + text.slice(message.hiliteStart+message.hiliteLength)
continue; ))
} } else {
} console.log(message)
concerningMessages++; }
console.log(`validation: ${type} in ${filename}`); }
console.log(` ${message.message}`); if (!concerningMessages) {
const text = message.extract console.log(`validation: ${chalk.green("ok")} for ${filename}`)
.replace(/\n/g, "⏎") }
.replace(/\t/g, " "); }
console.log( })
chalk.grey( validationQueue.push(promise)
" " + return promise
text.slice(0, message.hiliteStart) +
chalk.inverse(
text.substr(message.hiliteStart, message.hiliteLength)
) +
text.slice(message.hiliteStart + message.hiliteLength)
)
);
} else {
console.log(message);
}
}
if (!concerningMessages) {
console.log(`validation: ${chalk.green("ok")} for ${filename}`);
}
};
});
validationQueue.push(promise);
return promise;
} }
function runHint(filename, source) { function runHint(filename, source) {
hint(source, { hint(source, {
esversion: 9, esversion: 9,
undef: true, undef: true,
// unused: true, // unused: true,
loopfunc: true, loopfunc: true,
globals: ["console", "URLSearchParams"], globals: ["console", "URLSearchParams"],
browser: true, browser: true,
asi: true, asi: true,
}); })
const result = hint.data(); const result = hint.data()
let problems = 0; let problems = 0
if (result.errors) { if (result.errors) {
for (const error of result.errors) { for (const error of result.errors) {
if (error.evidence) { if (error.evidence) {
const text = error.evidence.replace(/\t/g, " "); const text = error.evidence.replace(/\t/g, " ")
if (["W014"].includes(error.code)) continue; if ([
let type = error.code.startsWith("W") "W014"
? chalk.yellow("warning") ].includes(error.code)) continue
: chalk.red("error"); let type = error.code.startsWith("W") ? chalk.yellow("warning") : chalk.red("error")
console.log(`hint: ${type} in ${filename}`); console.log(`hint: ${type} in ${filename}`)
console.log( console.log(` ${error.line}:${error.character}: ${error.reason} (${error.code})`)
` ${error.line}:${error.character}: ${error.reason} (${error.code})` console.log(chalk.gray(
); " "
console.log( + text.slice(0, error.character)
chalk.gray( + chalk.inverse(text.substr(error.character, 1))
" " + + text.slice(error.character+1)
text.slice(0, error.character) + ))
chalk.inverse(text.substr(error.character, 1)) + problems++
text.slice(error.character + 1) }
) }
); }
problems++; if (problems) {
} console.log(`hint: ${chalk.cyan(problems+" problems")} in ${filename}`)
} } else {
} console.log(`hint: ${chalk.green("ok")} for ${filename}`)
if (problems) { }
console.log(`hint: ${chalk.cyan(problems + " problems")} in ${filename}`);
} else {
console.log(`hint: ${chalk.green("ok")} for ${filename}`);
}
} }
async function addFile(sourcePath, targetPath) { async function addFile(sourcePath, targetPath) {
const contents = await fs.readFile(pj(".", sourcePath), { encoding: null }); const contents = await fs.readFile(pj(".", sourcePath), {encoding: null})
static.set(sourcePath, `${targetPath}?static=${hash(contents)}`); static.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
fs.writeFile(pj(buildDir, targetPath), contents); fs.writeFile(pj(buildDir, targetPath), contents)
} }
async function addJS(sourcePath, targetPath) { async function addJS(sourcePath, targetPath) {
const source = await fs.readFile(pj(".", sourcePath), { encoding: "utf8" }); const source = await fs.readFile(pj(".", sourcePath), {encoding: "utf8"})
static.set(sourcePath, `${targetPath}?static=${hash(source)}`); static.set(sourcePath, `${targetPath}?static=${hash(source)}`)
runHint(sourcePath, source); runHint(sourcePath, source);
fs.writeFile(pj(buildDir, targetPath), source); fs.writeFile(pj(buildDir, targetPath), source)
} }
async function addSass(sourcePath, targetPath) { async function addSass(sourcePath, targetPath) {
const renderedCSS = sass.renderSync({ const renderedCSS = sass.renderSync({
file: pj(".", sourcePath), file: pj(".", sourcePath),
outputStyle: "expanded", outputStyle: "expanded",
indentType: "tab", indentType: "tab",
indentWidth: 1, indentWidth: 1,
functions: { functions: {
"static($name)": function (name) { "static($name)": function(name) {
if (!(name instanceof sass.types.String)) { if (!(name instanceof sass.types.String)) {
throw "$name: expected a string"; throw "$name: expected a string"
} }
const result = static.get(name.getValue()); const result = static.get(name.getValue())
if (typeof result === "string") { if (typeof result === "string") {
return new sass.types.String(result); return new sass.types.String(result)
} else { } else {
throw new Error( throw new Error("static file '"+name.getValue()+"' does not exist")
"static file '" + name.getValue() + "' does not exist" }
); }
} }
}, }).css
}, static.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
}).css; validate(sourcePath, renderedCSS, "css")
static.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`); await fs.writeFile(pj(buildDir, targetPath), renderedCSS)
validate(sourcePath, renderedCSS, "css");
await fs.writeFile(pj(buildDir, targetPath), renderedCSS);
} }
async function addPug(sourcePath, targetPath) { async function addPug(sourcePath, targetPath) {
function getRelative(staticTarget) { function getRelative(staticTarget) {
const pathLayer = ( const pathLayer = (path.dirname(targetPath).replace(/\/$/, "").match(/\//g) || []).length
path.dirname(targetPath).replace(/\/$/, "").match(/\//g) || [] const prefix = Array(pathLayer).fill("../").join("")
).length; const result = prefix + staticTarget.replace(/^\//, "")
const prefix = Array(pathLayer).fill("../").join(""); if (result) return result
const result = prefix + staticTarget.replace(/^\//, ""); else return "./"
if (result) return result; }
else return "./"; function getStatic(target) {
} return getRelative(static.get(target))
function getStatic(target) { }
return getRelative(static.get(target)); function getStaticName(target) {
} return getRelative(static.get(target)).replace(/\?.*$/, "")
function getStaticName(target) { }
return getRelative(static.get(target)).replace(/\?.*$/, ""); function getLink(target) {
} return getRelative(links.get(target))
function getLink(target) { }
return getRelative(links.get(target)); const renderedHTML = pug.compileFile(pj(".", sourcePath), {pretty: true})({getStatic, getStaticName, getLink, ...pugLocals})
} let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gsm, "")
const renderedHTML = pug.compileFile(pj(".", sourcePath), { pretty: true })({ validate(sourcePath, renderedWithoutPHP, "html")
getStatic, await fs.writeFile(pj(buildDir, targetPath), renderedHTML)
getStaticName,
getLink,
...pugLocals,
});
let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gms, "");
validate(sourcePath, renderedWithoutPHP, "html");
await fs.writeFile(pj(buildDir, targetPath), renderedHTML);
} }
async function addBabel(sourcePath, targetPath) { async function addBabel(sourcePath, targetPath) {
const originalCode = await fs.readFile(pj(".", sourcePath), "utf8"); const originalCode = await fs.readFile(pj(".", sourcePath), "utf8")
const compiled = babel.transformSync(originalCode, { const compiled = babel.transformSync(originalCode, {
sourceMaps: false, sourceMaps: false,
sourceType: "script", sourceType: "script",
presets: [ presets: [
[ [
"@babel/env", "@babel/env", {
{ targets: {
targets: { "ie": 11
ie: 11, }
}, }
}, ]
], ],
], generatorOpts: {
generatorOpts: { comments: false,
comments: false, minified: false,
minified: false, sourceMaps: false,
sourceMaps: false, }
}, })
});
const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`; const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`
static.set(sourcePath, filenameWithQuery); static.set(sourcePath, filenameWithQuery)
await Promise.all([ await Promise.all([
fs.writeFile(pj(buildDir, targetPath), originalCode), fs.writeFile(pj(buildDir, targetPath), originalCode),
fs.writeFile(pj(buildDir, minFilename), compiled.code), fs.writeFile(pj(buildDir, minFilename), compiled.code),
fs.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map)), fs.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
]); ])
} }
(async () => { ;(async () => {
// Stage 1: Register // Stage 1: Register
for (const item of spec) { for (const item of spec) {
if (item.type === "pug") { if (item.type === "pug") {
links.set(item.source, item.target.replace(/index.html$/, "")); links.set(item.source, item.target.replace(/index.html$/, ""))
} }
} }
// Stage 2: Build // Stage 2: Build
for (const item of spec) { for (const item of spec) {
if (item.type === "file") { if (item.type === "file") {
await addFile(item.source, item.target); await addFile(item.source, item.target)
} else if (item.type === "js") { } else if (item.type === "js") {
await addJS(item.source, item.target); await addJS(item.source, item.target)
} else if (item.type === "sass") { } else if (item.type === "sass") {
await addSass(item.source, item.target); await addSass(item.source, item.target)
} else if (item.type === "babel") { } else if (item.type === "babel") {
await addBabel(item.source, item.target); await addBabel(item.source, item.target)
} else if (item.type === "pug") { } else if (item.type === "pug") {
await addPug(item.source, item.target); await addPug(item.source, item.target)
} else { } else {
throw new Error("Unknown item type: " + item.type); throw new Error("Unknown item type: "+item.type)
} }
} }
console.log(chalk.green("All files emitted.")); console.log(chalk.green("All files emitted."))
await Promise.all(validationQueue).then((v) => { await Promise.all(validationQueue).then(v => {
console.log(`validation: using host ${chalk.cyan(validationHost)}`); console.log(`validation: using host ${chalk.cyan(validationHost)}`)
v.forEach((cont) => cont()); v.forEach(cont => cont())
}); })
console.log(chalk.green("Build complete.") + "\n\n------------\n"); console.log(chalk.green("Build complete.") + "\n\n------------\n")
})(); })()

View file

@ -2,10 +2,10 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="static/main.css?static=9aad8398d2"> <link rel="stylesheet" type="text/css" href="static/main.css?static=f7c0898b94">
<script type="module" src="static/groups.js?static=2cc7f0daf8"></script> <script type="module" src="static/groups.js?static=2cc7f0daf8"></script>
<script type="module" src="static/chat-input.js?static=92188b34f9"></script> <script type="module" src="static/chat-input.js?static=16321d4eb4"></script>
<script type="module" src="static/room-picker.js?static=7bc94b38d3"></script> <script type="module" src="static/room-picker.js?static=46999be5e5"></script>
<script type="module" src="static/sync/sync.js?static=56e374b23d"></script> <script type="module" src="static/sync/sync.js?static=56e374b23d"></script>
<script type="module" src="static/chat.js?static=fc121d3d23"></script> <script type="module" src="static/chat.js?static=fc121d3d23"></script>
<title>Carbon</title> <title>Carbon</title>

View file

@ -1,6 +1,13 @@
import {ElemJS, ejs} from "./basic.js" import {ElemJS, ejs} from "./basic.js"
import {Subscribable} from "./store/Subscribable.js" import {Subscribable} from "./store/Subscribable.js"
import {Anchor} from "./Anchor.js" import {Anchor} from "./Anchor.js"
import * as lsm from "./lsm.js"
let sentIndex = 0
function getTxnId() {
return Date.now() + (sentIndex++)
}
function eventSearch(list, event, min = 0, max = -1) { function eventSearch(list, event, min = 0, max = -1) {
if (list.length === 0) return {success: false, i: 0} if (list.length === 0) return {success: false, i: 0}
@ -28,23 +35,35 @@ class Event extends ElemJS {
super("div") super("div")
this.class("c-message") this.class("c-message")
this.data = null this.data = null
this.group = null
this.update(data) this.update(data)
} }
setGroup(group) {
this.group = group
}
update(data) { update(data) {
this.data = data this.data = data
this.render() this.render()
} }
removeEvent() {
if (this.group) this.group.removeEvent(this)
else this.remove()
}
render() { render() {
this.child(this.data.content.body) this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
this.text(this.data.content.body)
} }
} }
class EventGroup extends ElemJS { class EventGroup extends ElemJS {
constructor(list) { constructor(reactive, list) {
super("div") super("div")
this.class("c-message-group") this.class("c-message-group")
this.reactive = reactive
this.list = list this.list = list
this.data = { this.data = {
sender: list[0].data.sender, sender: list[0].data.sender,
@ -66,9 +85,20 @@ class EventGroup extends ElemJS {
addEvent(event) { addEvent(event) {
const index = eventSearch(this.list, event).i const index = eventSearch(this.list, event).i
event.setGroup(this)
this.list.splice(index, 0, event) this.list.splice(index, 0, event)
this.messages.childAt(index + 1, event) this.messages.childAt(index + 1, event)
} }
removeEvent(event) {
const search = eventSearch(this.list, event)
if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
const index = search.i
// actually remove the event
this.list.splice(index, 1)
event.remove() // should get everything else
if (this.list.length === 0) this.reactive.removeGroup(this)
}
} }
class ReactiveTimeline extends ElemJS { class ReactiveTimeline extends ElemJS {
@ -90,7 +120,7 @@ class ReactiveTimeline extends ElemJS {
const success = indices.some(i => { const success = indices.some(i => {
if (!this.list[i]) { if (!this.list[i]) {
// if (printed++ < 100) console.log("tryadd success, created group") // if (printed++ < 100) console.log("tryadd success, created group")
const group = new EventGroup([event]) const group = new EventGroup(this, [event])
this.list.splice(i, 0, group) this.list.splice(i, 0, group)
this.childAt(i, group) this.childAt(i, group)
return true return true
@ -103,6 +133,12 @@ class ReactiveTimeline extends ElemJS {
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)
} }
removeGroup(group) {
const index = this.list.indexOf(group)
this.list.splice(index, 1)
group.remove() // should get everything else
}
render() { render() {
this.clearChildren() this.clearChildren()
this.list.forEach(group => this.child(group)) this.list.forEach(group => this.child(group))
@ -112,36 +148,84 @@ class ReactiveTimeline extends ElemJS {
} }
class Timeline extends Subscribable { class Timeline extends Subscribable {
constructor() { constructor(id) {
super() super()
Object.assign(this.events, { Object.assign(this.events, {
beforeChange: [] beforeChange: [],
afterChange: []
}) })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
beforeChange: [] beforeChange: [],
afterChange: []
}) })
this.id = id
this.list = [] this.list = []
this.map = new Map() this.map = new Map()
this.reactiveTimeline = new ReactiveTimeline([]) this.reactiveTimeline = new ReactiveTimeline([])
this.latest = 0 this.latest = 0
this.pending = new Set()
} }
updateEvents(events) { updateEvents(events) {
this.broadcast("beforeChange") this.broadcast("beforeChange")
for (const eventData of events) { for (const eventData of events) {
this.latest = Math.max(this.latest, eventData.origin_server_ts) this.latest = Math.max(this.latest, eventData.origin_server_ts)
if (this.map.has(eventData.event_id)) { let id = eventData.event_id
this.map.get(eventData.event_id).update(eventData) if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
id = eventData.content["chat.carbon.message.pending_id"]
}
if (this.map.has(id)) {
this.map.get(id).update(eventData)
} else { } else {
const event = new Event(eventData) const event = new Event(eventData)
this.map.set(id, event)
this.reactiveTimeline.addEvent(event) this.reactiveTimeline.addEvent(event)
} }
} }
this.broadcast("afterChange")
}
removeEvent(id) {
if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
this.map.get(id).removeEvent()
this.map.delete(id)
} }
getTimeline() { getTimeline() {
return this.reactiveTimeline return this.reactiveTimeline
} }
send(body) {
const tx = getTxnId()
const id = `pending$${tx}`
this.pending.add(id)
const content = {
msgtype: "m.text",
body,
"chat.carbon.message.pending_id": id
}
const fakeEvent = {
origin_server_ts: Date.now(),
event_id: id,
sender: lsm.get("mx_user_id"),
content,
pending: true
}
this.updateEvents([fakeEvent])
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
method: "PUT",
body: JSON.stringify(content),
headers: {
"Content-Type": "application/json"
}
})/*.then(() => {
const subscription = () => {
this.removeEvent(id)
this.unsubscribe("afterChange", subscription)
}
this.subscribe("afterChange", subscription)
})*/
}
/* /*
getGroupedEvents() { getGroupedEvents() {
let currentSender = Symbol("N/A") let currentSender = Symbol("N/A")

View file

@ -3,8 +3,6 @@ import {store} from "./store/store.js"
import * as lsm from "./lsm.js" import * as lsm from "./lsm.js"
import {chat} from "./chat.js" import {chat} from "./chat.js"
let sentIndex = 0
const input = q("#c-chat-textarea") const input = q("#c-chat-textarea")
store.activeRoom.subscribe("changeSelf", () => { store.activeRoom.subscribe("changeSelf", () => {
@ -33,21 +31,7 @@ function fixHeight() {
input.style.height = (input.scrollHeight + 1) + "px" input.style.height = (input.scrollHeight + 1) + "px"
} }
function getTxnId() {
return Date.now() + (sentIndex++)
}
function send(body) { function send(body) {
if (!store.activeRoom.exists()) return if (!store.activeRoom.exists()) return
const id = store.activeRoom.value().id return store.activeRoom.value().timeline.send(body)
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${id}/send/m.room.message/${getTxnId()}?access_token=${lsm.get("access_token")}`, {
method: "PUT",
body: JSON.stringify({
msgtype: "m.text",
body
}),
headers: {
"Content-Type": "application/json"
}
})
} }

View file

@ -182,6 +182,11 @@ body {
.c-message { .c-message {
margin-top: 4px; margin-top: 4px;
opacity: 1;
transition: opacity 0.2s ease-out;
}
.c-message--pending {
opacity: 0.5;
} }
.c-message-event { .c-message-event {

View file

@ -68,7 +68,7 @@ class Room extends ElemJS {
this.id = id this.id = id
this.data = data this.data = data
this.timeline = new Timeline() this.timeline = new Timeline(this.id)
this.group = null this.group = null
this.class("c-room") this.class("c-room")

View file

@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "esnext", "module": "esnext",
"checkJs": true, "checkJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
} }
} }

17
package-lock.json generated
View file

@ -1,5 +1,5 @@
{ {
"name": "carbon", "name": "cosc212-assignment-1",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
@ -1095,15 +1095,6 @@
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
} }
}, },
"@prettier/plugin-pug": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-pug/-/plugin-pug-1.9.0.tgz",
"integrity": "sha512-doLga3EPMPiUgO98aUWXoq8YuPLIwUWX0YbwqnSg2URQ7hKGjxlyEeVlAmrERVI3mm9zbwpEEZ02jw0ROd+5+g==",
"dev": true,
"requires": {
"pug-lexer": "^5.0.0"
}
},
"@types/color-name": { "@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -1913,12 +1904,6 @@
"mkdirp": "^0.5.5" "mkdirp": "^0.5.5"
} }
}, },
"prettier": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz",
"integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==",
"dev": true
},
"promise": { "promise": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",

View file

@ -15,12 +15,10 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.11.1", "@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0", "@babel/preset-env": "^7.11.0",
"@prettier/plugin-pug": "^1.9.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"http-server": "^0.12.3", "http-server": "^0.12.3",
"jshint": "^2.12.0", "jshint": "^2.12.0",
"node-fetch": "^2.6.0", "node-fetch": "^2.6.0",
"prettier": "^2.1.2",
"pug": "^3.0.0", "pug": "^3.0.0",
"sass": "^1.26.10" "sass": "^1.26.10"
} }

232
spec.js
View file

@ -1,117 +1,117 @@
module.exports = [ module.exports = [
{ {
type: "file", type: "file",
source: "/assets/fonts/whitney-500.woff", source: "/assets/fonts/whitney-500.woff",
target: "/static/whitney-500.woff", target: "/static/whitney-500.woff"
}, },
{ {
type: "file", type: "file",
source: "/assets/fonts/whitney-400.woff", source: "/assets/fonts/whitney-400.woff",
target: "/static/whitney-400.woff", target: "/static/whitney-400.woff"
}, },
{ {
type: "js", type: "js",
source: "/js/basic.js", source: "/js/basic.js",
target: "/static/basic.js", target: "/static/basic.js"
}, },
{ {
type: "js", type: "js",
source: "/js/groups.js", source: "/js/groups.js",
target: "/static/groups.js", target: "/static/groups.js"
}, },
{ {
type: "js", type: "js",
source: "/js/chat-input.js", source: "/js/chat-input.js",
target: "/static/chat-input.js", target: "/static/chat-input.js"
}, },
{ {
type: "js", type: "js",
source: "/js/room-picker.js", source: "/js/room-picker.js",
target: "/static/room-picker.js", target: "/static/room-picker.js"
}, },
{ {
type: "js", type: "js",
source: "/js/store/store.js", source: "/js/store/store.js",
target: "/static/store/store.js", target: "/static/store/store.js"
}, },
{ {
type: "js", type: "js",
source: "/js/store/Subscribable.js", source: "/js/store/Subscribable.js",
target: "/static/store/Subscribable.js", target: "/static/store/Subscribable.js"
}, },
{ {
type: "js", type: "js",
source: "/js/store/SubscribeValue.js", source: "/js/store/SubscribeValue.js",
target: "/static/store/SubscribeValue.js", target: "/static/store/SubscribeValue.js"
}, },
{ {
type: "js", type: "js",
source: "/js/store/SubscribeMapList.js", source: "/js/store/SubscribeMapList.js",
target: "/static/store/SubscribeMapList.js", target: "/static/store/SubscribeMapList.js"
}, },
{ {
type: "js", type: "js",
source: "/js/store/SubscribeSet.js", source: "/js/store/SubscribeSet.js",
target: "/static/store/SubscribeSet.js", target: "/static/store/SubscribeSet.js"
}, },
{ {
type: "js", type: "js",
source: "/js/sync/sync.js", source: "/js/sync/sync.js",
target: "/static/sync/sync.js", target: "/static/sync/sync.js"
}, },
{ {
type: "js", type: "js",
source: "/js/lsm.js", source: "/js/lsm.js",
target: "/static/lsm.js", target: "/static/lsm.js"
}, },
{ {
type: "js", type: "js",
source: "/js/Timeline.js", source: "/js/Timeline.js",
target: "/static/Timeline.js", target: "/static/Timeline.js"
}, },
{ {
type: "js", type: "js",
source: "/js/Anchor.js", source: "/js/Anchor.js",
target: "/static/Anchor.js", target: "/static/Anchor.js"
}, },
{ {
type: "js", type: "js",
source: "/js/chat.js", source: "/js/chat.js",
target: "/static/chat.js", target: "/static/chat.js"
}, },
{ {
type: "file", type: "file",
source: "/assets/fonts/whitney-500.woff", source: "/assets/fonts/whitney-500.woff",
target: "/static/whitney-500.woff", target: "/static/whitney-500.woff"
}, },
{ {
type: "file", type: "file",
source: "/assets/icons/directs.svg", source: "/assets/icons/directs.svg",
target: "/static/directs.svg", target: "/static/directs.svg"
}, },
{ {
type: "file", type: "file",
source: "/assets/icons/channels.svg", source: "/assets/icons/channels.svg",
target: "/static/channels.svg", target: "/static/channels.svg"
}, },
{ {
type: "file", type: "file",
source: "/assets/icons/join-event.svg", source: "/assets/icons/join-event.svg",
target: "/static/join-event.svg", target: "/static/join-event.svg"
}, },
{ {
type: "sass", type: "sass",
source: "/sass/main.sass", source: "/sass/main.sass",
target: "/static/main.css", target: "/static/main.css"
}, },
{ {
type: "pug", type: "pug",
source: "/home.pug", source: "/home.pug",
target: "/index.html", target: "/index.html"
}, },
{ {
type: "pug", type: "pug",
source: "/login.pug", source: "/login.pug",
target: "/login.html", target: "/login.html"
}, }
]; ]

View file

@ -26,32 +26,29 @@ mixin message-notice(content)
mixin message-event(icon, content) mixin message-event(icon, content)
.c-message-event .c-message-event
.c-message-event__inner .c-message-event__inner
img.c-message-event__icon(src=icon, alt="") img(src=icon alt="").c-message-event__icon
= content = content
doctype html doctype html
html html
head head
meta(charset="utf-8") meta(charset="utf-8")
link(rel="stylesheet", type="text/css", href=getStatic('/sass/main.sass')) link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
script(type="module", src=getStatic('/js/groups.js')) script(type="module" src=getStatic("/js/groups.js"))
script(type="module", src=getStatic('/js/chat-input.js')) script(type="module" src=getStatic("/js/chat-input.js"))
script(type="module", src=getStatic('/js/room-picker.js')) script(type="module" src=getStatic("/js/room-picker.js"))
script(type="module", src=getStatic('/js/sync/sync.js')) script(type="module" src=getStatic("/js/sync/sync.js"))
script(type="module", src=getStatic('/js/chat.js')) script(type="module" src=getStatic("/js/chat.js"))
title Carbon title Carbon
body body
main.main main.main
.c-groups .c-groups
#c-groups-display.c-groups__display .c-groups__display#c-groups-display
#c-group-marker.c-group-marker .c-group-marker#c-group-marker
#c-groups-list.c-groups__container .c-groups__container#c-groups-list
#c-rooms.c-rooms .c-rooms#c-rooms
.c-chat .c-chat
#c-chat-messages.c-chat__messages .c-chat__messages#c-chat-messages
#c-chat.c-chat__inner .c-chat__inner#c-chat
.c-chat-input .c-chat-input
textarea#c-chat-textarea.c-chat-input__textarea( textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
placeholder="Send a message...",
autocomplete="off"
)

View file

@ -1,15 +1,15 @@
import { ElemJS } from "./basic.js"; import {ElemJS} from "./basic.js"
class Anchor extends ElemJS { class Anchor extends ElemJS {
constructor() { constructor() {
super("div"); super("div")
this.class("c-anchor"); this.class("c-anchor")
} }
scroll() { scroll() {
// console.log("anchor scrolled") // console.log("anchor scrolled")
this.element.scrollIntoView({ block: "start" }); this.element.scrollIntoView({block: "start"})
} }
} }
export { Anchor }; export {Anchor}

View file

@ -1,171 +1,232 @@
import { ElemJS, ejs } from "./basic.js"; import {ElemJS, ejs} from "./basic.js"
import { Subscribable } from "./store/Subscribable.js"; import {Subscribable} from "./store/Subscribable.js"
import { Anchor } from "./Anchor.js"; import {Anchor} from "./Anchor.js"
import * as lsm from "./lsm.js"
let sentIndex = 0
function getTxnId() {
return Date.now() + (sentIndex++)
}
function eventSearch(list, event, min = 0, max = -1) { function eventSearch(list, event, min = 0, max = -1) {
if (list.length === 0) return { success: false, i: 0 }; if (list.length === 0) return {success: false, i: 0}
if (max === -1) max = list.length - 1; if (max === -1) max = list.length - 1
let mid = Math.floor((max + min) / 2); let mid = Math.floor((max + min) / 2)
// success condition // success condition
if (list[mid] && list[mid].data.event_id === event.data.event_id) if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
return { success: true, i: mid }; // failed condition
// failed condition if (min >= max) {
if (min >= max) { while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
while ( return {
mid !== -1 && success: false,
(!list[mid] || i: mid + 1
list[mid].data.origin_server_ts > event.data.origin_server_ts) }
) }
mid--; // recurse (below)
return { if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid-1)
success: false, // recurse (above)
i: mid + 1, else return eventSearch(list, event, mid+1, max)
};
}
// recurse (below)
if (list[mid].data.origin_server_ts > event.data.origin_server_ts)
return eventSearch(list, event, min, mid - 1);
// recurse (above)
else return eventSearch(list, event, mid + 1, max);
} }
class Event extends ElemJS { class Event extends ElemJS {
constructor(data) { constructor(data) {
super("div"); super("div")
this.class("c-message"); this.class("c-message")
this.data = null; this.data = null
this.update(data); this.group = null
} this.update(data)
}
update(data) { setGroup(group) {
this.data = data; this.group = group
this.render(); }
}
render() { update(data) {
this.child(this.data.content.body); 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")
this.text(this.data.content.body)
}
} }
class EventGroup extends ElemJS { class EventGroup extends ElemJS {
constructor(list) { constructor(reactive, list) {
super("div"); super("div")
this.class("c-message-group"); this.class("c-message-group")
this.list = list; this.reactive = reactive
this.data = { this.list = list
sender: list[0].data.sender, this.data = {
origin_server_ts: list[0].data.origin_server_ts, sender: list[0].data.sender,
}; origin_server_ts: list[0].data.origin_server_ts
this.child( }
ejs("div") this.child(
.class("c-message-group__avatar") ejs("div").class("c-message-group__avatar").child(
.child(ejs("div").class("c-message-group__icon")), ejs("div").class("c-message-group__icon")
(this.messages = ejs("div") ),
.class("c-message-group__messages") this.messages = ejs("div").class("c-message-group__messages").child(
.child( ejs("div").class("c-message-group__intro").child(
ejs("div") ejs("div").class("c-message-group__name").text(this.data.sender),
.class("c-message-group__intro") ejs("div").class("c-message-group__date").text(this.data.origin_server_ts)
.child( ),
ejs("div").class("c-message-group__name").text(this.data.sender), ...this.list
ejs("div") )
.class("c-message-group__date") )
.text(this.data.origin_server_ts) }
),
...this.list
))
);
}
addEvent(event) { addEvent(event) {
const index = eventSearch(this.list, event).i; const index = eventSearch(this.list, event).i
this.list.splice(index, 0, event); event.setGroup(this)
this.messages.childAt(index + 1, event); this.list.splice(index, 0, event)
} this.messages.childAt(index + 1, event)
}
removeEvent(event) {
const search = eventSearch(this.list, event)
if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
const index = search.i
// actually remove the event
this.list.splice(index, 1)
event.remove() // should get everything else
if (this.list.length === 0) this.reactive.removeGroup(this)
}
} }
class ReactiveTimeline extends ElemJS { class ReactiveTimeline extends ElemJS {
constructor(list) { constructor(list) {
super("div"); super("div")
this.class("c-event-groups"); this.class("c-event-groups")
this.list = list; this.list = list
this.render(); this.render()
} }
addEvent(event) { addEvent(event) {
const search = eventSearch(this.list, event); const search = eventSearch(this.list, event)
// console.log(search, this.list.map(l => l.data.sender), event.data) // console.log(search, this.list.map(l => l.data.sender), event.data)
if (!search.success && search.i >= 1) if (!search.success && search.i >= 1) this.tryAddGroups(event, [search.i-1, search.i])
this.tryAddGroups(event, [search.i - 1, search.i]); else this.tryAddGroups(event, [search.i])
else this.tryAddGroups(event, [search.i]); }
}
tryAddGroups(event, indices) { tryAddGroups(event, indices) {
const success = indices.some((i) => { const success = indices.some(i => {
if (!this.list[i]) { if (!this.list[i]) {
// if (printed++ < 100) console.log("tryadd success, created group") // if (printed++ < 100) console.log("tryadd success, created group")
const group = new EventGroup([event]); const group = new EventGroup(this, [event])
this.list.splice(i, 0, group); this.list.splice(i, 0, group)
this.childAt(i, group); this.childAt(i, group)
return true; return true
} else if ( } else if (this.list[i] && this.list[i].data.sender === event.data.sender) {
this.list[i] && // if (printed++ < 100) console.log("tryadd success, using existing group")
this.list[i].data.sender === event.data.sender this.list[i].addEvent(event)
) { return true
// 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
);
}
render() { removeGroup(group) {
this.clearChildren(); const index = this.list.indexOf(group)
this.list.forEach((group) => this.child(group)); this.list.splice(index, 1)
this.anchor = new Anchor(); group.remove() // should get everything else
this.child(this.anchor); }
}
render() {
this.clearChildren()
this.list.forEach(group => this.child(group))
this.anchor = new Anchor()
this.child(this.anchor)
}
} }
class Timeline extends Subscribable { class Timeline extends Subscribable {
constructor() { constructor(id) {
super(); super()
Object.assign(this.events, { Object.assign(this.events, {
beforeChange: [], beforeChange: [],
}); afterChange: []
Object.assign(this.eventDeps, { })
beforeChange: [], Object.assign(this.eventDeps, {
}); beforeChange: [],
this.list = []; afterChange: []
this.map = new Map(); })
this.reactiveTimeline = new ReactiveTimeline([]); this.id = id
this.latest = 0; this.list = []
} this.map = new Map()
this.reactiveTimeline = new ReactiveTimeline([])
this.latest = 0
this.pending = new Set()
}
updateEvents(events) { updateEvents(events) {
this.broadcast("beforeChange"); this.broadcast("beforeChange")
for (const eventData of events) { for (const eventData of events) {
this.latest = Math.max(this.latest, eventData.origin_server_ts); this.latest = Math.max(this.latest, eventData.origin_server_ts)
if (this.map.has(eventData.event_id)) { let id = eventData.event_id
this.map.get(eventData.event_id).update(eventData); if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
} else { id = eventData.content["chat.carbon.message.pending_id"]
const event = new Event(eventData); }
this.reactiveTimeline.addEvent(event); if (this.map.has(id)) {
} this.map.get(id).update(eventData)
} } else {
} const event = new Event(eventData)
this.map.set(id, event)
this.reactiveTimeline.addEvent(event)
}
}
this.broadcast("afterChange")
}
getTimeline() { removeEvent(id) {
return this.reactiveTimeline; if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
} this.map.get(id).removeEvent()
/* this.map.delete(id)
}
getTimeline() {
return this.reactiveTimeline
}
send(body) {
const tx = getTxnId()
const id = `pending$${tx}`
this.pending.add(id)
const content = {
msgtype: "m.text",
body,
"chat.carbon.message.pending_id": id
}
const fakeEvent = {
origin_server_ts: Date.now(),
event_id: id,
sender: lsm.get("mx_user_id"),
content,
pending: true
}
this.updateEvents([fakeEvent])
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
method: "PUT",
body: JSON.stringify(content),
headers: {
"Content-Type": "application/json"
}
})/*.then(() => {
const subscription = () => {
this.removeEvent(id)
this.unsubscribe("afterChange", subscription)
}
this.subscribe("afterChange", subscription)
})*/
}
/*
getGroupedEvents() { getGroupedEvents() {
let currentSender = Symbol("N/A") let currentSender = Symbol("N/A")
let groups = [] let groups = []
@ -185,4 +246,4 @@ class Timeline extends Subscribable {
*/ */
} }
export { Timeline }; export {Timeline}

View file

@ -3,13 +3,13 @@
* @template {HTMLElement} T * @template {HTMLElement} T
* @returns {T} * @returns {T}
*/ */
const q = (s) => document.querySelector(s); const q = s => document.querySelector(s);
/** /**
* Shortcut for querySelectorAll. * Shortcut for querySelectorAll.
* @template {HTMLElement} T * @template {HTMLElement} T
* @returns {T[]} * @returns {T[]}
*/ */
const qa = (s) => document.querySelectorAll(s); const qa = s => document.querySelectorAll(s);
/** /**
* An easier, chainable, object-oriented way to create and update elements * An easier, chainable, object-oriented way to create and update elements
@ -18,147 +18,143 @@ const qa = (s) => document.querySelectorAll(s);
* Created by Cadence Ember in 2018. * Created by Cadence Ember in 2018.
*/ */
class ElemJS { class ElemJS {
constructor(type) { constructor(type) {
if (type instanceof HTMLElement) { if (type instanceof HTMLElement) {
// If passed an existing element, bind to it // If passed an existing element, bind to it
this.bind(type); this.bind(type);
} else { } else {
// Otherwise, create a new detached element to bind to // Otherwise, create a new detached element to bind to
this.bind(document.createElement(type)); this.bind(document.createElement(type));
} }
this.children = []; this.children = [];
} }
/** Bind this construct to an existing element on the page. */ /** Bind this construct to an existing element on the page. */
bind(element) { bind(element) {
this.element = element; this.element = element;
this.element.js = this; this.element.js = this;
return this; return this;
} }
/** Add a class. */ /** Add a class. */
class() { class() {
for (let name of arguments) if (name) this.element.classList.add(name); for (let name of arguments) if (name) this.element.classList.add(name);
return this; return this;
} }
/** Remove a class. */ /** Remove a class. */
removeClass() { removeClass() {
for (let name of arguments) if (name) this.element.classList.remove(name); for (let name of arguments) if (name) this.element.classList.remove(name);
return this; return this;
} }
/** Set a JS property on the element. */ /** Set a JS property on the element. */
direct(name, value) { direct(name, value) {
if (name) this.element[name] = value; if (name) this.element[name] = value;
return this; return this;
} }
/** Set an attribute on the element. */ /** Set an attribute on the element. */
attribute(name, value) { attribute(name, value) {
if (name) this.element.setAttribute(name, value != undefined ? value : ""); if (name) this.element.setAttribute(name, value != undefined ? value : "");
return this; return this;
} }
/** Set a style on the element. */ /** Set a style on the element. */
style(name, value) { style(name, value) {
if (name) this.element.style[name] = value; if (name) this.element.style[name] = value;
return this; return this;
} }
/** Set the element's ID. */ /** Set the element's ID. */
id(name) { id(name) {
if (name) this.element.id = name; if (name) this.element.id = name;
return this; return this;
} }
/** Attach a callback function to an event on the element. */ /** Attach a callback function to an event on the element. */
on(name, callback) { on(name, callback) {
this.element.addEventListener(name, callback); this.element.addEventListener(name, callback);
return this; return this;
} }
/** Set the element's text. */ /** Set the element's text. */
text(name) { text(name) {
this.element.innerText = name; this.element.innerText = name;
return this; return this;
} }
/** Create a text node and add it to the element. */ /** Create a text node and add it to the element. */
addText(name) { addText(name) {
const node = document.createTextNode(name); const node = document.createTextNode(name);
this.element.appendChild(node); this.element.appendChild(node);
return this; return this;
} }
/** Set the element's HTML content. */ /** Set the element's HTML content. */
html(name) { html(name) {
this.element.innerHTML = name; this.element.innerHTML = name;
return this; return this;
} }
/** /**
* Add children to the element. * Add children to the element.
* Children can either be an instance of ElemJS, in * Children can either be an instance of ElemJS, in
* which case the element will be appended as a child, * which case the element will be appended as a child,
* or a string, in which case the string will be added as a text node. * or a string, in which case the string will be added as a text node.
* Each child should be a parameter to this method. * Each child should be a parameter to this method.
*/ */
child(...children) { child(...children) {
for (const toAdd of children) { for (const toAdd of children) {
if (typeof toAdd === "object" && toAdd !== null) { if (typeof toAdd === "object" && toAdd !== null) {
// Should be an instance of ElemJS, so append as child // Should be an instance of ElemJS, so append as child
toAdd.parent = this; toAdd.parent = this;
this.element.appendChild(toAdd.element); this.element.appendChild(toAdd.element);
this.children.push(toAdd); this.children.push(toAdd);
} else if (typeof toAdd === "string") { } else if (typeof toAdd === "string") {
// Is a string, so add as text node // Is a string, so add as text node
this.addText(toAdd); this.addText(toAdd);
} }
} }
return this; return this;
} }
childAt(index, toAdd) { childAt(index, toAdd) {
if (typeof toAdd === "object" && toAdd !== null) { if (typeof toAdd === "object" && toAdd !== null) {
toAdd.parent = this; toAdd.parent = this;
this.children.splice(index, 0, toAdd); this.children.splice(index, 0, toAdd);
if (index >= this.element.childNodes.length) { if (index >= this.element.childNodes.length) {
this.element.appendChild(toAdd.element); this.element.appendChild(toAdd.element)
} else { } else {
this.element.childNodes[index].insertAdjacentElement( this.element.childNodes[index].insertAdjacentElement("beforebegin", toAdd.element)
"beforebegin", }
toAdd.element }
); }
}
}
}
/** /**
* Remove all children from the element. * Remove all children from the element.
*/ */
clearChildren() { clearChildren() {
this.children.length = 0; this.children.length = 0;
while (this.element.lastChild) while (this.element.lastChild) this.element.removeChild(this.element.lastChild);
this.element.removeChild(this.element.lastChild); }
}
/** /**
* Remove this element. * Remove this element.
*/ */
remove() { remove() {
let index; let index;
if (this.parent && (index = this.parent.children.indexOf(this)) !== -1) { if (this.parent && (index = this.parent.children.indexOf(this)) !== -1) {
this.parent.children.splice(index, 1); this.parent.children.splice(index, 1);
} }
this.parent = null; this.parent = null;
this.element.remove(); this.element.remove();
} }
} }
/** Shortcut for `new ElemJS`. */ /** Shortcut for `new ElemJS`. */
function ejs(tag) { function ejs(tag) {
return new ElemJS(tag); return new ElemJS(tag);
} }
export { q, qa, ElemJS, ejs }; export {q, qa, ElemJS, ejs}

View file

@ -1,60 +1,37 @@
import { q } from "./basic.js"; import {q} from "./basic.js"
import { store } from "./store/store.js"; import {store} from "./store/store.js"
import * as lsm from "./lsm.js"; import * as lsm from "./lsm.js"
import { chat } from "./chat.js"; import {chat} from "./chat.js"
let sentIndex = 0; const input = q("#c-chat-textarea")
const input = q("#c-chat-textarea");
store.activeRoom.subscribe("changeSelf", () => { store.activeRoom.subscribe("changeSelf", () => {
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
input.focus(); input.focus()
} }
}); })
input.addEventListener("keydown", (event) => { input.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) { if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
event.preventDefault(); event.preventDefault()
const body = input.value; const body = input.value
send(input.value); send(input.value)
input.value = ""; input.value = ""
fixHeight(); fixHeight()
} }
}); })
input.addEventListener("input", () => { input.addEventListener("input", () => {
fixHeight(); fixHeight()
}); })
function fixHeight() { function fixHeight() {
input.style.height = "0px"; input.style.height = "0px"
// console.log(input.clientHeight, input.scrollHeight) // console.log(input.clientHeight, input.scrollHeight)
input.style.height = input.scrollHeight + 1 + "px"; input.style.height = (input.scrollHeight + 1) + "px"
}
function getTxnId() {
return Date.now() + sentIndex++;
} }
function send(body) { function send(body) {
if (!store.activeRoom.exists()) return; if (!store.activeRoom.exists()) return
const id = store.activeRoom.value().id; return store.activeRoom.value().timeline.send(body)
return fetch(
`${lsm.get(
"domain"
)}/_matrix/client/r0/rooms/${id}/send/m.room.message/${getTxnId()}?access_token=${lsm.get(
"access_token"
)}`,
{
method: "PUT",
body: JSON.stringify({
msgtype: "m.text",
body,
}),
headers: {
"Content-Type": "application/json",
},
}
);
} }

View file

@ -1,72 +1,65 @@
import { ElemJS, q, ejs } from "./basic.js"; import {ElemJS, q, ejs} from "./basic.js"
import { store } from "./store/store.js"; import {store} from "./store/store.js"
const chatMessages = q("#c-chat-messages"); const chatMessages = q("#c-chat-messages")
class Chat extends ElemJS { class Chat extends ElemJS {
constructor() { constructor() {
super(q("#c-chat")); super(q("#c-chat"))
this.removableSubscriptions = []; this.removableSubscriptions = []
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this)); store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
this.render(); this.render()
} }
unsubscribe() { unsubscribe() {
this.removableSubscriptions.forEach(({ name, target, subscription }) => { this.removableSubscriptions.forEach(({name, target, subscription}) => {
target.unsubscribe(name, subscription); target.unsubscribe(name, subscription)
}); })
this.removableSubscriptions.length = 0; this.removableSubscriptions.length = 0
} }
changeRoom() { changeRoom() {
// disconnect from the previous room // disconnect from the previous room
this.unsubscribe(); this.unsubscribe()
// connect to the new room's timeline updater // connect to the new room's timeline updater
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline; const timeline = store.activeRoom.value().timeline
const subscription = () => { const subscription = () => {
// scroll anchor does not work if the timeline is scrolled to the top. // scroll anchor does not work if the timeline is scrolled to the top.
// at the start, when there are not enough messages for a full screen, this is the case. // at the start, when there are not enough messages for a full screen, this is the case.
// once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor. // once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor.
let oldDifference = let oldDifference = chatMessages.scrollHeight - chatMessages.clientHeight
chatMessages.scrollHeight - chatMessages.clientHeight; setTimeout(() => {
setTimeout(() => { let newDifference = chatMessages.scrollHeight - chatMessages.clientHeight
let newDifference = // console.log("height difference", oldDifference, newDifference)
chatMessages.scrollHeight - chatMessages.clientHeight; if (oldDifference < 24) { // this is jank
// console.log("height difference", oldDifference, newDifference) this.element.parentElement.scrollBy(0, 1000)
if (oldDifference < 24) { }
// this is jank }, 0)
this.element.parentElement.scrollBy(0, 1000); }
} const name = "beforeChange"
}, 0); this.removableSubscriptions.push({name, target: timeline, subscription})
}; timeline.subscribe(name, subscription)
const name = "beforeChange"; }
this.removableSubscriptions.push({ this.render()
name, }
target: timeline,
subscription,
});
timeline.subscribe(name, subscription);
}
this.render();
}
render() { render() {
this.clearChildren(); this.clearChildren()
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
const reactiveTimeline = store.activeRoom.value().timeline.getTimeline(); const reactiveTimeline = store.activeRoom.value().timeline.getTimeline()
this.child(reactiveTimeline); this.child(reactiveTimeline)
setTimeout(() => { setTimeout(() => {
this.element.parentElement.scrollBy(0, 1); this.element.parentElement.scrollBy(0, 1)
reactiveTimeline.anchor.scroll(); reactiveTimeline.anchor.scroll()
}, 0); }, 0)
} }
} }
} }
const chat = new Chat(); const chat = new Chat()
export { chat }; export {chat}

View file

@ -1,14 +1,14 @@
import { q } from "./basic.js"; import {q} from "./basic.js"
let state = "CLOSED"; let state = "CLOSED"
const groups = q("#c-groups-display"); const groups = q("#c-groups-display")
const rooms = q("#c-rooms"); const rooms = q("#c-rooms")
groups.addEventListener("click", () => { groups.addEventListener("click", () => {
groups.classList.add("c-groups__display--closed"); groups.classList.add("c-groups__display--closed")
}); })
rooms.addEventListener("mouseout", () => { rooms.addEventListener("mouseout", () => {
groups.classList.remove("c-groups__display--closed"); groups.classList.remove("c-groups__display--closed")
}); })

View file

@ -1,11 +1,11 @@
function get(name) { function get(name) {
return localStorage.getItem(name); return localStorage.getItem(name)
} }
function set(name, value) { function set(name, value) {
return localStorage.setItem(name, value); return localStorage.setItem(name, value)
} }
window.lsm = { get, set }; window.lsm = {get, set}
export { get, set }; export {get, set}

View file

@ -1,247 +1,235 @@
import { q, ElemJS, ejs } from "./basic.js"; import {q, ElemJS, ejs} from "./basic.js"
import { store } from "./store/store.js"; import {store} from "./store/store.js"
import { Timeline } from "./Timeline.js"; import {Timeline} from "./Timeline.js"
import * as lsm from "./lsm.js"; import * as lsm from "./lsm.js"
function resolveMxc(url, size, method) { function resolveMxc(url, size, method) {
const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1); const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1)
if (size && method) { if (size && method) {
return `${lsm.get( return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
"domain" } else {
)}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`; return `${lsm.get("domain")}/_matrix/media/r0/download/${server}/${id}`
} else { }
return `${lsm.get("domain")}/_matrix/media/r0/download/${server}/${id}`;
}
} }
class ActiveGroupMarker extends ElemJS { class ActiveGroupMarker extends ElemJS {
constructor() { constructor() {
super(q("#c-group-marker")); super(q("#c-group-marker"))
store.activeGroup.subscribe("changeSelf", this.render.bind(this)); store.activeGroup.subscribe("changeSelf", this.render.bind(this))
} }
render() { render() {
if (store.activeGroup.exists()) { if (store.activeGroup.exists()) {
const group = store.activeGroup.value(); const group = store.activeGroup.value()
this.style("opacity", 1); this.style("opacity", 1)
this.style("transform", `translateY(${group.element.offsetTop}px)`); this.style("transform", `translateY(${group.element.offsetTop}px)`)
} else { } else {
this.style("opacity", 0); this.style("opacity", 0)
} }
} }
} }
const activeGroupMarker = new ActiveGroupMarker(); const activeGroupMarker = new ActiveGroupMarker()
class Group extends ElemJS { class Group extends ElemJS {
constructor(key, data) { constructor(key, data) {
super("div"); super("div")
this.data = data; this.data = data
this.order = this.data.order; this.order = this.data.order
this.class("c-group"); this.class("c-group")
this.child( this.child(
this.data.icon (this.data.icon
? ejs("img").class("c-group__icon").attribute("src", this.data.icon) ? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
: ejs("div").class("c-group__icon"), : ejs("div").class("c-group__icon")
ejs("div").class("c-group__name").text(this.data.name) ),
); ejs("div").class("c-group__name").text(this.data.name)
)
this.on("click", this.onClick.bind(this)); this.on("click", this.onClick.bind(this))
store.activeGroup.subscribe("changeSelf", this.render.bind(this)); store.activeGroup.subscribe("changeSelf", this.render.bind(this))
} }
render() { render() {
const active = store.activeGroup.value() === this; const active = store.activeGroup.value() === this
this.element.classList[active ? "add" : "remove"]("c-group--active"); this.element.classList[active ? "add" : "remove"]("c-group--active")
} }
onClick() { onClick() {
store.activeGroup.set(this); store.activeGroup.set(this)
} }
} }
class Room extends ElemJS { class Room extends ElemJS {
constructor(id, data) { constructor(id, data) {
super("div"); super("div")
this.id = id; this.id = id
this.data = data; this.data = data
this.timeline = new Timeline(); this.timeline = new Timeline(this.id)
this.group = null; this.group = null
this.class("c-room"); this.class("c-room")
this.on("click", this.onClick.bind(this)); this.on("click", this.onClick.bind(this))
store.activeRoom.subscribe("changeSelf", this.render.bind(this)); store.activeRoom.subscribe("changeSelf", this.render.bind(this))
this.render(); this.render()
} }
get order() { get order() {
if (this.group) { if (this.group) {
let chars = 36; let chars = 36
let total = 0; let total = 0
const name = this.getName(); const name = this.getName()
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i++) {
const c = name[i]; const c = name[i]
let d = 0; let d = 0
if (c >= "A" && c <= "Z") d = c.charCodeAt(0) - 65 + 10; 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 >= "a" && c <= "z") d = c.charCodeAt(0) - 97 + 10
else if (c >= "0" && c <= "9") d = +c; else if (c >= "0" && c <= "9") d = +c
total += d * chars ** -i; total += d * chars ** (-i)
} }
return total; return total
} else { } else {
return -this.timeline.latest; return -this.timeline.latest
} }
} }
getName() { getName() {
let name = this.data.state.events.find((e) => e.type === "m.room.name"); let name = this.data.state.events.find(e => e.type === "m.room.name")
if (name) { if (name) {
name = name.content.name; name = name.content.name
} else { } else {
const users = this.data.summary["m.heroes"]; const users = this.data.summary["m.heroes"]
const usernames = users.map((u) => (u.match(/^@([^:]+):/) || [])[1] || u); const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
name = usernames.join(", "); name = usernames.join(", ")
} }
return name; return name
} }
getIcon() { getIcon() {
const avatar = this.data.state.events.find( const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
(e) => e.type === "m.room.avatar" if (avatar) {
); return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop")
if (avatar) { } else {
return resolveMxc( return null
avatar.content.url || avatar.content.avatar_url, }
32, }
"crop"
);
} else {
return null;
}
}
isDirect() { isDirect() {
return store.directs.has(this.id); return store.directs.has(this.id)
} }
setGroup(id) { setGroup(id) {
this.group = id; this.group = id
} }
getGroup() { getGroup() {
if (this.group) { if (this.group) {
return store.groups.get(this.group).value(); return store.groups.get(this.group).value()
} else { } else {
return this.isDirect() return this.isDirect() ? store.groups.get("directs").value() : store.groups.get("channels").value()
? store.groups.get("directs").value() }
: store.groups.get("channels").value(); }
}
}
onClick() { onClick() {
store.activeRoom.set(this); store.activeRoom.set(this)
} }
render() { render() {
this.clearChildren(); this.clearChildren()
// data // data
const icon = this.getIcon(); const icon = this.getIcon()
if (icon) { if (icon) {
this.child(ejs("img").class("c-room__icon").attribute("src", icon)); this.child(ejs("img").class("c-room__icon").attribute("src", icon))
} else { } else {
this.child(ejs("div").class("c-room__icon", "c-room__icon--no-icon")); 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(ejs("div").class("c-room__name").text(this.getName()))
// active // active
const active = store.activeRoom.value() === this; const active = store.activeRoom.value() === this
this.element.classList[active ? "add" : "remove"]("c-room--active"); this.element.classList[active ? "add" : "remove"]("c-room--active")
} }
} }
class Rooms extends ElemJS { class Rooms extends ElemJS {
constructor() { constructor() {
super(q("#c-rooms")); super(q("#c-rooms"))
this.roomData = []; this.roomData = []
this.rooms = []; this.rooms = []
store.rooms.subscribe("askAdd", this.askAdd.bind(this)); store.rooms.subscribe("askAdd", this.askAdd.bind(this))
store.rooms.subscribe("addItem", this.addItem.bind(this)); store.rooms.subscribe("addItem", this.addItem.bind(this))
// store.rooms.subscribe("changeItem", this.render.bind(this)) // store.rooms.subscribe("changeItem", this.render.bind(this))
store.activeGroup.subscribe("changeSelf", this.render.bind(this)); store.activeGroup.subscribe("changeSelf", this.render.bind(this))
store.directs.subscribe("changeItem", this.render.bind(this)); store.directs.subscribe("changeItem", this.render.bind(this))
store.newEvents.subscribe("changeSelf", this.sort.bind(this)); store.newEvents.subscribe("changeSelf", this.sort.bind(this))
this.render(); this.render()
} }
sort() { sort() {
store.rooms.sort(); store.rooms.sort()
this.render(); this.render()
} }
askAdd(event, { key, data }) { askAdd(event, {key, data}) {
const room = new Room(key, data); const room = new Room(key, data)
store.rooms.addEnd(key, room); store.rooms.addEnd(key, room)
} }
addItem(event, key) { addItem(event, key) {
const room = store.rooms.get(key).value(); const room = store.rooms.get(key).value()
if (room.getGroup() === store.activeGroup.value()) { if (room.getGroup() === store.activeGroup.value()) {
this.child(room); this.child(room)
} }
} }
render() { render() {
this.clearChildren(); this.clearChildren()
let first = null; let first = null
// set room list // set room list
store.rooms.forEach((id, room) => { store.rooms.forEach((id, room) => {
if (room.value().getGroup() === store.activeGroup.value()) { if (room.value().getGroup() === store.activeGroup.value()) {
if (!first) first = room.value(); if (!first) first = room.value()
this.child(room.value()); this.child(room.value())
} }
}); })
// if needed, change the active room to be an item in the room list // if needed, change the active room to be an item in the room list
if ( if (!store.activeRoom.exists() || store.activeRoom.value().getGroup() !== store.activeGroup.value()) {
!store.activeRoom.exists() || if (first) {
store.activeRoom.value().getGroup() !== store.activeGroup.value() store.activeRoom.set(first)
) { } else {
if (first) { store.activeRoom.delete()
store.activeRoom.set(first); }
} else { }
store.activeRoom.delete(); }
}
}
}
} }
const rooms = new Rooms(); const rooms = new Rooms()
class Groups extends ElemJS { class Groups extends ElemJS {
constructor() { constructor() {
super(q("#c-groups-list")); super(q("#c-groups-list"))
store.groups.subscribe("askAdd", this.askAdd.bind(this)); store.groups.subscribe("askAdd", this.askAdd.bind(this))
store.groups.subscribe("changeItem", this.render.bind(this)); store.groups.subscribe("changeItem", this.render.bind(this))
} }
askAdd(event, { key, data }) { askAdd(event, {key, data}) {
const group = new Group(key, data); const group = new Group(key, data)
store.groups.addEnd(key, group); store.groups.addEnd(key, group)
store.groups.sort(); store.groups.sort()
} }
render() { render() {
this.clearChildren(); this.clearChildren()
store.groups.forEach((key, item) => { store.groups.forEach((key, item) => {
this.child(item.value()); this.child(item.value())
}); })
} }
} }
const groups = new Groups(); const groups = new Groups()

View file

@ -1,45 +1,38 @@
class Subscribable { class Subscribable {
constructor() { constructor() {
this.events = { this.events = {
addSelf: [], addSelf: [],
editSelf: [], editSelf: [],
removeSelf: [], removeSelf: [],
changeSelf: [], changeSelf: []
}; }
this.eventDeps = { this.eventDeps = {
addSelf: ["changeSelf"], addSelf: ["changeSelf"],
editSelf: ["changeSelf"], editSelf: ["changeSelf"],
removeSelf: ["changeSelf"], removeSelf: ["changeSelf"],
changeSelf: [], changeSelf: []
}; }
} }
subscribe(event, callback) { subscribe(event, callback) {
if (this.events[event]) { if (this.events[event]) {
this.events[event].push(callback); this.events[event].push(callback)
} else { } else {
throw new Error( throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys( }
this.events }
).join(", ")}`
);
}
}
unsubscribe(event, callback) { unsubscribe(event, callback) {
const index = this.events[event].indexOf(callback); const index = this.events[event].indexOf(callback)
if (index === -1) if (index === -1) throw new Error(`Tried to remove a nonexisting subscription from event ${event}`)
throw new Error( this.events[event].splice(index, 1)
`Tried to remove a nonexisting subscription from event ${event}` }
);
this.events[event].splice(index, 1);
}
broadcast(event, data) { broadcast(event, data) {
this.eventDeps[event].concat(event).forEach((eventName) => { this.eventDeps[event].concat(event).forEach(eventName => {
this.events[eventName].forEach((f) => f(event, data)); this.events[eventName].forEach(f => f(event, data))
}); })
} }
} }
export { Subscribable }; export {Subscribable}

View file

@ -1,41 +1,41 @@
import { Subscribable } from "./Subscribable.js"; import {Subscribable} from "./Subscribable.js"
import { SubscribeValue } from "./SubscribeValue.js"; import {SubscribeValue} from "./SubscribeValue.js"
class SubscribeMap extends Subscribable { class SubscribeMap extends Subscribable {
constructor() { constructor() {
super(); super()
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], addItem: [],
changeItem: [], changeItem: [],
removeItem: [], removeItem: []
}); })
this.map = new Map(); this.map = new Map()
} }
has(key) { has(key) {
return this.map.has(key) && this.map.get(key).exists(); return this.map.has(key) && this.map.get(key).exists()
} }
get(key) { get(key) {
if (this.map.has(key)) { if (this.map.has(key)) {
return this.map.get(key); return this.map.get(key)
} else { } else {
this.map.set(key, new SubscribeValue()); this.map.set(key, new SubscribeValue())
} }
} }
set(key, value) { set(key, value) {
let s; let s
if (this.map.has(key)) { if (this.map.has(key)) {
s = this.map.get(key).set(value); s = this.map.get(key).set(value)
this.broadcast("changeItem", key); this.broadcast("changeItem", key)
} else { } else {
s = new SubscribeValue().set(value); s = new SubscribeValue().set(value)
this.map.set(key, s); this.map.set(key, s)
this.broadcast("addItem", key); this.broadcast("addItem", key)
} }
return s; return s
} }
} }
export { SubscribeMap }; export {SubscribeMap}

View file

@ -1,86 +1,86 @@
import { Subscribable } from "./Subscribable.js"; import {Subscribable} from "./Subscribable.js"
import { SubscribeValue } from "./SubscribeValue.js"; import {SubscribeValue} from "./SubscribeValue.js"
class SubscribeMapList extends Subscribable { class SubscribeMapList extends Subscribable {
constructor(inner) { constructor(inner) {
super(); super()
this.inner = inner; this.inner = inner
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], addItem: [],
deleteItem: [], deleteItem: [],
editItem: [], editItem: [],
changeItem: [], changeItem: [],
askAdd: [], askAdd: []
}); })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
addItem: ["changeItem"], addItem: ["changeItem"],
deleteItem: ["changeItem"], deleteItem: ["changeItem"],
editItem: ["changeItem"], editItem: ["changeItem"],
changeItem: [], changeItem: [],
askAdd: [], askAdd: []
}); })
this.map = new Map(); this.map = new Map()
this.list = []; this.list = []
} }
has(key) { has(key) {
return this.map.has(key) && this.map.get(key).exists(); return this.map.has(key) && this.map.get(key).exists()
} }
get(key) { get(key) {
if (this.map.has(key)) { if (this.map.has(key)) {
return this.map.get(key); return this.map.get(key)
} else { } else {
const item = new this.inner(); const item = new this.inner()
this.map.set(key, item); this.map.set(key, item)
return item; return item
} }
} }
forEach(f) { forEach(f) {
this.list.forEach((key) => f(key, this.get(key))); this.list.forEach(key => f(key, this.get(key)))
} }
askAdd(key, data) { askAdd(key, data) {
this.broadcast("askAdd", { key, data }); this.broadcast("askAdd", {key, data})
} }
addStart(key, value) { addStart(key, value) {
this._add(key, value, true); this._add(key, value, true)
} }
addEnd(key, value) { addEnd(key, value) {
this._add(key, value, false); this._add(key, value, false)
} }
sort() { sort() {
this.list.sort((a, b) => { this.list.sort((a, b) => {
const orderA = this.map.get(a).value().order; const orderA = this.map.get(a).value().order
const orderB = this.map.get(b).value().order; const orderB = this.map.get(b).value().order
return orderA - orderB; return orderA - orderB
}); })
this.broadcast("changeItem"); this.broadcast("changeItem")
} }
_add(key, value, start) { _add(key, value, start) {
let s; let s
if (this.map.has(key)) { if (this.map.has(key)) {
const exists = this.map.get(key).exists(); const exists = this.map.get(key).exists()
s = this.map.get(key).set(value); s = this.map.get(key).set(value)
if (exists) { if (exists) {
this.broadcast("editItem", key); this.broadcast("editItem", key)
} else { } else {
this.broadcast("addItem", key); this.broadcast("addItem", key)
} }
} else { } else {
s = new this.inner().set(value); s = new this.inner().set(value)
this.map.set(key, s); this.map.set(key, s)
if (start) this.list.unshift(key); if (start) this.list.unshift(key)
else this.list.push(key); else this.list.push(key)
this.broadcast("addItem", key); this.broadcast("addItem", key)
} }
return s; return s
} }
} }
export { SubscribeMapList }; export {SubscribeMapList}

View file

@ -1,50 +1,50 @@
import { Subscribable } from "./Subscribable.js"; import {Subscribable} from "./Subscribable.js"
class SubscribeSet extends Subscribable { class SubscribeSet extends Subscribable {
constructor() { constructor() {
super(); super()
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], addItem: [],
deleteItem: [], deleteItem: [],
changeItem: [], changeItem: [],
askAdd: [], askAdd: []
}); })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
addItem: ["changeItem"], addItem: ["changeItem"],
deleteItem: ["changeItem"], deleteItem: ["changeItem"],
changeItem: [], changeItem: [],
askAdd: [], askAdd: []
}); })
this.set = new Set(); this.set = new Set()
} }
has(key) { has(key) {
return this.set.has(key); return this.set.has(key)
} }
forEach(f) { forEach(f) {
for (const key of this.set.keys()) { for (const key of this.set.keys()) {
f(key); f(key)
} }
} }
askAdd(key) { askAdd(key) {
this.broadcast("askAdd", key); this.broadcast("askAdd", key)
} }
add(key) { add(key) {
if (!this.set.has(key)) { if (!this.set.has(key)) {
this.set.add(key); this.set.add(key)
this.broadcast("addItem", key); this.broadcast("addItem", key)
} }
} }
delete(key) { delete(key) {
if (this.set.has(key)) { if (this.set.has(key)) {
this.set.delete(key); this.set.delete(key)
this.broadcast("deleteItem", key); this.broadcast("deleteItem", key)
} }
} }
} }
export { SubscribeSet }; export {SubscribeSet}

View file

@ -1,47 +1,47 @@
import { Subscribable } from "./Subscribable.js"; import {Subscribable} from "./Subscribable.js"
class SubscribeValue extends Subscribable { class SubscribeValue extends Subscribable {
constructor() { constructor() {
super(); super()
this.hasData = false; this.hasData = false
this.data = null; this.data = null
} }
exists() { exists() {
return this.hasData; return this.hasData
} }
value() { value() {
if (this.hasData) return this.data; if (this.hasData) return this.data
else return null; else return null
} }
set(data) { set(data) {
const exists = this.exists(); const exists = this.exists()
this.data = data; this.data = data
this.hasData = true; this.hasData = true
if (exists) { if (exists) {
this.broadcast("editSelf", this.data); this.broadcast("editSelf", this.data)
} else { } else {
this.broadcast("addSelf", this.data); this.broadcast("addSelf", this.data)
} }
return this; return this
} }
edit(f) { edit(f) {
if (this.exists()) { if (this.exists()) {
f(this.data); f(this.data)
this.set(this.data); this.set(this.data)
} else { } else {
throw new Error("Tried to edit a SubscribeValue that had no value"); throw new Error("Tried to edit a SubscribeValue that had no value")
} }
} }
delete() { delete() {
this.hasData = false; this.hasData = false
this.broadcast("removeSelf"); this.broadcast("removeSelf")
return this; return this
} }
} }
export { SubscribeValue }; export {SubscribeValue}

View file

@ -1,17 +1,17 @@
import { Subscribable } from "./Subscribable.js"; import {Subscribable} from "./Subscribable.js"
import { SubscribeMapList } from "./SubscribeMapList.js"; import {SubscribeMapList} from "./SubscribeMapList.js"
import { SubscribeSet } from "./SubscribeSet.js"; import {SubscribeSet} from "./SubscribeSet.js"
import { SubscribeValue } from "./SubscribeValue.js"; import {SubscribeValue} from "./SubscribeValue.js"
const store = { const store = {
groups: new SubscribeMapList(SubscribeValue), groups: new SubscribeMapList(SubscribeValue),
rooms: new SubscribeMapList(SubscribeValue), rooms: new SubscribeMapList(SubscribeValue),
directs: new SubscribeSet(), directs: new SubscribeSet(),
activeGroup: new SubscribeValue(), activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(), activeRoom: new SubscribeValue(),
newEvents: new Subscribable(), newEvents: new Subscribable()
}; }
window.store = store; window.store = store
export { store }; export {store}

View file

@ -1,139 +1,130 @@
import { store } from "../store/store.js"; import {store} from "../store/store.js"
import * as lsm from "../lsm.js"; import * as lsm from "../lsm.js"
let lastBatch = null; let lastBatch = null
function resolveMxc(url, size, method) { function resolveMxc(url, size, method) {
const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1); const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1)
if (size && method) { if (size && method) {
return `${lsm.get( return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
"domain" } else {
)}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`; return `${lsm.get("domain")}/_matrix/media/r0/download/${server}/${id}`
} else { }
return `${lsm.get("domain")}/_matrix/media/r0/download/${server}/${id}`;
}
} }
function sync() { function sync() {
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/sync`); const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/sync`)
url.searchParams.append("access_token", lsm.get("access_token")); url.searchParams.append("access_token", lsm.get("access_token"))
const filter = { const filter = {
room: { room: {
// pulling more from the timeline massively increases download size // pulling more from the timeline massively increases download size
timeline: { timeline: {
limit: 5, limit: 5
}, },
// members are not currently needed // members are not currently needed
state: { state: {
lazy_load_members: true, lazy_load_members: true
}, }
}, },
presence: { presence: {
// presence is not implemented, ignore it // presence is not implemented, ignore it
types: [], types: []
}, }
}; }
url.searchParams.append("filter", JSON.stringify(filter)); url.searchParams.append("filter", JSON.stringify(filter))
url.searchParams.append("timeout", 20000); url.searchParams.append("timeout", 20000)
if (lastBatch) { if (lastBatch) {
url.searchParams.append("since", lastBatch); url.searchParams.append("since", lastBatch)
} }
return fetch(url.toString()) return fetch(url.toString()).then(res => res.json()).then(root => {
.then((res) => res.json()) lastBatch = root.next_batch
.then((root) => { return root
lastBatch = root.next_batch; })
return root;
});
} }
function manageSync(root) { function manageSync(root) {
try { try {
let newEvents = false; let newEvents = false
// set up directs // set up directs
const directs = root.account_data.events.find((e) => e.type === "m.direct"); const directs = root.account_data.events.find(e => e.type === "m.direct")
if (directs) { if (directs) {
Object.values(directs.content).forEach((ids) => { Object.values(directs.content).forEach(ids => {
ids.forEach((id) => store.directs.add(id)); ids.forEach(id => store.directs.add(id))
}); })
} }
// set up rooms // set up rooms
Object.entries(root.rooms.join).forEach(([id, room]) => { Object.entries(root.rooms.join).forEach(([id, room]) => {
if (!store.rooms.has(id)) { if (!store.rooms.has(id)) {
store.rooms.askAdd(id, room); store.rooms.askAdd(id, room)
} }
const timeline = store.rooms.get(id).value().timeline; const timeline = store.rooms.get(id).value().timeline
if (room.timeline.events.length) newEvents = true; if (room.timeline.events.length) newEvents = true
timeline.updateEvents(room.timeline.events); timeline.updateEvents(room.timeline.events)
}); })
// set up groups // set up groups
Promise.all( Promise.all(
Object.keys(root.groups.join).map((id) => { Object.keys(root.groups.join).map(id => {
if (!store.groups.has(id)) { if (!store.groups.has(id)) {
return Promise.all( return Promise.all(["profile", "rooms"].map(path => {
["profile", "rooms"].map((path) => { const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
const url = new URL( url.searchParams.append("access_token", lsm.get("access_token"))
`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}` return fetch(url.toString()).then(res => res.json())
); })).then(([profile, rooms]) => {
url.searchParams.append("access_token", lsm.get("access_token")); rooms = rooms.chunk
return fetch(url.toString()).then((res) => res.json()); let order = 999
}) let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
).then(([profile, rooms]) => { if (orderEvent) {
rooms = rooms.chunk; if (orderEvent.content.tags.includes(id)) {
let order = 999; order = orderEvent.content.tags.indexOf(id)
let orderEvent = root.account_data.events.find( }
(e) => e.type === "im.vector.web.tag_ordering" }
); const data = {
if (orderEvent) { name: profile.name,
if (orderEvent.content.tags.includes(id)) { icon: resolveMxc(profile.avatar_url, 96, "crop"),
order = orderEvent.content.tags.indexOf(id); order
} }
} store.groups.askAdd(id, data)
const data = { rooms.forEach(groupRoom => {
name: profile.name, if (store.rooms.has(groupRoom.room_id)) {
icon: resolveMxc(profile.avatar_url, 96, "crop"), store.rooms.get(groupRoom.room_id).value().setGroup(id)
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); ).then(() => {
} store.rooms.sort()
}); })
}); if (newEvents) store.newEvents.broadcast("changeSelf")
} } catch (e) {
}) console.error(root)
).then(() => { throw e
store.rooms.sort(); }
});
if (newEvents) store.newEvents.broadcast("changeSelf");
} catch (e) {
console.error(root);
throw e;
}
} }
function syncLoop() { function syncLoop() {
return sync().then(manageSync).then(syncLoop); return sync().then(manageSync).then(syncLoop)
} }
[ ;[
{ {
id: "directs", id: "directs",
name: "Directs", name: "Directs",
icon: "/static/directs.svg", icon: "/static/directs.svg",
order: -2, order: -2
}, },
{ {
id: "channels", id: "channels",
name: "Channels", name: "Channels",
icon: "/static/channels.svg", icon: "/static/channels.svg",
order: -1, order: -1
}, }
].forEach((data) => store.groups.askAdd(data.id, data)); ].forEach(data => store.groups.askAdd(data.id, data))
store.activeGroup.set(store.groups.get("directs").value()); store.activeGroup.set(store.groups.get("directs").value())
syncLoop(); syncLoop()

View file

@ -1,36 +1,21 @@
doctype html doctype html
html html
head head
meta(charset="utf-8") meta(charset="utf-8")
link(rel="stylesheet", type="text/css", href=getStatic('/sass/main.sass')) link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
title Carbon title Carbon
body body
main.main main.main
form form
div div
label(for="login") Username label(for="login") Username
input#login( input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login
type="text", div
name="login", label(for="password") Password
autocomplete="username", input(type="text" name="password" autocomplete="current-password" required)#password
placeholder="example:matrix.org", div
required
) label(for="homeserver") Homeserver
div input(type="text" name="homeserver" value="matrix.org" required)#homeserver
label(for="password") Password div
input#password( input(type="submit" value="Login")
type="text",
name="password",
autocomplete="current-password",
required
)
div
label(for="homeserver") Homeserver
input#homeserver(
type="text",
name="homeserver",
value="matrix.org",
required
)
div
input(type="submit", value="Login")

View file

@ -44,6 +44,11 @@
.c-message .c-message
margin-top: 4px margin-top: 4px
opacity: 1
transition: opacity 0.2s ease-out
&--pending
opacity: 0.5
.c-message-event .c-message-event
padding-top: 10px padding-top: 10px