const {ElemJS, ejs, q} = require("./basic.js") const {Subscribable} = require("./store/subscribable.js") const {SubscribeValue} = require("./store/subscribe_value.js") const {SubscribeMap} = require("./store/subscribe_map.js") const {store} = require("./store/store.js") const {Anchor} = require("./anchor.js") const {Sender} = require("./sender.js") const {ReadMarker, markFullyRead} = require("./read-marker.js") const lsm = require("./lsm.js") const {resolveMxc} = require("./functions.js") const {renderEvent} = require("./events/render-event") const {dateFormatter} = require("./date-formatter") let debug = false const NO_MAX = Symbol("NO_MAX") let sentIndex = 0 function getTxnId() { return Date.now() + (sentIndex++) } function eventSearch(list, event, min = 0, max = NO_MAX) { if (list.length === 0) return {success: false, i: 0} if (max === NO_MAX) 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 EventGroup extends ElemJS { constructor(reactive, list) { super("div") this.class("c-message-group") this.reactive = reactive this.list = list this.data = { sender: list[0].data.sender, origin_server_ts: list[0].data.origin_server_ts } this.sender = new Sender(this.reactive.id, this.data.sender) this.child( this.sender.avatar, this.messages = ejs("div").class("c-message-group__messages").child( ejs("div").class("c-message-group__intro").child( this.sender.name, ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts)) ), ...this.list ) ) } canGroup() { if (this.list.length) return this.list[0].canGroup() else return true } addEvent(event) { const index = eventSearch(this.list, event).i event.setGroup(this) this.list.splice(index, 0, event) this.messages.childAt(index + 1, event) } removeEvent(event) { const search = eventSearch(this.list, event) if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`) const index = search.i // actually remove the event this.list.splice(index, 1) event.remove() // should get everything else if (this.list.length === 0) this.reactive.removeGroup(this) } } /** Displays a spinner and creates an event to notify timeline to load more messages */ class LoadMore extends ElemJS { constructor(id) { super("div") this.class("c-message-notice") this.id = id this.child( ejs("div").class("c-message-notice__inner").child( ejs("span").class("loading-icon"), ejs("span").text("Loading more...") ) ) const intersection_observer = new IntersectionObserver(e => this.intersectionHandler(e)) intersection_observer.observe(this.element) } intersectionHandler(e) { if (e.some(e => e.isIntersecting)) { store.rooms.get(this.id).value().timeline.loadScrollback() } } } class ReactiveTimeline extends ElemJS { constructor(id, list) { super("div") this.class("c-event-groups") this.id = id this.list = list this.loadMore = new LoadMore(this.id) this.render() } addEvent(event) { this.loadMore.remove() // if (debug) console.log("running search", this.list, event) // if (debug) debugger; const search = eventSearch(this.list, event) // console.log(search, this.list.map(l => l.data.sender), event.data) if (!search.success) { if (search.i >= 1) { // add at end this.tryAddGroups(event, [search.i - 1, search.i]) } else { // add at start this.tryAddGroups(event, [0, -1]) } } else { this.tryAddGroups(event, [search.i]) } this.loadMore = new LoadMore(this.id) this.childAt(0, this.loadMore) } tryAddGroups(event, indices) { const createGroupAt = i => { // if (printed++ < 100) console.log("tryadd success, created group") if (i === -1) { // here, -1 means at the start, before the first group i = 0 // jank but it does the trick } if (event.canGroup()) { const group = new EventGroup(this, [event]) this.list.splice(i, 0, group) this.childAt(i, group) event.setGroup(group) } else { this.list.splice(i, 0, event) this.childAt(i, event) } } const success = indices.some(i => { if (!this.list[i]) { createGroupAt(i) return true } else if (event.canGroup() && this.list[i] && this.list[i].canGroup() && 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) // I believe all the bugs are now fixed. Lol. if (!success) createGroupAt(indices[0]) } removeGroup(group) { const index = this.list.indexOf(group) this.list.splice(index, 1) group.remove() // should get everything else } render() { this.clearChildren() this.child(this.loadMore) this.list.forEach(group => this.child(group)) this.anchor = new Anchor() this.child(this.anchor) } } class Timeline extends Subscribable { constructor(room) { super() Object.assign(this.events, { beforeChange: [], afterChange: [], beforeScrollbackLoad: [], afterScrollbackLoad: [], }) Object.assign(this.eventDeps, { beforeChange: [], afterChange: [], beforeScrollbackLoad: [], afterScrollbackLoad: [], }) this.room = room this.id = this.room.id this.list = [] this.map = new Map() this.reactiveTimeline = new ReactiveTimeline(this.id, []) this.latest = 0 this.latestEventID = null this.pending = new Set() this.pendingEdits = [] this.typing = new SubscribeValue().set([]) this.userReads = new SubscribeMap(SubscribeValue) this.readMarker = new ReadMarker(this) this.from = null } updateStateEvents(events) { for (const eventData of events) { let id = eventData.event_id if (eventData.type === "m.room.member") { // update members if (eventData.membership !== "leave") { const member = this.room.members.get(eventData.state_key) // only use the latest state if (!member.exists() || eventData.origin_server_ts > member.data.origin_server_ts) { member.set(eventData) } } } } } updateEvents(events) { this.broadcast("beforeChange") // handle state events this.updateStateEvents(events) for (const eventData of events) { // set variables let id = eventData.event_id if (eventData.origin_server_ts > this.latest) { this.latest = eventData.origin_server_ts this.latestEventID = id } // handle local echoes if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) { const pendingID = eventData.content["chat.carbon.message.pending_id"] if (id !== pendingID) { const target = this.map.get(pendingID) this.map.set(id, target) this.map.delete(pendingID) // update fully read marker - assume we have fully read up to messages we send markFullyRead(this.id, id) } } // handle timeline events if (this.map.has(id)) { // update existing event this.map.get(id).update(eventData) } else { // skip redacted events if (eventData.unsigned && eventData.unsigned.redacted_by) { continue } // handle redactions if (eventData.type === "m.room.redaction") { if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent() continue } // handle edits if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") { this.pendingEdits.push(eventData) continue } // add new event const event = renderEvent(eventData) this.map.set(id, event) this.reactiveTimeline.addEvent(event) // update read receipt for sender on their own event this.moveReadReceipt(eventData.sender, id) } } // apply edits this.pendingEdits = this.pendingEdits.filter(eventData => { const replaces = eventData.content["m.relates_to"].event_id if (this.map.has(replaces)) { const event = this.map.get(replaces) event.data.content = eventData.content["m.new_content"] event.setEdited(eventData.origin_server_ts) event.update(event.data) return false // handled; remove from list } else { return true // we don't have the event it edits yet; keep in list } }) this.broadcast("afterChange") } updateEphemeral(events) { for (const eventData of events) { if (eventData.type === "m.typing") { this.typing.set(eventData.content.user_ids) } if (eventData.type === "m.receipt") { for (const eventID of Object.keys(eventData.content)) { for (const user of Object.keys(eventData.content[eventID]["m.read"])) { this.moveReadReceipt(user, eventID) } } // console.log("Updated read receipts:", this.userReads) } } } moveReadReceipt(user, eventID) { // check for a previous event to move from const prev = this.userReads.get(user) if (prev.exists()) { const prevID = prev.value() if (this.map.has(prevID) && this.map.has(eventID)) { // ensure new message came later if (this.map.get(eventID).data.origin_server_ts < this.map.get(prevID).data.origin_server_ts) return this.map.get(prevID).readBy.delete(user) } } // set on new message this.userReads.set(user, eventID) if (this.map.has(eventID)) this.map.get(eventID).readBy.add(user) } updateUnreadCount(count) { this.room.number.update({unreads: count}) } updateNotificationCount(count) { this.room.number.update({notifications: count}) } removeEvent(id) { if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`) this.map.get(id).removeEvent() this.map.delete(id) } getTimeline() { return this.reactiveTimeline } async loadScrollback() { debug = true if (!this.from) return // no more scrollback for this timeline const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/messages`) url.searchParams.set("access_token", lsm.get("access_token")) url.searchParams.set("from", this.from) url.searchParams.set("dir", "b") url.searchParams.set("limit", "20") const filter = { lazy_load_members: true } url.searchParams.set("filter", JSON.stringify(filter)) const root = await fetch(url.toString()).then(res => res.json()) this.broadcast("beforeScrollbackLoad") this.from = root.end // console.log(this.updateEvents, root.chunk) if (root.state) this.updateStateEvents(root.state) if (root.chunk.length) { // there are events to display this.updateEvents(root.chunk) } if (!root.chunk.length || !root.end) { // we reached the top of the scrollback this.reactiveTimeline.loadMore.remove() } this.broadcast("afterScrollbackLoad") } send(type, content) { const tx = getTxnId() const id = `pending$${tx}` this.pending.add(id) content["chat.carbon.message.pending_id"] = id const fakeEvent = { type, origin_server_ts: Date.now(), event_id: id, sender: lsm.get("mx_user_id"), content, pending: true } this.updateEvents([fakeEvent]) return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, { method: "PUT", body: JSON.stringify(content), headers: { "Content-Type": "application/json" } }) } } module.exports = {Timeline}