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 }