+
diff --git a/build/static/Anchor.js b/build/static/Anchor.js
new file mode 100644
index 0000000..54074c7
--- /dev/null
+++ b/build/static/Anchor.js
@@ -0,0 +1,15 @@
+import {ElemJS} from "./basic.js"
+
+class Anchor extends ElemJS {
+ constructor() {
+ super("div")
+ this.class("c-anchor")
+ }
+
+ scroll() {
+ console.log("anchor scrolled")
+ this.element.scrollIntoView({block: "start"})
+ }
+}
+
+export {Anchor}
diff --git a/build/static/Timeline.js b/build/static/Timeline.js
index 1e99f3c..317ac35 100644
--- a/build/static/Timeline.js
+++ b/build/static/Timeline.js
@@ -1,5 +1,27 @@
-import {ElemJS} from "./basic.js"
+import {ElemJS, ejs} from "./basic.js"
import {Subscribable} from "./store/Subscribable.js"
+import {Anchor} from "./Anchor.js"
+
+function eventSearch(list, event, min = 0, max = -1) {
+ if (list.length === 0) return {success: false, i: 0}
+
+ if (max === -1) max = list.length - 1
+ let mid = Math.floor((max + min) / 2)
+ // success condition
+ if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
+ // failed condition
+ if (min >= max) {
+ while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
+ return {
+ success: false,
+ i: mid + 1
+ }
+ }
+ // 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 {
constructor(data) {
@@ -19,61 +41,106 @@ class Event extends ElemJS {
}
}
+class EventGroup extends ElemJS {
+ constructor(list) {
+ super("div")
+ this.class("c-message-group")
+ this.list = list
+ this.data = {
+ sender: list[0].data.sender,
+ origin_server_ts: list[0].data.origin_server_ts
+ }
+ this.child(
+ ejs("div").class("c-message-group__avatar").child(
+ ejs("div").class("c-message-group__icon")
+ ),
+ this.messages = ejs("div").class("c-message-group__messages").child(
+ ejs("div").class("c-message-group__intro").child(
+ ejs("div").class("c-message-group__name").text(this.data.sender),
+ ejs("div").class("c-message-group__date").text("at 4:20 pm")
+ ),
+ ...this.list
+ )
+ )
+ }
+
+ addEvent(event) {
+ const index = eventSearch(this.list, event).i
+ this.list.splice(index, 0, event)
+ this.messages.childAt(index + 1, event)
+ }
+}
+
+class ReactiveTimeline extends ElemJS {
+ constructor(list) {
+ super("div")
+ this.class("c-event-groups")
+ this.list = list
+ this.render()
+ }
+
+ addEvent(event) {
+ const search = eventSearch(this.list, event)
+ // console.log(search, this.list.map(l => l.data.sender), event.data)
+ if (!search.success && search.i >= 1) this.tryAddGroups(event, [search.i-1, search.i])
+ else this.tryAddGroups(event, [search.i])
+ }
+
+ tryAddGroups(event, indices) {
+ const success = indices.some(i => {
+ if (!this.list[i]) {
+ // if (printed++ < 100) console.log("tryadd success, created group")
+ const group = new EventGroup([event])
+ this.list.splice(i, 0, group)
+ this.childAt(i, group)
+ return true
+ } else if (this.list[i] && this.list[i].data.sender === event.data.sender) {
+ // if (printed++ < 100) console.log("tryadd success, using existing group")
+ this.list[i].addEvent(event)
+ return true
+ }
+ })
+ if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data)
+ }
+
+ render() {
+ this.clearChildren()
+ this.list.forEach(group => this.child(group))
+ this.anchor = new Anchor()
+ this.child(this.anchor)
+ }
+}
+
class Timeline extends Subscribable {
constructor() {
super()
Object.assign(this.events, {
- addItem: [],
- removeItem: []
+ beforeChange: []
})
Object.assign(this.eventDeps, {
- addItem: [],
- removeItem: []
+ beforeChange: []
})
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)
+ this.reactiveTimeline = new ReactiveTimeline([])
}
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))
+ this.broadcast("beforeChange")
+ for (const eventData of events) {
+ if (this.map.has(eventData.event_id)) {
+ this.map.get(eventData.event_id).update(eventData)
} 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})
+ const event = new Event(eventData)
+ this.reactiveTimeline.addEvent(event)
}
}
}
+ getTimeline() {
+ return this.reactiveTimeline
+ }
+/*
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
@@ -90,6 +157,7 @@ class Timeline extends Subscribable {
if (currentGroup.length) groups.push(currentGroup)
return groups
}
+ */
}
export {Timeline}
diff --git a/build/static/chat.js b/build/static/chat.js
index 1381969..38c4684 100644
--- a/build/static/chat.js
+++ b/build/static/chat.js
@@ -1,6 +1,8 @@
import {ElemJS, q, ejs} from "./basic.js"
import {store} from "./store/store.js"
+const chatMessages = q("#c-chat-messages")
+
class Chat extends ElemJS {
constructor() {
super(q("#c-chat"))
@@ -25,10 +27,20 @@ class Chat extends ElemJS {
// 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 subscription = () => {
+ // 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.
+ // once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor.
+ let oldDifference = chatMessages.scrollHeight - chatMessages.clientHeight
+ setTimeout(() => {
+ let newDifference = chatMessages.scrollHeight - chatMessages.clientHeight
+ console.log("height difference", oldDifference, newDifference)
+ if (oldDifference < 24) { // this is jank
+ this.element.parentElement.scrollBy(0, 1000)
+ }
+ }, 0)
}
- const name = "addItem"
+ const name = "beforeChange"
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription)
}
@@ -38,25 +50,16 @@ class Chat extends ElemJS {
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))
- )
- )
- )
- }
+ const reactiveTimeline = store.activeRoom.value().timeline.getTimeline()
+ this.child(reactiveTimeline)
+ setTimeout(() => {
+ this.element.parentElement.scrollBy(0, 1)
+ reactiveTimeline.anchor.scroll()
+ }, 0)
}
}
}
-new Chat()
+const chat = new Chat()
+
+export {chat}
diff --git a/build/static/main.css b/build/static/main.css
index 620c02e..1ef8ece 100644
--- a/build/static/main.css
+++ b/build/static/main.css
@@ -136,6 +136,10 @@ body {
border-radius: 0px 6px 6px 0px;
}
+.c-event-groups * {
+ overflow-anchor: none;
+}
+
.c-message-group, .c-message-event {
margin-top: 12px;
padding-top: 12px;
@@ -206,7 +210,7 @@ body {
.c-chat {
display: grid;
- grid-template-rows: 1fr auto;
+ grid-template-rows: 1fr 82px;
align-items: end;
flex: 1;
}
@@ -224,6 +228,7 @@ body {
.c-chat-input {
width: 100%;
border-top: 2px solid #4b4e54;
+ background-color: #36393e;
}
.c-chat-input__textarea {
width: calc(100% - 40px);
@@ -241,4 +246,9 @@ body {
border: none;
border-radius: 8px;
resize: none;
+}
+
+.c-anchor {
+ overflow-anchor: auto;
+ height: 1px;
}
\ No newline at end of file
diff --git a/src/home.pug b/src/home.pug
index 9bf9781..ea11138 100644
--- a/src/home.pug
+++ b/src/home.pug
@@ -48,7 +48,7 @@ html
.c-groups__container#c-groups-list
.c-rooms#c-rooms
.c-chat
- .c-chat__messages
+ .c-chat__messages#c-chat-messages
.c-chat__inner#c-chat
.c-chat-input
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
\ No newline at end of file
diff --git a/src/js/Anchor.js b/src/js/Anchor.js
new file mode 100644
index 0000000..54074c7
--- /dev/null
+++ b/src/js/Anchor.js
@@ -0,0 +1,15 @@
+import {ElemJS} from "./basic.js"
+
+class Anchor extends ElemJS {
+ constructor() {
+ super("div")
+ this.class("c-anchor")
+ }
+
+ scroll() {
+ console.log("anchor scrolled")
+ this.element.scrollIntoView({block: "start"})
+ }
+}
+
+export {Anchor}
diff --git a/src/js/Timeline.js b/src/js/Timeline.js
index 1e99f3c..317ac35 100644
--- a/src/js/Timeline.js
+++ b/src/js/Timeline.js
@@ -1,5 +1,27 @@
-import {ElemJS} from "./basic.js"
+import {ElemJS, ejs} from "./basic.js"
import {Subscribable} from "./store/Subscribable.js"
+import {Anchor} from "./Anchor.js"
+
+function eventSearch(list, event, min = 0, max = -1) {
+ if (list.length === 0) return {success: false, i: 0}
+
+ if (max === -1) max = list.length - 1
+ let mid = Math.floor((max + min) / 2)
+ // success condition
+ if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
+ // failed condition
+ if (min >= max) {
+ while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
+ return {
+ success: false,
+ i: mid + 1
+ }
+ }
+ // 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 {
constructor(data) {
@@ -19,61 +41,106 @@ class Event extends ElemJS {
}
}
+class EventGroup extends ElemJS {
+ constructor(list) {
+ super("div")
+ this.class("c-message-group")
+ this.list = list
+ this.data = {
+ sender: list[0].data.sender,
+ origin_server_ts: list[0].data.origin_server_ts
+ }
+ this.child(
+ ejs("div").class("c-message-group__avatar").child(
+ ejs("div").class("c-message-group__icon")
+ ),
+ this.messages = ejs("div").class("c-message-group__messages").child(
+ ejs("div").class("c-message-group__intro").child(
+ ejs("div").class("c-message-group__name").text(this.data.sender),
+ ejs("div").class("c-message-group__date").text("at 4:20 pm")
+ ),
+ ...this.list
+ )
+ )
+ }
+
+ addEvent(event) {
+ const index = eventSearch(this.list, event).i
+ this.list.splice(index, 0, event)
+ this.messages.childAt(index + 1, event)
+ }
+}
+
+class ReactiveTimeline extends ElemJS {
+ constructor(list) {
+ super("div")
+ this.class("c-event-groups")
+ this.list = list
+ this.render()
+ }
+
+ addEvent(event) {
+ const search = eventSearch(this.list, event)
+ // console.log(search, this.list.map(l => l.data.sender), event.data)
+ if (!search.success && search.i >= 1) this.tryAddGroups(event, [search.i-1, search.i])
+ else this.tryAddGroups(event, [search.i])
+ }
+
+ tryAddGroups(event, indices) {
+ const success = indices.some(i => {
+ if (!this.list[i]) {
+ // if (printed++ < 100) console.log("tryadd success, created group")
+ const group = new EventGroup([event])
+ this.list.splice(i, 0, group)
+ this.childAt(i, group)
+ return true
+ } else if (this.list[i] && this.list[i].data.sender === event.data.sender) {
+ // if (printed++ < 100) console.log("tryadd success, using existing group")
+ this.list[i].addEvent(event)
+ return true
+ }
+ })
+ if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data)
+ }
+
+ render() {
+ this.clearChildren()
+ this.list.forEach(group => this.child(group))
+ this.anchor = new Anchor()
+ this.child(this.anchor)
+ }
+}
+
class Timeline extends Subscribable {
constructor() {
super()
Object.assign(this.events, {
- addItem: [],
- removeItem: []
+ beforeChange: []
})
Object.assign(this.eventDeps, {
- addItem: [],
- removeItem: []
+ beforeChange: []
})
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)
+ this.reactiveTimeline = new ReactiveTimeline([])
}
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))
+ this.broadcast("beforeChange")
+ for (const eventData of events) {
+ if (this.map.has(eventData.event_id)) {
+ this.map.get(eventData.event_id).update(eventData)
} 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})
+ const event = new Event(eventData)
+ this.reactiveTimeline.addEvent(event)
}
}
}
+ getTimeline() {
+ return this.reactiveTimeline
+ }
+/*
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
@@ -90,6 +157,7 @@ class Timeline extends Subscribable {
if (currentGroup.length) groups.push(currentGroup)
return groups
}
+ */
}
export {Timeline}
diff --git a/src/js/chat.js b/src/js/chat.js
index 1381969..38c4684 100644
--- a/src/js/chat.js
+++ b/src/js/chat.js
@@ -1,6 +1,8 @@
import {ElemJS, q, ejs} from "./basic.js"
import {store} from "./store/store.js"
+const chatMessages = q("#c-chat-messages")
+
class Chat extends ElemJS {
constructor() {
super(q("#c-chat"))
@@ -25,10 +27,20 @@ class Chat extends ElemJS {
// 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 subscription = () => {
+ // 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.
+ // once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor.
+ let oldDifference = chatMessages.scrollHeight - chatMessages.clientHeight
+ setTimeout(() => {
+ let newDifference = chatMessages.scrollHeight - chatMessages.clientHeight
+ console.log("height difference", oldDifference, newDifference)
+ if (oldDifference < 24) { // this is jank
+ this.element.parentElement.scrollBy(0, 1000)
+ }
+ }, 0)
}
- const name = "addItem"
+ const name = "beforeChange"
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription)
}
@@ -38,25 +50,16 @@ class Chat extends ElemJS {
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))
- )
- )
- )
- }
+ const reactiveTimeline = store.activeRoom.value().timeline.getTimeline()
+ this.child(reactiveTimeline)
+ setTimeout(() => {
+ this.element.parentElement.scrollBy(0, 1)
+ reactiveTimeline.anchor.scroll()
+ }, 0)
}
}
}
-new Chat()
+const chat = new Chat()
+
+export {chat}
diff --git a/src/sass/components/anchor.sass b/src/sass/components/anchor.sass
new file mode 100644
index 0000000..e3d7068
--- /dev/null
+++ b/src/sass/components/anchor.sass
@@ -0,0 +1,3 @@
+.c-anchor
+ overflow-anchor: auto
+ height: 1px
diff --git a/src/sass/components/chat-input.sass b/src/sass/components/chat-input.sass
index aff0f26..892fee6 100644
--- a/src/sass/components/chat-input.sass
+++ b/src/sass/components/chat-input.sass
@@ -8,6 +8,7 @@
.c-chat-input
width: 100%
border-top: 2px solid c.$divider
+ background-color: c.$dark
&__textarea
width: calc(100% - 40px)
diff --git a/src/sass/components/chat.sass b/src/sass/components/chat.sass
index 5a911f9..5ca48e0 100644
--- a/src/sass/components/chat.sass
+++ b/src/sass/components/chat.sass
@@ -2,7 +2,7 @@
.c-chat
display: grid
- grid-template-rows: 1fr auto
+ grid-template-rows: 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
align-items: end
flex: 1
diff --git a/src/sass/components/messages.sass b/src/sass/components/messages.sass
index 0303541..779cd58 100644
--- a/src/sass/components/messages.sass
+++ b/src/sass/components/messages.sass
@@ -1,5 +1,8 @@
@use "../colors" as c
+.c-event-groups *
+ overflow-anchor: none
+
.c-message-group, .c-message-event
margin-top: 12px
padding-top: 12px
diff --git a/src/sass/main.sass b/src/sass/main.sass
index c3f0594..d342bbb 100644
--- a/src/sass/main.sass
+++ b/src/sass/main.sass
@@ -4,3 +4,4 @@
@use "./components/messages"
@use "./components/chat"
@use "./components/chat-input"
+@use "./components/anchor"