All checks were successful
continuous-integration/drone/push Build is passing
242 lines
6.4 KiB
TypeScript
242 lines
6.4 KiB
TypeScript
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<string, MessageReaction[]>;
|
|
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<string, Receipt[]>;
|
|
|
|
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<string, Receipt>();
|
|
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);
|
|
}
|
|
}
|
|
}
|