Now technically a chat app

This commit is contained in:
Cadence Ember 2020-10-16 02:24:15 +13:00
parent ac6320c12c
commit 33e4a7d7cb
Signed by untrusted user: cadence
GPG key ID: BC1C2C61CF521B17
19 changed files with 434 additions and 13 deletions

View file

@ -2,11 +2,12 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="static/main.css?static=b0aba41b0b"> <link rel="stylesheet" type="text/css" href="static/main.css?static=0c50689ce5">
<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=a90499fdac"></script> <script type="module" src="static/chat-input.js?static=e8b21037fa"></script>
<script type="module" src="static/room-picker.js?static=d92adfaaca"></script> <script type="module" src="static/room-picker.js?static=1d38378110"></script>
<script type="module" src="static/sync/sync.js?static=232a64285c"></script> <script type="module" src="static/sync/sync.js?static=9e31c8a727"></script>
<script type="module" src="static/chat.js?static=d4a415f1ae"></script>
<title>Carbon</title> <title>Carbon</title>
</head> </head>
<body> <body>
@ -20,7 +21,7 @@
<div class="c-rooms" id="c-rooms"></div> <div class="c-rooms" id="c-rooms"></div>
<div class="c-chat"> <div class="c-chat">
<div class="c-chat__messages"> <div class="c-chat__messages">
<div class="c-chat__inner"> <div class="c-chat__inner" id="c-chat">
<div class="c-message-notice"> <div class="c-message-notice">
<div class="c-message-notice__inner">You've reached the start of the conversation.</div> <div class="c-message-notice__inner">You've reached the start of the conversation.</div>
</div> </div>

95
build/static/Timeline.js Normal file
View file

@ -0,0 +1,95 @@
import {ElemJS} from "./basic.js"
import {Subscribable} from "./store/Subscribable.js"
class Event extends ElemJS {
constructor(data) {
super("div")
this.class("c-message")
this.data = null
this.update(data)
}
update(data) {
this.data = data
this.render()
}
render() {
this.child(this.data.content.body)
}
}
class Timeline extends Subscribable {
constructor() {
super()
Object.assign(this.events, {
addItem: [],
removeItem: []
})
Object.assign(this.eventDeps, {
addItem: [],
removeItem: []
})
this.list = []
this.map = new Map()
this.elementsMap = new Map()
this.elementsList = []
}
_binarySearch(event, min = 0, max = -1) {
if (this.list.length === 0) return {success: false, i: 0}
if (max === -1) max = this.list.length - 1
let mid = Math.floor((max + min) / 2)
// success condition
if (this.list[mid] && this.list[mid].event_id === event.event_id) return {success: true, i: mid}
// failed condition
if (min >= max) {
while (mid !== -1 && (!this.list[mid] || this.list[mid].origin_server_ts > event.origin_server_ts)) mid--
return {
success: false,
i: mid + 1
}
}
// recurse (below)
if (this.list[mid].origin_server_ts > event.origin_server_ts) return this._binarySearch(event, min, mid-1)
// recurse (above)
else return this._binarySearch(event, mid+1, max)
}
updateEvents(events) {
for (const event of events) {
if (this.map.has(event.event_id)) {
this.map.set(event.event_id, event)
this.elementsMap.get(event.event_id).update(this.map.get(event.event_id))
} else {
const index = this._binarySearch(event).i
this.list.splice(index, 0, event)
this.map.set(event.event_id, event)
const e = new Event(event)
this.elementsList.splice(index, 0, e)
this.elementsMap.set(event.event_id, e)
this.broadcast("addItem", {index, element: e})
}
}
}
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
let currentGroup = []
for (const event of this.list) {
if (event.sender === currentSender) {
currentGroup.push(event)
} else {
if (currentGroup.length) groups.push(currentGroup)
currentGroup = [event]
currentSender = event.sender
}
}
if (currentGroup.length) groups.push(currentGroup)
return groups
}
}
export {Timeline}

View file

