Better event grouping code

This commit is contained in:
Cadence Ember 2020-10-19 18:37:17 +13:00
parent aaa7305b1c
commit 4b0b5c4b39
Signed by: cadence
GPG key ID: BC1C2C61CF521B17
14 changed files with 316 additions and 126 deletions

View file

@ -2,12 +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=0c50689ce5"> <link rel="stylesheet" type="text/css" href="static/main.css?static=da499eb39c">
<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=e8b21037fa"></script> <script type="module" src="static/chat-input.js?static=e8b21037fa"></script>
<script type="module" src="static/room-picker.js?static=1d38378110"></script> <script type="module" src="static/room-picker.js?static=1d38378110"></script>
<script type="module" src="static/sync/sync.js?static=9e31c8a727"></script> <script type="module" src="static/sync/sync.js?static=9e31c8a727"></script>
<script type="module" src="static/chat.js?static=d4a415f1ae"></script> <script type="module" src="static/chat.js?static=8a04bee48d"></script>
<title>Carbon</title> <title>Carbon</title>
</head> </head>
<body> <body>
@ -20,7 +20,7 @@
</div> </div>
<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" id="c-chat-messages">
<div class="c-chat__inner" id="c-chat"></div> <div class="c-chat__inner" id="c-chat"></div>
</div> </div>
<div class="c-chat-input"> <div class="c-chat-input">

15
build/static/Anchor.js Normal file
View file

@ -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}

View file

@ -1,5 +1,27 @@
import {ElemJS} 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"
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 { class Event extends ElemJS {
constructor(data) { 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 { class Timeline extends Subscribable {
constructor() { constructor() {
super() super()
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], beforeChange: []
removeItem: []
}) })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
addItem: [], beforeChange: []
removeItem: []
}) })
this.list = [] this.list = []
this.map = new Map() this.map = new Map()
this.elementsMap = new Map() this.reactiveTimeline = new ReactiveTimeline([])
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) { updateEvents(events) {
for (const event of events) { this.broadcast("beforeChange")
if (this.map.has(event.event_id)) { for (const eventData of events) {
this.map.set(event.event_id, event) if (this.map.has(eventData.event_id)) {
this.elementsMap.get(event.event_id).update(this.map.get(event.event_id)) this.map.get(eventData.event_id).update(eventData)
} else { } else {
const index = this._binarySearch(event).i const event = new Event(eventData)
this.list.splice(index, 0, event) this.reactiveTimeline.addEvent(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})
} }
} }
} }
getTimeline() {
return this.reactiveTimeline
}
/*
getGroupedEvents() { getGroupedEvents() {
let currentSender = Symbol("N/A") let currentSender = Symbol("N/A")
let groups = [] let groups = []
@ -90,6 +157,7 @@ class Timeline extends Subscribable {
if (currentGroup.length) groups.push(currentGroup) if (currentGroup.length) groups.push(currentGroup)
return groups return groups
} }
*/
} }
export {Timeline} export {Timeline}

View file

