const {ElemJS, ejs} = require("./basic.js") const {Subscribable} = require("./store/subscribable.js") const {store} = require("./store/store.js") const {Anchor} = require("./anchor.js") const {Sender} = require("./sender.js") const lsm = require("./lsm.js") let debug = false const NO_MAX = Symbol("NO_MAX") const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"}) 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 Event extends ElemJS { constructor(data) { super("div") this.class("c-message") this.data = null this.group = null this.editedAt = null this.update(data) } // predicates canGroup() { return this.data.type === "m.room.message" } // operations setGroup(group) { this.group = group } setEdited(time) { this.editedAt = time this.render() } update(data) { this.data = data this.render() } removeEvent() { if (this.group) this.group.removeEvent(this) else this.remove() } render() { this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending") if (this.data.type === "m.room.message") { this.text(this.data.content.body) } else if (this.data.type === "m.room.member") { if (this.data.content.membership === "join") { this.child(ejs("i").text("joined the room")) } else if (this.data.content.membership === "invite") { this.child(ejs("i").text(`invited ${this.data.content.displayname} to the room`)) } else if (this.data.content.membership === "leave") { this.child(ejs("i").text("left the room")) } else { this.child(ejs("i").text("unknown membership event")) } } else if (this.data.type === "m.room.encrypted") { this.child(ejs("i").text("Carbon does not yet support encrypted messages.")) } else { this.child(ejs("i").text(`Unsupported event type ${this.data.type}`)) } if (this.editedAt) { this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt))) } } } 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 ) ) } 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 success = indices.some(i => { if (!this.list[i]) { // if (printed++ < 100) console.log("tryadd success, created group") const group = new EventGroup(this, [event]) if (i === -1) { // here, -1 means at the start, before the first group i = 0 // jank but it does the trick } this.list.splice(i, 0, group) this.childAt(i, group) event.setGroup(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) } 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.pending = new Set() this.pendingEdits = [] 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 this.latest = Math.max(this.latest, eventData.origin_server_ts) let id = eventData.event_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 target = this.map.get(eventData.content["chat.carbon.message.pending_id"]) this.map.set(id, target) this.map.delete(eventData.content["chat.carbon.message.pending_id"]) } // handle timeline events if (this.map.has(id)) { // update existing event this.map.get(id).update(eventData) } else { // skip displaying events that we don't know how to if (eventData.type === "m.reaction") { continue } // 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 = new Event(eventData) this.map.set(id, event) this.reactiveTimeline.addEvent(event) } } // 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") } 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) throw new Error("Can't load scrollback, no from token") 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) } else { // we reached the top of the scrollback this.reactiveTimeline.loadMore.remove() } this.broadcast("afterScrollbackLoad") } send(body) { const tx = getTxnId() const id = `pending$${tx}` this.pending.add(id) const content = { msgtype: "m.text", body, "chat.carbon.message.pending_id": id } const fakeEvent = { type: "m.room.message", 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}