room.ts (17821B)
1 import EventEmitter from "events"; 2 import { 3 IRoomEvent, 4 IRoomMemberEvent, 5 IRoomStateEvent, 6 isRoomAvatarEvent, 7 isRoomCreateEvent, 8 isRoomMemberEvent, 9 isRoomTopicEvent, 10 isSpaceChildEvent, 11 isSpaceParentEvent 12 } from "./api/events"; 13 import { MatrixClient } from "./client"; 14 import { useEffect, useState } from "react"; 15 import { EncryptionAlgorithm, EncryptionSettings, RoomId } from "@mtrnord/matrix-sdk-crypto-js"; 16 import { OnlineState } from "./api/otherEnums"; 17 import { MatrixE2EE } from "./e2ee"; 18 import { AccessTokenMissingError, FailedSendingError, HostnameMissingError, ProfileFetchError, SDKError } from "./utils"; 19 20 export interface RoomEvents { 21 // Used to notify about changes to the event list 22 'events': (events: IRoomEvent[]) => void; 23 'state_events': (stateEvents: IRoomStateEvent[]) => void; 24 } 25 26 export declare interface Room { 27 on<U extends keyof RoomEvents>( 28 event: U, listener: RoomEvents[U] 29 ): this; 30 31 emit<U extends keyof RoomEvents>( 32 event: U, ...args: Parameters<RoomEvents[U]> 33 ): boolean; 34 } 35 36 export class Room extends EventEmitter { 37 private events: IRoomEvent[] = []; 38 private pendingEvents: IRoomEvent[] = []; 39 private stateEvents: IRoomStateEvent[] = []; 40 private name?: string; 41 42 private notification_count: number = 0; 43 private notification_highlight_count: number = 0; 44 private joined_count: number = 0; 45 private invited_count: number = 0; 46 private is_dm: boolean = false; 47 private has_all_users: boolean = false; 48 49 public windowPos: { 50 [list: string]: number 51 } = {} 52 53 54 constructor(public roomID: string, private hostname: string, private client: MatrixClient, private e2ee: MatrixE2EE) { 55 super(); 56 } 57 58 public addEvents(events: IRoomEvent[]): void { 59 console.log("Adding events") 60 events.forEach((newEvent) => { 61 if (newEvent.unsigned?.transaction_id) { 62 this.pendingEvents = this.pendingEvents.filter((event) => event.unsigned?.transaction_id !== newEvent.unsigned?.transaction_id); 63 } 64 65 this.events.push(newEvent); 66 }); 67 68 this.emit("events", this.getEvents()); 69 } 70 71 public addStateEvents(state: IRoomStateEvent[]): void { 72 // if the state event id is already known then we update the event instead of pushing it on to the Array 73 state.forEach((newEvent) => { 74 const index = this.stateEvents.findIndex((oldEvent) => oldEvent.state_key === newEvent.state_key && oldEvent.type === newEvent.type); 75 if (index !== -1) { 76 this.stateEvents[index] = newEvent; 77 } else { 78 this.stateEvents.push(newEvent); 79 } 80 }); 81 this.emit("state_events", this.stateEvents); 82 } 83 84 public getStateEvents(): IRoomStateEvent[] { 85 return this.stateEvents; 86 } 87 88 public isTombstoned(): boolean { 89 let isTombstoned: boolean = false; 90 this.stateEvents.forEach((event) => { 91 if (event.type === "m.room.tombstone") { 92 isTombstoned = true; 93 } 94 }); 95 return isTombstoned; 96 } 97 98 public getAvatarURL(): string | undefined { 99 let avatarURL: string | undefined = undefined; 100 this.stateEvents.forEach((event) => { 101 if (isRoomAvatarEvent(event)) { 102 const rawAvatarURL = event.content.url; 103 if (rawAvatarURL?.startsWith("mxc://")) { 104 avatarURL = this.client.convertMXC(rawAvatarURL); 105 } 106 } 107 }); 108 return avatarURL; 109 } 110 111 public isSpace(): boolean { 112 let isSpace: boolean = false; 113 this.stateEvents.forEach((event) => { 114 if (isRoomCreateEvent(event)) { 115 isSpace = event.content.type === "m.space"; 116 } 117 }); 118 return isSpace; 119 } 120 121 public setName(name: string): void { 122 this.name = name; 123 } 124 125 public getName(): string { 126 if (!this.name) { 127 return this.roomID; 128 } 129 return this.name; 130 } 131 132 public getTopic(): string | undefined { 133 let topic: string | undefined = undefined; 134 this.stateEvents.forEach((event) => { 135 if (isRoomTopicEvent(event)) { 136 topic = event.content.topic; 137 } 138 }); 139 return topic; 140 } 141 142 public setNotificationCount(count: number): void { 143 this.notification_count = count; 144 } 145 146 public getNotificationCount(): number { 147 return this.notification_count; 148 } 149 150 public setNotificationHighlightCount(count: number): void { 151 this.notification_highlight_count = count; 152 } 153 154 public getNotificationHighlightCount(): number { 155 return this.notification_highlight_count; 156 } 157 158 public setJoinedCount(count: number): void { 159 this.joined_count = count; 160 } 161 162 public getJoinedCount(): number { 163 return this.joined_count; 164 } 165 166 public setInvitedCount(count: number): void { 167 this.invited_count = count; 168 } 169 170 public getInvitedCount(): number { 171 return this.invited_count; 172 } 173 174 public getSpaceChildrenIDs(): string[] { 175 const children: string[] = []; 176 this.stateEvents.forEach((event) => { 177 if (isSpaceChildEvent(event)) { 178 children.push(event.state_key); 179 } 180 }); 181 return children; 182 } 183 184 public getSpaceParentIDs(): { roomID: string, canonical: boolean }[] { 185 const parents: { roomID: string, canonical: boolean }[] = []; 186 this.stateEvents.forEach((event) => { 187 if (isSpaceParentEvent(event)) { 188 parents.push({ roomID: event.state_key, canonical: event.content.canonical || false }); 189 } 190 }); 191 return parents; 192 } 193 194 public setDM(isDM: boolean): void { 195 this.is_dm = isDM; 196 } 197 198 public isDM(): boolean { 199 return this.is_dm; 200 } 201 202 public get presence(): OnlineState { 203 // TODO: Implement this 204 return OnlineState.Unknown; 205 } 206 207 public getEvents(): IRoomEvent[] { 208 return [...this.events, ...this.pendingEvents]; 209 } 210 211 public getPureEvents(): IRoomEvent[] { 212 return this.events; 213 } 214 215 public getMemberName(userID: string): string { 216 let name: string = userID; 217 this.stateEvents.forEach((event) => { 218 if (event.type === "m.room.member") { 219 if (event.state_key === userID && event.content.membership == "join") { 220 name = event.content.displayname; 221 } 222 } 223 }); 224 return name; 225 } 226 227 public getMemberAvatar(userID: string, size: number = 64): string | undefined { 228 let avatarURL: string | undefined = undefined; 229 this.stateEvents.forEach((event) => { 230 if (event.type === "m.room.member" && event.state_key === userID && event.content.membership == "join") { 231 const rawAvatarURL = event.content.avatar_url; 232 if (rawAvatarURL?.startsWith("mxc://")) { 233 avatarURL = this.client.convertMXC(rawAvatarURL, size); 234 } 235 } 236 }); 237 return avatarURL; 238 } 239 240 public isBot(userID: string): boolean { 241 let isBot: boolean = false; 242 this.stateEvents.forEach((event) => { 243 if (event.type === "m.room.member" && event.state_key === userID && event.content.membership == "join") { 244 if (event.content["dev.nordgedanken.msc4015"]) { 245 isBot = event.content["dev.nordgedanken.msc4015"]; 246 } 247 if (event.content.bot) { 248 isBot = event.content.bot; 249 } 250 } 251 }); 252 return isBot; 253 } 254 255 public async joinedMembers(): Promise<IRoomMemberEvent[] | SDKError> { 256 if (!this.has_all_users) { 257 if (!this.client.hostname) { 258 return new HostnameMissingError(); 259 } 260 if (!this.client.accessToken) { 261 return new AccessTokenMissingError(); 262 } 263 // We dont have all members so we need to fetch them 264 const resp = await fetch(`${this.client.hostname}/_matrix/client/v3/rooms/${this.roomID}/members`, { 265 headers: { 266 "Authorization": `Bearer ${this.client.accessToken}` 267 } 268 }); 269 if (!resp.ok) { 270 if (resp.status === 404 || resp.status === 403) { 271 return this.stateEvents.filter((event) => { 272 if (isRoomMemberEvent(event) && event.content.membership == "join") { 273 return event; 274 } 275 }); 276 } 277 return new ProfileFetchError(resp); 278 } 279 const json = await resp.json() as { chunk: IRoomMemberEvent[] }; 280 this.stateEvents = [...this.stateEvents, ...json.chunk]; 281 this.has_all_users = true; 282 } 283 return this.stateEvents.filter((event) => { 284 if (isRoomMemberEvent(event) && event.content.membership == "join") { 285 return event; 286 } 287 }); 288 } 289 290 public isEncrypted(): boolean { 291 let isEncrypted: boolean = false; 292 this.stateEvents.forEach((event) => { 293 if (event.type === "m.room.encryption" && event.content.algorithm === "m.megolm.v1.aes-sha2" && event.state_key === "") { 294 isEncrypted = true; 295 // This gets called at room opening so this works for now 296 this.has_all_users = true; 297 } 298 }); 299 return isEncrypted; 300 } 301 302 // TODO: Workaround since txn id doesnt come down sync 303 private deletePendingByEventID(eventID: string): void { 304 this.pendingEvents = this.pendingEvents.filter((event) => event.eventID !== eventID); 305 this.emit("events", this.getEvents()); 306 } 307 308 public async sendHtmlMessage(html: string, plainText: string, callbackLocalEcho: () => void): Promise<string | SDKError> { 309 const txn_id = Date.now().toString(); 310 // @ts-ignore: Intentionally incomplete 311 const event = { 312 type: "m.room.message", 313 unsigned: { 314 transaction_id: txn_id 315 }, 316 origin_server_ts: txn_id, 317 sender: this.client.mxid, 318 event_id: txn_id, 319 content: { 320 "msgtype": "m.text", 321 "body": plainText, 322 "format": "org.matrix.custom.html", 323 "formatted_body": html 324 } 325 } as IRoomEvent; 326 this.pendingEvents.push(event); 327 this.emit("events", this.getEvents()); 328 callbackLocalEcho(); 329 330 if (!this.isEncrypted()) { 331 const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.message/${event.unsigned?.transaction_id}`, { 332 method: "PUT", 333 headers: { 334 "Content-Type": "application/json", 335 "Authorization": `Bearer ${this.client.accessToken}` 336 }, 337 body: JSON.stringify(event.content) 338 }); 339 if (!resp.ok) { 340 this.deletePendingByEventID(event.event_id); 341 return new FailedSendingError(resp); 342 } 343 const json = await resp.json(); 344 this.deletePendingByEventID(event.event_id); 345 return json.event_id; 346 } else { 347 console.log("Sending encrypted message"); 348 await this.e2ee.getMissingSessions(); 349 await this.e2ee.shareKeysForRoom(this); 350 const encrypted = await this.e2ee.encryptRoomEvent( 351 new RoomId(this.roomID), 352 "m.room.message", 353 JSON.stringify(event.content) 354 ); 355 const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.encrypted/${event.unsigned?.transaction_id}`, { 356 method: "PUT", 357 headers: { 358 "Content-Type": "application/json", 359 "Authorization": `Bearer ${this.client.accessToken}` 360 }, 361 body: encrypted 362 }); 363 if (!resp.ok) { 364 this.deletePendingByEventID(event.event_id); 365 return new FailedSendingError(resp); 366 } 367 const json = await resp.json(); 368 this.deletePendingByEventID(event.event_id); 369 return json.event_id; 370 } 371 } 372 373 public async sendTextMessage(text: string, callbackLocalEcho: () => void): Promise<string | SDKError> { 374 const txn_id = Date.now().toString(); 375 // @ts-ignore: Intentionally incomplete 376 const event = { 377 type: "m.room.message", 378 unsigned: { 379 transaction_id: txn_id 380 }, 381 origin_server_ts: txn_id, 382 event_id: txn_id, 383 sender: this.client.mxid, 384 content: { 385 "msgtype": "m.text", 386 "body": text, 387 } 388 } as IRoomEvent; 389 this.pendingEvents.push(event); 390 this.emit("events", this.getEvents()); 391 callbackLocalEcho(); 392 393 if (!this.isEncrypted()) { 394 const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.message/${event.unsigned?.transaction_id}`, { 395 method: "PUT", 396 headers: { 397 "Content-Type": "application/json", 398 "Authorization": `Bearer ${this.client.accessToken}` 399 }, 400 body: JSON.stringify(event.content) 401 }); 402 if (!resp.ok) { 403 this.deletePendingByEventID(event.event_id); 404 return new FailedSendingError(resp); 405 } 406 const json = await resp.json(); 407 this.deletePendingByEventID(event.event_id); 408 return json.event_id; 409 } else { 410 console.log("Sending encrypted message2"); 411 await this.e2ee.getMissingSessions(); 412 await this.e2ee.shareKeysForRoom(this); 413 const encrypted = await this.e2ee.encryptRoomEvent( 414 new RoomId(this.roomID), 415 "m.room.message", 416 JSON.stringify(event.content) 417 ); 418 const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.encrypted/${event.unsigned?.transaction_id}`, { 419 method: "PUT", 420 headers: { 421 "Content-Type": "application/json", 422 "Authorization": `Bearer ${this.client.accessToken}` 423 }, 424 body: encrypted 425 }); 426 if (!resp.ok) { 427 this.deletePendingByEventID(event.event_id); 428 return new FailedSendingError(resp); 429 } 430 const json = await resp.json(); 431 this.deletePendingByEventID(event.event_id); 432 return json.event_id; 433 } 434 } 435 436 public getJoinedMemberIDs(): string[] { 437 const members: string[] = []; 438 this.stateEvents.forEach((event) => { 439 if (event.type === "m.room.member" && event.content.membership === "join") { 440 members.push(event.state_key); 441 } 442 }); 443 return members; 444 } 445 446 public getEncryptionSettings(): EncryptionSettings | undefined { 447 let settings: EncryptionSettings | undefined = undefined; 448 this.stateEvents.forEach((event) => { 449 if (event.type === "m.room.encryption" && event.state_key === "") { 450 if (!settings) { 451 settings = new EncryptionSettings(); 452 } 453 settings.algorithm = event.content.algorithm === "m.megolm.v1.aes-sha2" ? EncryptionAlgorithm.MegolmV1AesSha2 : EncryptionAlgorithm.OlmV1Curve25519AesSha2; 454 if (event.content.rotation_period_ms) { 455 settings.rotationPeriod = BigInt(event.content.rotation_period_ms); 456 } 457 if (event.content.rotation_period_msgs) { 458 settings.rotationPeriodMessages = BigInt(event.content.rotation_period_msgs); 459 } 460 } 461 if (event.type === "m.room.history_visibility" && event.state_key === "") { 462 if (!settings) { 463 settings = new EncryptionSettings(); 464 } 465 settings.historyVisibility = event.content.history_visibility; 466 } 467 }); 468 if (settings) { 469 (settings as EncryptionSettings).onlyAllowTrustedDevices = false; 470 } 471 return settings; 472 } 473 474 public isJoined(): boolean { 475 let isJoined: boolean = false; 476 this.stateEvents.forEach((event) => { 477 if (event.type === "m.room.member" && event.state_key === this.client.mxid) { 478 isJoined = event.content.membership === "join"; 479 } 480 }); 481 return isJoined; 482 } 483 } 484 485 export function useStateEvents(room?: Room) { 486 const [events, setEvents] = useState<IRoomStateEvent[]>(room?.getStateEvents() || []); 487 488 useEffect(() => { 489 if (room) { 490 setEvents(room?.getStateEvents() || []); 491 // Listen for event updates 492 const listenForStateEvents = (events: IRoomStateEvent[]) => { 493 setEvents(events); 494 }; 495 room.on("state_events", listenForStateEvents); 496 return () => { 497 room.off("state_events", listenForStateEvents); 498 } 499 } else { 500 setEvents([]); 501 } 502 }, [room]) 503 return events; 504 }