Carbon/src/js/Timeline.js
Cadence Ember 988dd1050b
All checks were successful
continuous-integration/drone/push Build is passing
Add note about encrypted messages
2020-10-21 20:27:22 +13:00

368 lines
10 KiB
JavaScript

import {ElemJS, ejs} from $to_relative "/js/basic.js"
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
import {store} from $to_relative "/js/store/store.js"
import {Anchor} from $to_relative "/js/Anchor.js"
import * as lsm from $to_relative "/js/lsm.js"
import {resolveMxc} from $to_relative "/js/functions.js"
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 = -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) {
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 {
this.child(ejs("i").text("left the room"))
}
} 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 Sender {
constructor(roomID, mxid) {
this.sender = store.rooms.get(roomID).value().members.get(mxid)
this.sender.subscribe("changeSelf", this.update.bind(this))
this.name = new ElemJS("div").class("c-message-group__name")
this.avatar = new ElemJS("div").class("c-message-group__avatar")
this.displayingGoodData = false
this.update()
}
update() {
if (this.sender.exists()) {
// name
if (this.sender.value().content.displayname) {
this.name.text(this.sender.value().content.displayname)
this.displayingGoodData = true
} else if (!this.displayingGoodData) {
this.name.text(this.sender.value().state_key)
}
// avatar
this.avatar.clearChildren()
if (this.sender.value().content.avatar_url) {
this.avatar.child(
ejs("img").class("c-message-group__icon").attribute("src", resolveMxc(this.sender.value().content.avatar_url, 96, "crop"))
)
} else {
this.avatar.child(
ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
)
}
}
}
}
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)
}
}
class ReactiveTimeline extends ElemJS {
constructor(id, list) {
super("div")
this.class("c-event-groups")
this.id = id
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(this, [event])
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.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: []
})
Object.assign(this.eventDeps, {
beforeChange: [],
afterChange: []
})
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()
}
updateStateEvents(events) {
for (const eventData of events) {
let id = eventData.event_id
if (eventData.type === "m.room.member") {
// update members
if (eventData.membership !== "leave") {
this.room.members.get(eventData.state_key).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") {
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)
continue
} else {
// uhhhhhhh
console.error(`want to replace event ${replaces} with ${eventData.id} but replaced event not found`)
}
}
// add new event
const event = new Event(eventData)
this.map.set(id, event)
this.reactiveTimeline.addEvent(event)
}
}
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
}
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"
}
})/*.then(() => {
const subscription = () => {
this.removeEvent(id)
this.unsubscribe("afterChange", subscription)
}
this.subscribe("afterChange", subscription)
})*/
}
/*
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
let currentGroup = []
for (const event of this.list) {
if (event.sender === currentSender) {
currentGroup.push(event)
} else {
if (currentGroup.length) groups.push(currentGroup)
currentGroup = [event]
currentSender = event.sender
}
}
if (currentGroup.length) groups.push(currentGroup)
return groups
}
*/
}
export {Timeline}