@ -119,6 +119,18 @@ class ElemJS {
return this; return this;
} }
childAt(index, toAdd) {
if (typeof toAdd === "object" && toAdd !== null) {
toAdd.parent = this;
this.children.splice(index, 0, toAdd);
if (index >= this.element.childNodes.length) {
this.element.appendChild(toAdd.element)
} else {
this.element.childNodes[index].insertAdjacentElement("beforebegin", toAdd.element)
}
}
}
/** /**
* Remove all children from the element. * Remove all children from the element.
*/ */

View file

@ -1,17 +1,46 @@
import {q} from "./basic.js" import {q} from "./basic.js"
import {store} from "./store/store.js"
import * as lsm from "./lsm.js"
let sentIndex = 0
const chat = q("#c-chat-textarea") const chat = q("#c-chat-textarea")
chat.addEventListener("keydown", event => { chat.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) { if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
chat.value = ""
event.preventDefault() event.preventDefault()
const body = chat.value
send(chat.value)
chat.value = ""
fixHeight()
} }
}) })
chat.addEventListener("input", () => { chat.addEventListener("input", () => {
fixHeight()
})
function fixHeight() {
chat.style.height = "0px" chat.style.height = "0px"
console.log(chat.clientHeight, chat.scrollHeight) console.log(chat.clientHeight, chat.scrollHeight)
chat.style.height = (chat.scrollHeight + 1) + "px" chat.style.height = (chat.scrollHeight + 1) + "px"
}) }
function getTxnId() {
return Date.now() + (sentIndex++)
}
function send(body) {
if (!store.activeRoom.exists()) return
const id = store.activeRoom.value().id
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"
}
})
}

62
build/static/chat.js Normal file
View file

@ -0,0 +1,62 @@
import {ElemJS, q, ejs} from "./basic.js"
import {store} from "./store/store.js"
class Chat extends ElemJS {
constructor() {
super(q("#c-chat"))
this.removableSubscriptions = []
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
this.render()
}
unsubscribe() {
this.removableSubscriptions.forEach(({name, target, subscription}) => {
target.unsubscribe(name, subscription)
})
this.removableSubscriptions.length = 0
}
changeRoom() {
// disconnect from the previous room
this.unsubscribe()
// connect to the new room's timeline updater
if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline
const subscription = (_, {element, index}) => {
this.childAt(index, element)
}
const name = "addItem"
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription)
}
this.render()
}
render() {
this.clearChildren()
if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline
for (const group of timeline.getGroupedEvents()) {
const first = group[0]
this.child(
ejs("div").class("c-message-group").child(
ejs("div").class("c-message-group__avatar").child(
ejs("div").class("c-message-group__icon")
),
ejs("div").class("c-message-group__messages").child(
ejs("div").class("c-message-group__intro").child(
ejs("div").class("c-message-group__name").text(first.sender)
),
...group.map(event => timeline.elementsMap.get(event.event_id))
)
)
)
}
}
}
}
new Chat()

View file

