You've reached the start of the conversation.
diff --git a/build/static/Timeline.js b/build/static/Timeline.js
new file mode 100644
index 0000000..1e99f3c
--- /dev/null
+++ b/build/static/Timeline.js
@@ -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}
diff --git a/build/static/basic.js b/build/static/basic.js
index c525e80..1f3e695 100644
--- a/build/static/basic.js
+++ b/build/static/basic.js
@@ -119,6 +119,18 @@ class ElemJS {
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.
*/
diff --git a/build/static/chat-input.js b/build/static/chat-input.js
index a21d6a5..a245c52 100644
--- a/build/static/chat-input.js
+++ b/build/static/chat-input.js
@@ -1,17 +1,46 @@
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")
chat.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
- chat.value = ""
event.preventDefault()
+ const body = chat.value
+ send(chat.value)
+ chat.value = ""
+ fixHeight()
}
})
chat.addEventListener("input", () => {
+ fixHeight()
+})
+
+function fixHeight() {
chat.style.height = "0px"
console.log(chat.clientHeight, chat.scrollHeight)
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"
+ }
+ })
+}
diff --git a/build/static/chat.js b/build/static/chat.js
new file mode 100644
index 0000000..1381969
--- /dev/null
+++ b/build/static/chat.js
@@ -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()
diff --git a/build/static/main.css b/build/static/main.css
index 7064538..620c02e 100644
--- a/build/static/main.css
+++ b/build/static/main.css
@@ -208,6 +208,7 @@ body {
display: grid;
grid-template-rows: 1fr auto;
align-items: end;
+ flex: 1;
}
.c-chat__messages {
height: 100%;
diff --git a/build/static/room-picker.js b/build/static/room-picker.js
index 7d19236..592db94 100644
--- a/build/static/room-picker.js
+++ b/build/static/room-picker.js
@@ -1,5 +1,6 @@
import {q, ElemJS, ejs} from "./basic.js"
import {store} from "./store/store.js"
+import {Timeline} from "./Timeline.js"
import * as lsm from "./lsm.js"
function resolveMxc(url, size, method) {
@@ -66,6 +67,7 @@ class Room extends ElemJS {
this.id = id
this.data = data
+ this.timeline = new Timeline()
this.class("c-room")
diff --git a/build/static/store/Subscribable.js b/build/static/store/Subscribable.js
index d205b79..6c7640e 100644
--- a/build/static/store/Subscribable.js
+++ b/build/static/store/Subscribable.js
@@ -23,7 +23,9 @@ class Subscribable {
}
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) {
diff --git a/build/static/sync/sync.js b/build/static/sync/sync.js
index cea0f31..d626638 100644
--- a/build/static/sync/sync.js
+++ b/build/static/sync/sync.js
@@ -48,6 +48,8 @@ function manageSync(root) {
if (!store.rooms.has(id)) {
store.rooms.askAdd(id, room)
}
+ const timeline = store.rooms.get(id).value().timeline
+ timeline.updateEvents(room.timeline.events)
})
} catch (e) {
console.error(root)
diff --git a/spec.js b/spec.js
index 3f529d9..957769e 100644
--- a/spec.js
+++ b/spec.js
@@ -64,6 +64,16 @@ module.exports = [
source: "/js/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",
source: "/assets/fonts/whitney-500.woff",
diff --git a/src/home.pug b/src/home.pug
index fcadfda..be29472 100644
--- a/src/home.pug
+++ b/src/home.pug
@@ -38,6 +38,7 @@ html
script(type="module" src=getStatic("/js/chat-input.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/chat.js"))
title Carbon
body
main.main
@@ -48,7 +49,7 @@ html
.c-rooms#c-rooms
.c-chat
.c-chat__messages
- .c-chat__inner
+ .c-chat__inner#c-chat
+message-notice("You've reached the start of the conversation.")
+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)`,
diff --git a/src/js/Timeline.js b/src/js/Timeline.js
new file mode 100644
index 0000000..1e99f3c
--- /dev/null
+++ b/src/js/Timeline.js
@@ -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}
diff --git a/src/js/basic.js b/src/js/basic.js
index c525e80..1f3e695 100644
--- a/src/js/basic.js
+++ b/src/js/basic.js
@@ -119,6 +119,18 @@ class ElemJS {
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.
*/
diff --git a/src/js/chat-input.js b/src/js/chat-input.js
index a21d6a5..a245c52 100644
--- a/src/js/chat-input.js
+++ b/src/js/chat-input.js
@@ -1,17 +1,46 @@
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")
chat.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
- chat.value = ""
event.preventDefault()
+ const body = chat.value
+ send(chat.value)
+ chat.value = ""
+ fixHeight()
}
})
chat.addEventListener("input", () => {
+ fixHeight()
+})
+
+function fixHeight() {
chat.style.height = "0px"
console.log(chat.clientHeight, chat.scrollHeight)
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"
+ }
+ })
+}
diff --git a/src/js/chat.js b/src/js/chat.js
new file mode 100644
index 0000000..1381969
--- /dev/null
+++ b/src/js/chat.js
@@ -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()
diff --git a/src/js/room-picker.js b/src/js/room-picker.js
index 7d19236..592db94 100644
--- a/src/js/room-picker.js
+++ b/src/js/room-picker.js
@@ -1,5 +1,6 @@
import {q, ElemJS, ejs} from "./basic.js"
import {store} from "./store/store.js"
+import {Timeline} from "./Timeline.js"
import * as lsm from "./lsm.js"
function resolveMxc(url, size, method) {
@@ -66,6 +67,7 @@ class Room extends ElemJS {
this.id = id
this.data = data
+ this.timeline = new Timeline()
this.class("c-room")
diff --git a/src/js/store/Subscribable.js b/src/js/store/Subscribable.js
index d205b79..6c7640e 100644
--- a/src/js/store/Subscribable.js
+++ b/src/js/store/Subscribable.js
@@ -23,7 +23,9 @@ class Subscribable {
}
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) {
diff --git a/src/js/sync/sync.js b/src/js/sync/sync.js
index cea0f31..d626638 100644
--- a/src/js/sync/sync.js
+++ b/src/js/sync/sync.js
@@ -48,6 +48,8 @@ function manageSync(root) {
if (!store.rooms.has(id)) {
store.rooms.askAdd(id, room)
}
+ const timeline = store.rooms.get(id).value().timeline
+ timeline.updateEvents(room.timeline.events)
})
} catch (e) {
console.error(root)
diff --git a/src/sass/components/chat.sass b/src/sass/components/chat.sass
index 3bb3d32..5a911f9 100644
--- a/src/sass/components/chat.sass
+++ b/src/sass/components/chat.sass
@@ -4,7 +4,7 @@
display: grid
grid-template-rows: 1fr auto
align-items: end
- // height: 100%
+ flex: 1
&__messages
height: 100%