@ -1,6 +1,8 @@
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")
class Chat extends ElemJS { class Chat extends ElemJS {
constructor() { constructor() {
super(q("#c-chat")) super(q("#c-chat"))
@ -25,10 +27,20 @@ class Chat extends ElemJS {
// 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 = (_, {element, index}) => { const subscription = () => {
this.childAt(index, element) // 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}) this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription) timeline.subscribe(name, subscription)
} }
@ -38,25 +50,16 @@ class Chat extends ElemJS {
render() { render() {
this.clearChildren() this.clearChildren()
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline const reactiveTimeline = store.activeRoom.value().timeline.getTimeline()
for (const group of timeline.getGroupedEvents()) { this.child(reactiveTimeline)
const first = group[0] setTimeout(() => {
this.child( this.element.parentElement.scrollBy(0, 1)
ejs("div").class("c-message-group").child( reactiveTimeline.anchor.scroll()
ejs("div").class("c-message-group__avatar").child( }, 0)
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() const chat = new Chat()
export {chat}

View file

@ -136,6 +136,10 @@ body {
border-radius: 0px 6px 6px 0px; border-radius: 0px 6px 6px 0px;
} }
.c-event-groups * {
overflow-anchor: none;
}
.c-message-group, .c-message-event { .c-message-group, .c-message-event {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
@ -206,7 +210,7 @@ body {
.c-chat { .c-chat {
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: 1fr 82px;
align-items: end; align-items: end;
flex: 1; flex: 1;
} }
@ -224,6 +228,7 @@ body {
.c-chat-input { .c-chat-input {
width: 100%; width: 100%;
border-top: 2px solid #4b4e54; border-top: 2px solid #4b4e54;
background-color: #36393e;
} }
.c-chat-input__textarea { .c-chat-input__textarea {
width: calc(100% - 40px); width: calc(100% - 40px);
@ -241,4 +246,9 @@ body {
border: none; border: none;
border-radius: 8px; border-radius: 8px;
resize: none; resize: none;
}
.c-anchor {
overflow-anchor: auto;
height: 1px;
} }

View file

@ -48,7 +48,7 @@ html
.c-groups__container#c-groups-list .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__inner#c-chat .c-chat__inner#c-chat
.c-chat-input .c-chat-input
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea

15
src/js/Anchor.js Normal file
View file

@ -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}

View file

@ -1,5 +1,27 @@
import {ElemJS} 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"
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 { class Event extends ElemJS {
constructor(data) { 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 { class Timeline extends Subscribable {
constructor() { constructor() {
super() super()
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], beforeChange: []
removeItem: []
}) })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
addItem: [], beforeChange: []
removeItem: []
}) })
this.list = [] this.list = []
this.map = new Map() this.map = new Map()
this.elementsMap = new Map() this.reactiveTimeline = new ReactiveTimeline([])
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) { updateEvents(events) {
for (const event of events) { this.broadcast("beforeChange")
if (this.map.has(event.event_id)) { for (const eventData of events) {
this.map.set(event.event_id, event) if (this.map.has(eventData.event_id)) {
this.elementsMap.get(event.event_id).update(this.map.get(event.event_id)) this.map.get(eventData.event_id).update(eventData)
} else { } else {
const index = this._binarySearch(event).i const event = new Event(eventData)
this.list.splice(index, 0, event) this.reactiveTimeline.addEvent(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})
} }
} }
} }
getTimeline() {
return this.reactiveTimeline
}
/*
getGroupedEvents() { getGroupedEvents() {
let currentSender = Symbol("N/A") let currentSender = Symbol("N/A")
let groups = [] let groups = []
@ -90,6 +157,7 @@ class Timeline extends Subscribable {
if (currentGroup.length) groups.push(currentGroup) if (currentGroup.length) groups.push(currentGroup)
return groups return groups
} }
*/
} }
export {Timeline} export {Timeline}

View file

@ -1,6 +1,8 @@
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")
class Chat extends ElemJS { class Chat extends ElemJS {
constructor() { constructor() {
super(q("#c-chat")) super(q("#c-chat"))
@ -25,10 +27,20 @@ class Chat extends ElemJS {
// 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 = (_, {element, index}) => { const subscription = () => {
this.childAt(index, element) // 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}) this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription) timeline.subscribe(name, subscription)
} }
@ -38,25 +50,16 @@ class Chat extends ElemJS {
render() { render() {
this.clearChildren() this.clearChildren()
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline const reactiveTimeline = store.activeRoom.value().timeline.getTimeline()
for (const group of timeline.getGroupedEvents()) { this.child(reactiveTimeline)
const first = group[0] setTimeout(() => {
this.child( this.element.parentElement.scrollBy(0, 1)
ejs("div").class("c-message-group").child( reactiveTimeline.anchor.scroll()
ejs("div").class("c-message-group__avatar").child( }, 0)
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() const chat = new Chat()
export {chat}

View file

@ -0,0 +1,3 @@
.c-anchor
overflow-anchor: auto
height: 1px

View file

@ -8,6 +8,7 @@
.c-chat-input .c-chat-input
width: 100% width: 100%
border-top: 2px solid c.$divider border-top: 2px solid c.$divider
background-color: c.$dark
&__textarea &__textarea
width: calc(100% - 40px) width: calc(100% - 40px)

View file

@ -2,7 +2,7 @@
.c-chat .c-chat
display: grid 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 align-items: end
flex: 1 flex: 1

View file

@ -1,5 +1,8 @@
@use "../colors" as c @use "../colors" as c
.c-event-groups *
overflow-anchor: none
.c-message-group, .c-message-event .c-message-group, .c-message-event
margin-top: 12px margin-top: 12px
padding-top: 12px padding-top: 12px

View file

@ -4,3 +4,4 @@
@use "./components/messages" @use "./components/messages"
@use "./components/chat" @use "./components/chat"
@use "./components/chat-input" @use "./components/chat-input"
@use "./components/anchor"