@ -208,6 +208,7 @@ body {
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
align-items: end; align-items: end;
flex: 1;
} }
.c-chat__messages { .c-chat__messages {
height: 100%; height: 100%;

View file

@ -1,5 +1,6 @@
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 * as lsm from "./lsm.js" import * as lsm from "./lsm.js"
function resolveMxc(url, size, method) { function resolveMxc(url, size, method) {
@ -66,6 +67,7 @@ class Room extends ElemJS {
this.id = id this.id = id
this.data = data this.data = data
this.timeline = new Timeline()
this.class("c-room") this.class("c-room")

View file

@ -23,7 +23,9 @@ class Subscribable {
} }
unsubscribe(event, callback) { unsubscribe(event, callback) {
this.events[event].push(callback) const index = this.events[event].indexOf(callback)
if (index === -1) throw new Error(`Tried to remove a nonexisting subscription from event ${event}`)
this.events[event].splice(index, 1)
} }
broadcast(event, data) { broadcast(event, data) {

View file

@ -48,6 +48,8 @@ function manageSync(root) {
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
timeline.updateEvents(room.timeline.events)
}) })
} catch (e) { } catch (e) {
console.error(root) console.error(root)

10
spec.js
View file

@ -64,6 +64,16 @@ module.exports = [
source: "/js/lsm.js", source: "/js/lsm.js",
target: "/static/lsm.js" target: "/static/lsm.js"
}, },
{
type: "js",
source: "/js/Timeline.js",
target: "/static/Timeline.js"
},
{
type: "js",
source: "/js/chat.js",
target: "/static/chat.js"
},
{ {
type: "file", type: "file",
source: "/assets/fonts/whitney-500.woff", source: "/assets/fonts/whitney-500.woff",

View file

@ -38,6 +38,7 @@ html
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"))
title Carbon title Carbon
body body
main.main main.main
@ -48,7 +49,7 @@ html
.c-rooms#c-rooms .c-rooms#c-rooms
.c-chat .c-chat
.c-chat__messages .c-chat__messages
.c-chat__inner .c-chat__inner#c-chat
+message-notice("You've reached the start of the conversation.") +message-notice("You've reached the start of the conversation.")
+message("Cadence", [ +message("Cadence", [
`the second button is for rooms (gonna call them "channels" to make discord users happy) that are not in a group (which will be most rooms - few people set up groups because they're so annoying, and many communities of people only need a single chatroom)`, `the second button is for rooms (gonna call them "channels" to make discord users happy) that are not in a group (which will be most rooms - few people set up groups because they're so annoying, and many communities of people only need a single chatroom)`,

95
src/js/Timeline.js Normal file
View file

@ -0,0 +1,95 @@
import {ElemJS} from "./basic.js"
import {Subscribable} from "./store/Subscribable.js"
class Event extends ElemJS {
constructor(data) {
super("div")
this.class("c-message")
this.data = null
this.update(data)
}
update(data) {
this.data = data
this.render()
}
render() {
this.child(this.data.content.body)
}
}
class Timeline extends Subscribable {
constructor() {
super()
Object.assign(this.events, {
addItem: [],
removeItem: []
})
Object.assign(this.eventDeps, {
addItem: [],
removeItem: []
})
this.list = []
this.map = new Map()
this.elementsMap = new Map()
this.elementsList = []
}
_binarySearch(event, min = 0, max = -1) {
if (this.list.length === 0) return {success: false, i: 0}
if (max === -1) max = this.list.length - 1
let mid = Math.floor((max + min) / 2)
// success condition
if (this.list[mid] && this.list[mid].event_id === event.event_id) return {success: true, i: mid}
// failed condition
if (min >= max) {
while (mid !== -1 && (!this.list[mid] || this.list[mid].origin_server_ts > event.origin_server_ts)) mid--
return {
success: false,
i: mid + 1
}
}
// recurse (below)
if (this.list[mid].origin_server_ts > event.origin_server_ts) return this._binarySearch(event, min, mid-1)
// recurse (above)
else return this._binarySearch(event, mid+1, max)
}
updateEvents(events) {
for (const event of events) {
if (this.map.has(event.event_id)) {
this.map.set(event.event_id, event)
this.elementsMap.get(event.event_id).update(this.map.get(event.event_id))
} else {
const index = this._binarySearch(event).i
this.list.splice(index, 0, event)
this.map.set(event.event_id, event)
const e = new Event(event)
this.elementsList.splice(index, 0, e)
this.elementsMap.set(event.event_id, e)
this.broadcast("addItem", {index, element: e})
}
}
}
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
let currentGroup = []
for (const event of this.list) {
if (event.sender === currentSender) {
currentGroup.push(event)
} else {
if (currentGroup.length) groups.push(currentGroup)
currentGroup = [event]
currentSender = event.sender
}
}
if (currentGroup.length) groups.push(currentGroup)
return groups
}
}
export {Timeline}

View file

@ -119,6 +119,18 @@ class ElemJS {
return this; return this;
} }
childAt(index, toAdd) {
if (typeof toAdd === "object" && toAdd !== null) {
toAdd.parent = this;
this.children.splice(index, 0, toAdd);
if (index >= this.element.childNodes.length) {
this.element.appendChild(toAdd.element)
} else {
this.element.childNodes[index].insertAdjacentElement("beforebegin", toAdd.element)
}
}
}
/** /**
* Remove all children from the element. * Remove all children from the element.
*/ */

View file

@ -1,17 +1,46 @@
import {q} from "./basic.js" import {q} from "./basic.js"
import {store} from "./store/store.js"
import * as lsm from "./lsm.js"
let sentIndex = 0
const chat = q("#c-chat-textarea") const chat = q("#c-chat-textarea")
chat.addEventListener("keydown", event => { chat.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) { if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
chat.value = ""
event.preventDefault() event.preventDefault()
const body = chat.value
send(chat.value)
chat.value = ""
fixHeight()
} }
}) })
chat.addEventListener("input", () => { chat.addEventListener("input", () => {
fixHeight()
})
function fixHeight() {
chat.style.height = "0px" chat.style.height = "0px"
console.log(chat.clientHeight, chat.scrollHeight) console.log(chat.clientHeight, chat.scrollHeight)
chat.style.height = (chat.scrollHeight + 1) + "px" chat.style.height = (chat.scrollHeight + 1) + "px"
}) }
function getTxnId() {
return Date.now() + (sentIndex++)
}
function send(body) {
if (!store.activeRoom.exists()) return
const id = store.activeRoom.value().id
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"
}
})
}

