forked from cadence/Carbon
		
	Better event grouping code
This commit is contained in:
		
							parent
							
								
									aaa7305b1c
								
							
						
					
					
						commit
						4b0b5c4b39
					
				
					 14 changed files with 316 additions and 126 deletions
				
			
		| 
						 | 
				
			
			@ -2,12 +2,12 @@
 | 
			
		|||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <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/chat-input.js?static=e8b21037fa"></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/chat.js?static=d4a415f1ae"></script>
 | 
			
		||||
    <script type="module" src="static/chat.js?static=8a04bee48d"></script>
 | 
			
		||||
    <title>Carbon</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +20,7 @@
 | 
			
		|||
      </div>
 | 
			
		||||
      <div class="c-rooms" id="c-rooms"></div>
 | 
			
		||||
      <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>
 | 
			
		||||
        <div class="c-chat-input">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										15
									
								
								build/static/Anchor.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								build/static/Anchor.js
									
										
									
									
									
										Normal 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}
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
							
								
								
									
										15
									
								
								src/js/Anchor.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/js/Anchor.js
									
										
									
									
									
										Normal 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}
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								src/sass/components/anchor.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/sass/components/anchor.sass
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
.c-anchor
 | 
			
		||||
  overflow-anchor: auto
 | 
			
		||||
  height: 1px
 | 
			
		||||
| 
						 | 
				
			
			@ -8,6 +8,7 @@
 | 
			
		|||
.c-chat-input
 | 
			
		||||
  width: 100%
 | 
			
		||||
  border-top: 2px solid c.$divider
 | 
			
		||||
  background-color: c.$dark
 | 
			
		||||
 | 
			
		||||
  &__textarea
 | 
			
		||||
    width: calc(100% - 40px)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,3 +4,4 @@
 | 
			
		|||
@use "./components/messages"
 | 
			
		||||
@use "./components/chat"
 | 
			
		||||
@use "./components/chat-input"
 | 
			
		||||
@use "./components/anchor"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue