347 lines
9.7 KiB
JavaScript
347 lines
9.7 KiB
JavaScript
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")
|
|
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
|
|
)
|
|
)
|
|
}
|
|
|
|
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 = renderEvent(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}
|