62
src/js/chat.js Normal file
View file

@ -0,0 +1,62 @@
import {ElemJS, q, ejs} from "./basic.js"
import {store} from "./store/store.js"
class Chat extends ElemJS {
constructor() {
super(q("#c-chat"))
this.removableSubscriptions = []
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
this.render()
}
unsubscribe() {
this.removableSubscriptions.forEach(({name, target, subscription}) => {
target.unsubscribe(name, subscription)
})
this.removableSubscriptions.length = 0
}
changeRoom() {
// disconnect from the previous room
this.unsubscribe()
// connect to the new room's timeline updater
if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline
const subscription = (_, {element, index}) => {
this.childAt(index, element)
}
const name = "addItem"
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription)
}
this.render()
}
render() {
this.clearChildren()
if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline
for (const group of timeline.getGroupedEvents()) {
const first = group[0]
this.child(
ejs("div").class("c-message-group").child(
ejs("div").class("c-message-group__avatar").child(
ejs("div").class("c-message-group__icon")
),
ejs("div").class("c-message-group__messages").child(
ejs("div").class("c-message-group__intro").child(
ejs("div").class("c-message-group__name").text(first.sender)
),
...group.map(event => timeline.elementsMap.get(event.event_id))
)
)
)
}
}
}
}
new Chat()

View file

@ -1,5 +1,6 @@
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 * as lsm from "./lsm.js" import * as lsm from "./lsm.js"
function resolveMxc(url, size, method) { function resolveMxc(url, size, method) {
@ -66,6 +67,7 @@ class Room extends ElemJS {
this.id = id this.id = id
this.data = data this.data = data
this.timeline = new Timeline()
this.class("c-room") this.class("c-room")

View file

@ -23,7 +23,9 @@ class Subscribable {
} }
unsubscribe(event, callback) { unsubscribe(event, callback) {
this.events[event].push(callback) const index = this.events[event].indexOf(callback)
if (index === -1) throw new Error(`Tried to remove a nonexisting subscription from event ${event}`)
this.events[event].splice(index, 1)
} }
broadcast(event, data) { broadcast(event, data) {

View file

@ -48,6 +48,8 @@ function manageSync(root) {
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
timeline.updateEvents(room.timeline.events)
}) })
} catch (e) { } catch (e) {
console.error(root) console.error(root)

View file

@ -4,7 +4,7 @@
display: grid display: grid
grid-template-rows: 1fr auto grid-template-rows: 1fr auto
align-items: end align-items: end
// height: 100% flex: 1
&__messages &__messages
height: 100% height: 100%