From 33e4a7d7cb0a820a729744df8571f71fefc5e5d3 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 16 Oct 2020 02:24:15 +1300 Subject: [PATCH] Now technically a chat app --- build/index.html | 11 ++-- build/static/Timeline.js | 95 ++++++++++++++++++++++++++++++ build/static/basic.js | 12 ++++ build/static/chat-input.js | 33 ++++++++++- build/static/chat.js | 62 +++++++++++++++++++ build/static/main.css | 1 + build/static/room-picker.js | 2 + build/static/store/Subscribable.js | 4 +- build/static/sync/sync.js | 2 + spec.js | 10 ++++ src/home.pug | 3 +- src/js/Timeline.js | 95 ++++++++++++++++++++++++++++++ src/js/basic.js | 12 ++++ src/js/chat-input.js | 33 ++++++++++- src/js/chat.js | 62 +++++++++++++++++++ src/js/room-picker.js | 2 + src/js/store/Subscribable.js | 4 +- src/js/sync/sync.js | 2 + src/sass/components/chat.sass | 2 +- 19 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 build/static/Timeline.js create mode 100644 build/static/chat.js create mode 100644 src/js/Timeline.js create mode 100644 src/js/chat.js diff --git a/build/index.html b/build/index.html index 07eb256..5e59cd7 100644 --- a/build/index.html +++ b/build/index.html @@ -2,11 +2,12 @@ - + - - - + + + + Carbon @@ -20,7 +21,7 @@
-
+
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%