From 4b0b5c4b39aa0539b9edb19dd0dd362ecfcf0706 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 19 Oct 2020 18:37:17 +1300 Subject: [PATCH] Better event grouping code --- build/index.html | 6 +- build/static/Anchor.js | 15 +++ build/static/Timeline.js | 146 ++++++++++++++++++++-------- build/static/chat.js | 45 +++++---- build/static/main.css | 12 ++- src/home.pug | 2 +- src/js/Anchor.js | 15 +++ src/js/Timeline.js | 146 ++++++++++++++++++++-------- src/js/chat.js | 45 +++++---- src/sass/components/anchor.sass | 3 + src/sass/components/chat-input.sass | 1 + src/sass/components/chat.sass | 2 +- src/sass/components/messages.sass | 3 + src/sass/main.sass | 1 + 14 files changed, 316 insertions(+), 126 deletions(-) create mode 100644 build/static/Anchor.js create mode 100644 src/js/Anchor.js create mode 100644 src/sass/components/anchor.sass diff --git a/build/index.html b/build/index.html index e48d8af..ae8b0a4 100644 --- a/build/index.html +++ b/build/index.html @@ -2,12 +2,12 @@ - + - + Carbon @@ -20,7 +20,7 @@
-
+
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"