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

client.ts (17523B)


      1 import {
      2     createContext,
      3     useContext,
      4     useEffect,
      5     useState
      6 } from "react";
      7 import { OwnUser } from "./ownUser";
      8 import {
      9     IProfileInfo,
     10     IRateLimitError,
     11 } from "./api/apiTypes";
     12 import { IRoomEvent, IRoomStateEvent } from './api/events';
     13 import { Room } from "./room";
     14 import EventEmitter from "events";
     15 import {
     16     DBSchema,
     17     IDBPDatabase,
     18     deleteDB,
     19     openDB
     20 } from "idb";
     21 import { DeviceId, UserId } from "@mtrnord/matrix-sdk-crypto-js";
     22 import { MatrixSlidingSync } from "./slidingSync";
     23 import { AccessTokenMissingError, HostnameMissingError, NotLogeedInError, ProfileFetchError, SDKError } from './utils';
     24 
     25 export interface MatrixClientEvents {
     26     // Used to notify about changes to the room list
     27     'rooms': (rooms: Set<Room>) => void;
     28     //'delete': (changedCount: number) => void;
     29 }
     30 
     31 export declare interface MatrixClient {
     32     on<U extends keyof MatrixClientEvents>(
     33         event: U, listener: MatrixClientEvents[U]
     34     ): this;
     35 
     36     emit<U extends keyof MatrixClientEvents>(
     37         event: U, ...args: Parameters<MatrixClientEvents[U]>
     38     ): boolean;
     39 }
     40 
     41 interface MatrixDB extends DBSchema {
     42     rooms: {
     43         // Same as roomToRoom map
     44         key: string;
     45         value: {
     46             windowPos: {
     47                 [list: string]: number;
     48             };
     49             roomID: string;
     50             name: string;
     51             notification_count: number;
     52             highlight_count: number;
     53             joined_count: number;
     54             invited_count: number;
     55             events?: IRoomEvent[];
     56             stateEvents?: IRoomStateEvent[];
     57             avatarUrl?: string;
     58             isSpace: boolean;
     59             isDM?: boolean;
     60         };
     61     };
     62     loginInfo: {
     63         // login info
     64         value: {
     65             userId: string;
     66             device_id?: string;
     67             hostname?: string;
     68             slidingSyncHostname?: string;
     69             access_token?: string;
     70             displayName?: string;
     71             avatarUrl?: string;
     72         };
     73         // User ID
     74         key: string;
     75     };
     76     syncInfo: {
     77         // sync info
     78         value: {
     79             userId: string;
     80             syncPos?: string;
     81             initialSync: boolean;
     82             lastRanges?: { [key: string]: number[][] }; // [start, end]
     83             lastTxnID?: string;
     84             to_device_since?: string;
     85         };
     86         // User ID
     87         key: string;
     88     }
     89 }
     90 
     91 export class MatrixClient extends EventEmitter {
     92     private static _instance: MatrixClient;
     93     public roomsInView: string[] = [];
     94     public spacesInView: string[] = [];
     95     public spaceOpen: string[] = [];
     96     public database?: IDBPDatabase<MatrixDB>;
     97     public profileInfo?: IProfileInfo;
     98     public currentRoom?: string;
     99     private user: OwnUser = new OwnUser(this);
    100     private sync: MatrixSlidingSync = new MatrixSlidingSync(this, this.user);
    101 
    102     public get accessToken(): string | undefined {
    103         return this.user.access_token;
    104     }
    105 
    106     public get hostname(): string | undefined {
    107         return this.user.hostname;
    108     }
    109 
    110     public get isLoggedIn(): boolean {
    111         return this.user.access_token !== undefined;
    112     }
    113 
    114     public get mxid(): string | undefined {
    115         return this.user.mxid;
    116     }
    117 
    118     private onSyncRooms(rooms: Set<Room>) {
    119         this.emit("rooms", rooms);
    120     }
    121 
    122     public async passwordLogin(username: string, password: string): Promise<SDKError | void> {
    123         const loginResp = await this.user.passwordLogin(username, password);
    124         if (loginResp instanceof SDKError) {
    125             return loginResp
    126         }
    127         this.sync.on("rooms", (rooms) => this.onSyncRooms(rooms));
    128     }
    129 
    130     public convertMXC(url: string, size?: number): string {
    131         if (size) {
    132             return `${this.user.hostname}/_matrix/media/v3/thumbnail/${url.substring(6)}?width=${size}&height=${size}&method=scale`;
    133         }
    134         return `${this.user.hostname}/_matrix/media/v3/download/${url.substring(6)}`;
    135     }
    136 
    137     public setCurrentRoom(roomID?: string) {
    138         if (roomID !== this.currentRoom) {
    139             this.currentRoom = roomID;
    140             console.log("Current room changed to", roomID, "restarting sync");
    141             this.sync.mustUpdateTxnID = true;
    142             //this.abortController.abort();
    143             //this.abortController = new AbortController();
    144         }
    145     }
    146 
    147     public static async Instance() {
    148         let instance = this._instance;
    149         // Load from database if not done
    150         if (!instance) {
    151             instance = (this._instance = new this());
    152             if (!instance.database) {
    153                 await instance.createDatabase();
    154             }
    155             const tx = instance.database?.transaction('loginInfo', 'readonly');
    156             // We dont know the mxid so we just get all and use the first. In theory this allows for multiple accounts
    157             const loginInfo = await tx?.store.getAll();
    158             await tx?.done;
    159             if (loginInfo && loginInfo.length > 0) {
    160                 instance.user.mxid = loginInfo[0].userId;
    161                 instance.user.hostname = loginInfo[0].hostname;
    162                 instance.user.slidingSyncHostname = loginInfo[0].slidingSyncHostname;
    163                 instance.user.access_token = loginInfo[0].access_token;
    164                 instance.user.device_id = loginInfo[0].device_id;
    165                 instance.profileInfo = {
    166                     avatar_url: loginInfo[0].avatarUrl,
    167                     displayname: loginInfo[0].displayName,
    168                 };
    169                 if (instance.user.mxid && instance.user.hostname && instance.user.access_token && instance.user.device_id) {
    170                     await instance.user.e2ee.initOlmMachine(new UserId(instance.user.mxid), new DeviceId(instance.user.device_id));
    171                 }
    172 
    173                 // Load sync info
    174                 const syncTx = instance.database?.transaction('syncInfo', 'readonly');
    175                 const syncInfo = await syncTx?.store.get(instance.user.mxid!);
    176                 await syncTx?.done;
    177 
    178                 if (syncInfo) {
    179                     instance.sync.applyStoredSyncInfo(syncInfo);
    180                 }
    181 
    182                 // Load rooms
    183                 const roomTx = instance.database?.transaction('rooms', 'readonly');
    184                 const rooms = await roomTx?.store.getAll();
    185                 await roomTx?.done;
    186 
    187                 if (rooms) {
    188                     instance.sync.rooms = new Set(rooms.map(room => {
    189                         const roomObj = new Room(room.roomID, instance.user.hostname!, instance, instance.user.e2ee);
    190                         roomObj.windowPos = room.windowPos;
    191                         roomObj.setInvitedCount(room.invited_count);
    192                         roomObj.setJoinedCount(room.joined_count);
    193                         roomObj.setNotificationCount(room.notification_count);
    194                         roomObj.setNotificationHighlightCount(room.highlight_count);
    195                         roomObj.setName(room.name);
    196                         if (room.events) {
    197                             roomObj.addEvents(room.events);
    198                         }
    199                         if (room.stateEvents) {
    200                             roomObj.addStateEvents(room.stateEvents);
    201                         }
    202                         if (room.isDM) {
    203                             roomObj.setDM(room.isDM);
    204                         }
    205                         return roomObj;
    206                     }))
    207                     instance.emit("rooms", instance.sync.rooms);
    208                 }
    209             }
    210             instance.setMaxListeners(60);
    211             instance.sync.on("rooms", (rooms) => instance.onSyncRooms(rooms));
    212         }
    213 
    214 
    215         return instance;
    216     }
    217 
    218     public async createDatabase() {
    219         this.database = await openDB<MatrixDB>("matrix", 4, {
    220             upgrade(db, oldVersion) {
    221                 if (oldVersion < 1) {
    222                     if (db.objectStoreNames.contains("rooms")) {
    223                         db.deleteObjectStore("rooms");
    224                     }
    225                     //if (db.objectStoreNames.contains("loginInfo")) {
    226                     //    db.deleteObjectStore("loginInfo");
    227                     //}
    228                     if (db.objectStoreNames.contains("syncInfo")) {
    229                         db.deleteObjectStore("syncInfo");
    230                     }
    231                     db.createObjectStore('rooms', { keyPath: 'roomID' });
    232                     db.createObjectStore('loginInfo', { keyPath: 'userId' });
    233                     db.createObjectStore('syncInfo', { keyPath: 'userId' });
    234                 }
    235             }
    236         });
    237     }
    238 
    239     public async decryptRoomEvent(roomID: string, event: IRoomEvent): Promise<SDKError | IRoomEvent> {
    240         if (!this.isLoggedIn) {
    241             return new NotLogeedInError();
    242         }
    243         const decryptedEvent = this.user.e2ee.decryptRoomEvent(roomID, event);
    244         return decryptedEvent;
    245     }
    246 
    247     public async logout(): Promise<void | SDKError> {
    248         this.sync.logout();
    249         this.sync.off("rooms", this.onSyncRooms);
    250         const error = await this.user.logout();
    251         if (error instanceof SDKError) {
    252             return error;
    253         }
    254         this.user.e2ee.logoutE2ee();
    255         if (this.user.mxid) {
    256             const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite');
    257             await syncInfoTX?.store.delete(this.user.mxid);
    258             await syncInfoTX?.done;
    259             const loginInfoTX = this.database?.transaction('loginInfo', 'readwrite');
    260             await loginInfoTX?.store.delete(this.user.mxid);
    261             await loginInfoTX?.done;
    262         }
    263         const roomTX = this.database?.transaction('rooms', 'readwrite');
    264         await roomTX?.store.clear();
    265         await roomTX?.done;
    266         this.user.mxid = undefined;
    267         this.sync.resetAbortController();
    268         await deleteDB("cetirizine-crypto", {
    269             blocked() {
    270                 location.reload();
    271             },
    272         });
    273     }
    274 
    275     /**
    276      * addInViewRoom
    277      * 
    278      * Tells the sync that a room with a certain roomID is inView.
    279      */
    280     public addInViewRoom(roomID: string) {
    281         this.roomsInView.push(roomID);
    282     }
    283 
    284     /**
    285      * removeInViewRoom
    286      * 
    287      * Tells the sync that a room isn't in the room anymore
    288      */
    289     public removeInViewRoom(roomID: string) {
    290         this.roomsInView = this.roomsInView.filter(room => room !== roomID);
    291     }
    292 
    293     public addInViewSpace(roomID: string) {
    294         this.spacesInView.push(roomID);
    295     }
    296 
    297     public removeInViewSpace(roomID: string) {
    298         this.spacesInView = this.spacesInView.filter(room => room !== roomID);
    299     }
    300 
    301     public addSpaceOpen(roomID: string) {
    302         if (roomID === "other") {
    303             return;
    304         }
    305         this.spaceOpen.push(roomID);
    306 
    307         console.log("Space opened", roomID, "restarting sync");
    308         this.sync.mustUpdateTxnID = true;
    309         //this.abortController.abort();
    310         //this.abortController = new AbortController();
    311     }
    312 
    313     public removeSpaceOpen(roomID: string) {
    314         if (roomID === "other") {
    315             return;
    316         }
    317         this.spaceOpen = this.spaceOpen.filter(room => room !== roomID);
    318         this.sync.mustUpdateTxnID = true;
    319 
    320         // We intentionally do not restart the sync here since it will update in the next sync anyway.
    321     }
    322 
    323     public getRooms(): Set<Room> {
    324         return this.sync.rooms;
    325     }
    326 
    327     public getSpaces(): Room[] {
    328         return [...this.sync.rooms].filter(room => room.isSpace() && !room.isTombstoned()).sort((a: Room, b: Room) => {
    329             if (a.getName() < b.getName()) {
    330                 return -1;
    331             }
    332             if (a.getName() > b.getName()) {
    333                 return 1;
    334             }
    335             return 0;
    336         });
    337     }
    338 
    339     public getSpacesWithRooms(): Set<{
    340         spaceRoom: Room, children: Set<Room>
    341     }> {
    342         const spaces = this.getSpaces();
    343         const result: Set<{
    344             spaceRoom: Room, children: Set<Room>
    345         }> = new Set();
    346         // Find children of spaces
    347         for (const space of spaces) {
    348             const childrenIDs = space.getSpaceChildrenIDs();
    349 
    350             const children = new Set([...this.getRooms()].filter(room => childrenIDs.includes(room.roomID)));
    351 
    352             result.add({
    353                 spaceRoom: space,
    354                 children: children,
    355             });
    356         }
    357         // Find spaces of parents
    358         // Check parents of each room and if we have a parent make sure to add it to the result unless already added
    359         for (const room of this.getRooms()) {
    360             if (room.isSpace() || room.isTombstoned()) {
    361                 continue;
    362             }
    363             const parents = room.getSpaceParentIDs();
    364             for (const parent of parents) {
    365                 const parentObj = [...this.getRooms()].find(room => room.roomID === parent.roomID);
    366                 if (!parentObj) {
    367                     continue;
    368                 }
    369                 const alreadyAddedSpace = [...result].find(space => space.spaceRoom.roomID === parentObj.roomID);
    370                 if (alreadyAddedSpace) {
    371                     // Check if room in children
    372                     if (![...alreadyAddedSpace.children].find(child => child.roomID === room.roomID)) {
    373                         alreadyAddedSpace.children.add(room);
    374                     }
    375                     continue;
    376                 }
    377                 // If space not added yet, add it
    378                 result.add({
    379                     spaceRoom: parentObj,
    380                     children: new Set([room]),
    381                 });
    382             }
    383         }
    384 
    385         return result;
    386     }
    387 
    388     public async startSync() {
    389         await this.sync.startSync();
    390     }
    391 
    392     public async fetchProfileInfo(userId: string): Promise<SDKError | IProfileInfo> {
    393         // @ts-ignore
    394         if (globalThis.IS_STORYBOOK) {
    395             await new Promise(r => setTimeout(r, 5000))
    396         }
    397         if (this.profileInfo) {
    398             return this.profileInfo;
    399         }
    400         if (!this.user.hostname) {
    401             return new HostnameMissingError()
    402         }
    403         if (!this.database) {
    404             await this.createDatabase();
    405         }
    406         if (!this.user.access_token) {
    407             return new AccessTokenMissingError();
    408         }
    409         const resp = await fetch(`${this.user.hostname}/_matrix/client/v3/profile/${userId}`, {
    410             headers: {
    411                 "Authorization": `Bearer ${this.user.access_token}`
    412             }
    413         });
    414         if (!resp.ok) {
    415             if (resp.status === 404 || resp.status === 403) {
    416                 return {} as IProfileInfo;
    417             }
    418             return new ProfileFetchError(resp);
    419         }
    420         const json = await resp.json() as IProfileInfo;
    421         if (json.avatar_url) {
    422             json.avatar_url = this.convertMXC(json.avatar_url);
    423         }
    424         this.profileInfo = json;
    425         const tx = this.database?.transaction('loginInfo', 'readwrite');
    426         await tx?.store.put({
    427             userId: this.user.mxid!,
    428             device_id: this.user.device_id!,
    429             hostname: this.user.hostname,
    430             slidingSyncHostname: this.user.slidingSyncHostname,
    431             access_token: this.user.access_token,
    432             displayName: json.displayname,
    433             avatarUrl: json.avatar_url,
    434         });
    435         await tx?.done
    436 
    437         return json;
    438     }
    439 }
    440 
    441 export function isRateLimitError(arg: any): arg is IRateLimitError {
    442     return arg.retry_after_ms !== undefined;
    443 }
    444 
    445 export const defaultMatrixClient: MatrixClient = await MatrixClient.Instance();
    446 export const MatrixContext = createContext<MatrixClient>(defaultMatrixClient);
    447 
    448 // List of rooms
    449 export function useRooms() {
    450     const client = useContext(MatrixContext);
    451     const [rooms, setRooms] = useState<Set<Room>>(client.getRooms());
    452 
    453     useEffect(() => {
    454         // Listen for room updates
    455         const listenForRooms = (rooms: Set<Room>) => {
    456             setRooms(rooms);
    457         };
    458         client.on("rooms", listenForRooms);
    459         // This is a no-op if there is already a sync
    460         client.startSync();
    461         return () => {
    462             client.off("rooms", listenForRooms);
    463         }
    464     }, [])
    465     return rooms;
    466 }
    467 
    468 export function useRoom(roomID?: string): Room | undefined {
    469     const rooms = useRooms();
    470 
    471     return [...rooms].find(room => room.roomID === roomID);
    472 }
    473 
    474 export function useSpaces() {
    475     const client = useContext(MatrixContext);
    476     const [spacesWithRooms, setSpacesWithRooms] = useState<Set<{
    477         spaceRoom: Room, children: Set<Room>
    478     }>>(client.getSpacesWithRooms());
    479 
    480     useEffect(() => {
    481         // Listen for room updates
    482         const listenForRooms = (_rooms: Set<Room>) => {
    483             setSpacesWithRooms(client.getSpacesWithRooms());
    484         };
    485         client.on("rooms", listenForRooms);
    486         // This is a no-op if there is already a sync
    487         client.startSync();
    488         return () => {
    489             client.off("rooms", listenForRooms);
    490         }
    491     }, [])
    492     return spacesWithRooms;
    493 }
    494 
    495 
    496 export function useProfile() {
    497     const client = useContext(MatrixContext);
    498     const [profile, setProfile] = useState<IProfileInfo>(client.profileInfo || {
    499         displayname: client.mxid || "Unknown",
    500     });
    501 
    502     useEffect(() => {
    503         client.fetchProfileInfo(client.mxid!).then((profile) => {
    504             if (!(profile instanceof SDKError)) {
    505                 if (!profile.displayname) {
    506                     profile.displayname = client.mxid || "Unknown";
    507                 }
    508                 setProfile(profile);
    509             }
    510         })
    511     }, [])
    512     return profile;
    513 }