import dayjs from "dayjs"; import type { MatrixEvent, MatrixEventData, MatrixEventsList, MessageType, } from "../api/matrix/MatrixApiEvent"; import type { Receipt, Room } from "../api/matrix/MatrixApiRoom"; import type { WsMessage } from "../api/WsApi"; import { timeMs } from "./DateUtils"; export interface MessageReaction { event_id: string; account: string; key: string; } export interface Message { event_id: string; account: string; time_sent: number; time_sent_dayjs: dayjs.Dayjs; modified: boolean; inReplyTo?: string; reactions: Map; content: string; type: MessageType; file?: string; } export class RoomEventsManager { readonly room: Room; private events: MatrixEvent[]; private receipts: Receipt[]; messages: Message[]; endToken?: string; typingUsers: string[]; receiptsEventsMap: Map; get canLoadOlder(): boolean { return !!this.endToken && this.events.length > 0; } constructor( room: Room, initialMessages: MatrixEventsList, receipts: Receipt[] ) { this.room = room; this.events = []; this.receipts = receipts; this.messages = []; this.typingUsers = []; this.receiptsEventsMap = new Map(); this.processNewEvents(initialMessages); } /** * Process events given by the API */ processNewEvents(evts: MatrixEventsList) { this.endToken = evts.end; this.events = [...this.events, ...evts.events]; this.rebuildMessagesList(); } processWsMessage(m: WsMessage) { if (m.room_id !== this.room.id) { console.debug("Not an event for current room."); return false; } let data: MatrixEventData; if (m.type === "RoomReactionEvent") { data = { type: "m.reaction", content: { "m.relates_to": { key: m.data["m.relates_to"].key, event_id: m.data["m.relates_to"].event_id, }, }, }; } else if (m.type === "RoomRedactionEvent") { data = { type: "m.room.redaction", redacts: m.data.redacts, }; } else if (m.type === "RoomMessageEvent") { data = { type: "m.room.message", content: { body: m.data["m.new_content"]?.body ?? m.data.body, msgtype: m.data.msgtype, "m.relates_to": m.data["m.relates_to"], url: m.data.url, file: m.data.file, }, }; } else if (m.type === "ReceiptEvent") { for (const r of m.receipts) { const prevReceipt = this.receipts.find( (needle) => r.user === needle.user ); // Create new receipt if (!prevReceipt) this.receipts.push({ user: r.user, event_id: r.event, ts: r.ts ?? timeMs(), }); // Update receipt else { prevReceipt.event_id = r.event; prevReceipt.ts = r.ts ?? timeMs(); } } this.rebuildMessagesList(); return true; // Emphemeral event } else if (m.type === "TypingEvent") { this.typingUsers = m.user_ids; return true; // Not a real event } else { // Ignore event console.info("Event not supported => ignored"); return false; } this.events.push({ sender: m.sender, id: m.event_id, time: m.origin_server_ts, data, }); this.rebuildMessagesList(); return true; } private rebuildMessagesList() { this.messages = []; // Sorts events list to process oldest events first this.events.sort((a, b) => a.time - b.time); // Process receipts (users map) const receiptsUsersMap = new Map(); for (const r of this.receipts) { receiptsUsersMap.set(r.user, { ...r }); } // First, process redactions to skip redacted events const redacted = new Set( this.events .map((e) => e.data.type === "m.room.redaction" ? e.data.redacts : undefined ) .filter((e) => e !== undefined) ); for (const evt of this.events) { if (redacted.has(evt.id)) continue; const data = evt.data; // Message if (data.type === "m.room.message") { // Check if this message replaces another one if (data.content["m.relates_to"]?.rel_type === "replace") { const message = this.messages.find( (m) => m.event_id === data.content["m.relates_to"]?.event_id ); if (!message) continue; message.modified = true; message.content = data.content.body; continue; } // Else it is a new message; update receipts if needed else { const userReceipt = receiptsUsersMap.get(evt.sender); // Create fake receipt if none is available if (!userReceipt) receiptsUsersMap.set(evt.sender, { event_id: evt.id, ts: evt.time, user: evt.sender, }); // If the message is more recent than user receipt, replace the receipt else if (userReceipt.ts < evt.time) { userReceipt.event_id = evt.id; userReceipt.ts = evt.time; } } this.messages.push({ event_id: evt.id, account: evt.sender, modified: false, inReplyTo: data.content["m.relates_to"]?.["m.in_reply_to"]?.event_id, reactions: new Map(), time_sent: evt.time, time_sent_dayjs: dayjs.unix(evt.time / 1000), type: data.content.msgtype, file: data.content.file?.url ?? data.content.url, content: data.content.body, }); } // Reaction if (data.type === "m.reaction") { const message = this.messages.find( (m) => m.event_id === data.content["m.relates_to"].event_id ); const key = data.content["m.relates_to"].key; if (!message) continue; if (!message.reactions.has(key)) message.reactions.set(key, []); message.reactions.get(key)!.push({ account: evt.sender, event_id: evt.id, key, }); } } // Adapt receipts to be event-indexed this.receiptsEventsMap.clear(); for (const receipt of [...receiptsUsersMap.values()]) { if (!this.receiptsEventsMap.has(receipt.event_id)) this.receiptsEventsMap.set(receipt.event_id, [receipt]); else this.receiptsEventsMap.get(receipt.event_id)!.push(receipt); } } }