cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

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 }