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

commit 3d86c3e024c6de1b98cbd39f619b3a3b7e21f50b
parent 709dcc61e86de38ca750bd736e9795c7ad58521b
Author: MTRNord <mtrnord1@gmail.com>
Date:   Tue,  9 May 2023 18:52:12 +0200

Refactor client

Diffstat:
Msrc/app/sdk/api/apiTypes.ts | 384-------------------------------------------------------------------------------
Asrc/app/sdk/api/events.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/sdk/api/slidingSync.ts | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/sdk/client.ts | 1218+++++--------------------------------------------------------------------------
Asrc/app/sdk/e2ee.ts | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/sdk/ownUser.ts | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/sdk/room.ts | 25+++++++++++++++++--------
Asrc/app/sdk/slidingSync.ts | 731+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/events/memberEvent.tsx | 2+-
Msrc/components/events/messageEvent.tsx | 138+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/components/events/unknownEvent.tsx | 2+-
Msrc/pages/LoginPage.tsx | 25++++++++++++++++++++++++-
Msrc/pages/MainPage.tsx | 11+++++------
13 files changed, 1731 insertions(+), 1618 deletions(-)

diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts @@ -86,388 +86,4 @@ export interface IProfileInfo { displayname?: string; } -// Old sync -export interface ISyncResponse { - account_data?: IAccountData; - device_lists?: { - changed?: string[]; - left?: string[]; - }; - device_one_time_keys_count?: { - [key: string]: number; - }; - next_batch: string; - presence?: { - events?: any[]; - }; - rooms?: { - invite?: { - [key: string]: { - invite_state?: { - events?: { - content: any; - sender: string; - state_key: string; - type: string; - // Future proofing - [key: string]: any; - }[]; - }; - }; - }; - join?: { - [key: string]: { - account_data?: IAccountData; - ephemeral?: { - events?: any[]; - }; - state?: { - events: IClientEventWithoutRoomId[]; - } - summary?: { - "m.heroes": string[]; - "m.joined_member_count": number; - "m.invited_member_count": number; - }; - timeline?: ITimeline; - unread_notifications?: { - highlight_count: number; - notification_count: number; - }; - unread_thread_notifications?: { - [key: string]: { - highlight_count: number; - notification_count: number; - }; - }; - }; - }; - leave?: { - [key: string]: { - state?: { - events: IClientEventWithoutRoomId[]; - }; - account_data?: IAccountData; - timeline?: ITimeline; - }; - }; - knock?: { - [key: string]: { - knock_state?: { - events?: { - content: any; - sender: string; - state_key: string; - type: string; - }[]; - }; - }; - }; - }; - to_device?: { - events?: any[]; - }; -} - // Sliding sync -export interface ISlidingSyncReq { - txn_id?: string; - lists?: { - [key: string]: { - ranges?: number[][]; - slow_get_all_rooms?: boolean; - sort?: string[]; - required_state?: string[][]; - timeline_limit?: number; - filters?: { - [key: string]: any; - }; - }; - } - bump_event_types?: string[]; - extensions?: { - [key: string]: any; - }; - room_subscriptions?: { - [key: string]: { - sort?: string[]; - required_state?: string[][]; - timeline_limit?: number; - filters?: { - [key: string]: any; - }; - }; - }; -}; - - -export interface ISlidingSyncResp { - lists?: { - [key: string]: List; - }; - rooms?: { - [key: string]: RoomJson; - }; - extensions?: Extensions; - pos: string; - txn_id: string; -} - -export interface Extensions { - e2ee?: E2EEExtension; - to_device?: ToDeviceExtension; -} - -export interface E2EEExtension { - device_one_time_keys_count?: { - [key: string]: number; - }; - device_lists?: { - changed?: string[]; - left?: string[]; - }; - device_unused_fallback_key_types?: string[]; -} - -export interface ToDeviceExtension { - next_batch: string; - events?: any[]; -} - -export interface List { - ops?: (SYNC_OP | INSERT_OP | INVALIDATE_OP | DELETE_OP)[]; - count: number; -} - -export function isSyncOp(op: any): op is SYNC_OP { - return op.op === "SYNC"; -} - -export function isInsertOp(op: any): op is INSERT_OP { - return op.op === "INSERT"; -} - -export function isInvalidateOp(op: any): op is INVALIDATE_OP { - return op.op === "INVALIDATE"; -} - -export function isDeleteOp(op: any): op is DELETE_OP { - return op.op === "DELETE"; -} - -export interface DELETE_OP { - op: string; - index: number; -} - -export interface INVALIDATE_OP { - op: string; - range: number[]; -} - -export interface INSERT_OP { - op: string; - index: number; - room_id: string; -} - -export interface SYNC_OP { - op: string; - range: number[]; - room_ids: string[]; -} - -export interface RoomJson { - name?: string, - // List of events - timeline?: IRoomEvent[], - required_state?: IRoomStateEvent[], - notification_count: number, - highlight_count: number, - initial: boolean, - joined_count: number, - invited_count: number, - prev_batch: string, - is_dm?: boolean, -} - -export interface IRoomEvent<Content = any> { - content: Content; - event_id: string; - origin_server_ts: number; - sender: string; - type: string; - unsigned?: any; - "m.relates.to"?: any; - [key: string]: any; -} - -export interface IRoomMessageContent<MsgType = any> { - body: string; - msgtype: MsgType; -} - -export interface IRoomMessageTextContent extends IRoomMessageContent<"m.text"> { - format?: string; - formatted_body?: string; -} - -export interface IRoomMessageImageContent extends IRoomMessageContent<"m.image"> { - info?: IImageInfo; - url?: string; - file?: IEncryptedFile; -} - -export interface IRoomMessageAudioContent extends IRoomMessageContent<"m.audio"> { - info?: IAudioInfo; - url?: string; - file?: IEncryptedFile; -} - -export interface IRoomMessageEvent<Content = any> extends IRoomEvent<Content> { } - -export interface IRoomMessageTextEvent extends IRoomMessageEvent<IRoomMessageTextContent> { } - -export function isRoomMessageTextEvent(event: IRoomEvent): event is IRoomMessageTextEvent { - return event.type === "m.room.message" && event.content.msgtype === "m.text"; -} - -export interface IRoomMessageNoticeEvent extends IRoomMessageEvent<IRoomMessageTextContent> { } - -export function isRoomMessageNoticeEvent(event: IRoomEvent): event is IRoomMessageNoticeEvent { - return event.type === "m.room.message" && event.content.msgtype === "m.notice"; -} - -export function isRoomMessageImageEvent(event: IRoomEvent): event is IRoomMessageEvent<IRoomMessageImageContent> { - return event.type === "m.room.message" && event.content.msgtype === "m.image"; -} - -export function isRoomMessageAudioEvent(event: IRoomEvent): event is IRoomMessageEvent<IRoomMessageAudioContent> { - return event.type === "m.room.message" && event.content.msgtype === "m.audio"; -} - -export function isRoomMessageEvent(event: IRoomEvent): event is IRoomMessageEvent { - return event.type === "m.room.message"; -} - -export interface IRoomStateEvent<Content = any> extends IRoomEvent<Content> { - state_key: string; -} - -export function isRoomStateEvent(event: IRoomEvent): event is IRoomStateEvent { - return event.state_key !== undefined; -} - -export interface IRoomMemberContent { - avatar_url?: string; - displayname?: string; - membership: "invite" | "join" | "knock" | "leave" | "ban"; - is_direct?: boolean; - reason?: string; -} - -export interface IRoomMemberEvent extends IRoomStateEvent<IRoomMemberContent> { } - -export function isRoomMemberEvent(event: IRoomEvent): event is IRoomMemberEvent { - return event.type === "m.room.member"; -} - -export interface IRoomCreateContent { - creator: string; - "m.federate"?: boolean; - predecessor?: { - room_id: string; - event_id: string; - }; - room_version?: string; - type?: string; -} - -export interface IRoomCreateEvent extends IRoomStateEvent<IRoomCreateContent> { } - -export function isRoomCreateEvent(event: IRoomEvent): event is IRoomCreateEvent { - return event.type === "m.room.create"; -} - -export interface IThumbnailInfo { - h: number; - mimetype: string; - size: number; - w: number; -} - -export interface IEncryptedFile { - v: string; - key: { - alg: string; - ext: boolean; - k: string; - key_ops: string[]; - kty: string; - }; - iv: string; - hashes: { - [key: string]: string; - }; - url: string; -} - -export interface IImageInfo { - h: number; - mimetype: string; - size: number; - thumbnail_info?: IThumbnailInfo; - thumbnail_url?: string; - thumbnail_file?: IEncryptedFile; - w: number; -} - -export interface IAudioInfo { - duration?: number; - mimetype?: string; - size?: number; -} - -export interface IRoomAvatarContent { - info: IImageInfo; - url?: string; -} - -export interface IRoomAvatarEvent extends IRoomStateEvent<IRoomAvatarContent> { } - -export function isRoomAvatarEvent(event: IRoomEvent): event is IRoomAvatarEvent { - return event.type === "m.room.avatar"; -} - -export interface ISpaceChildContent { - via: string[]; - order?: string; - suggested?: boolean; -} - -export interface ISpaceChildEvent extends IRoomStateEvent<ISpaceChildContent> { } - -export function isSpaceChildEvent(event: IRoomEvent): event is ISpaceChildEvent { - return event.type === "m.space.child"; -} - -export interface ISpaceParentContent { - via: string[]; - canonical?: boolean; -} - -export interface ISpaceParentEvent extends IRoomStateEvent<ISpaceParentContent> { } - -export function isSpaceParentEvent(event: IRoomEvent): event is ISpaceParentEvent { - return event.type === "m.space.parent"; -} - -export interface IRoomTopicContent { - topic: string; -} - -export interface IRoomTopicEvent extends IRoomStateEvent<IRoomTopicContent> { } - -export function isRoomTopicEvent(event: IRoomEvent): event is IRoomTopicEvent { - return event.type === "m.room.topic"; -} diff --git a/src/app/sdk/api/events.ts b/src/app/sdk/api/events.ts @@ -0,0 +1,181 @@ + +export interface IRoomEvent<Content = any> { + content: Content; + event_id: string; + origin_server_ts: number; + sender: string; + type: string; + unsigned?: any; + "m.relates.to"?: any; + [key: string]: any; +} + +export interface IRoomMessageContent<MsgType = any> { + body: string; + msgtype: MsgType; +} + +export interface IRoomMessageTextContent extends IRoomMessageContent<"m.text"> { + format?: string; + formatted_body?: string; +} + +export interface IRoomMessageImageContent extends IRoomMessageContent<"m.image"> { + info?: IImageInfo; + url?: string; + file?: IEncryptedFile; +} + +export interface IRoomMessageAudioContent extends IRoomMessageContent<"m.audio"> { + info?: IAudioInfo; + url?: string; + file?: IEncryptedFile; +} + +export interface IRoomMessageEvent<Content = any> extends IRoomEvent<Content> { } + +export interface IRoomMessageTextEvent extends IRoomMessageEvent<IRoomMessageTextContent> { } + +export function isRoomMessageTextEvent(event: IRoomEvent): event is IRoomMessageTextEvent { + return event.type === "m.room.message" && event.content.msgtype === "m.text"; +} + +export interface IRoomMessageNoticeEvent extends IRoomMessageEvent<IRoomMessageTextContent> { } + +export function isRoomMessageNoticeEvent(event: IRoomEvent): event is IRoomMessageNoticeEvent { + return event.type === "m.room.message" && event.content.msgtype === "m.notice"; +} + +export function isRoomMessageImageEvent(event: IRoomEvent): event is IRoomMessageEvent<IRoomMessageImageContent> { + return event.type === "m.room.message" && event.content.msgtype === "m.image"; +} + +export function isRoomMessageAudioEvent(event: IRoomEvent): event is IRoomMessageEvent<IRoomMessageAudioContent> { + return event.type === "m.room.message" && event.content.msgtype === "m.audio"; +} + +export function isRoomMessageEvent(event: IRoomEvent): event is IRoomMessageEvent { + return event.type === "m.room.message"; +} + +export interface IRoomStateEvent<Content = any> extends IRoomEvent<Content> { + state_key: string; +} + +export function isRoomStateEvent(event: IRoomEvent): event is IRoomStateEvent { + return event.state_key !== undefined; +} + +export interface IRoomMemberContent { + avatar_url?: string; + displayname?: string; + membership: "invite" | "join" | "knock" | "leave" | "ban"; + is_direct?: boolean; + reason?: string; +} + +export interface IRoomMemberEvent extends IRoomStateEvent<IRoomMemberContent> { } + +export function isRoomMemberEvent(event: IRoomEvent): event is IRoomMemberEvent { + return event.type === "m.room.member"; +} + +export interface IRoomCreateContent { + creator: string; + "m.federate"?: boolean; + predecessor?: { + room_id: string; + event_id: string; + }; + room_version?: string; + type?: string; +} + +export interface IRoomCreateEvent extends IRoomStateEvent<IRoomCreateContent> { } + +export function isRoomCreateEvent(event: IRoomEvent): event is IRoomCreateEvent { + return event.type === "m.room.create"; +} + +export interface IThumbnailInfo { + h: number; + mimetype: string; + size: number; + w: number; +} + +export interface IEncryptedFile { + v: string; + key: { + alg: string; + ext: boolean; + k: string; + key_ops: string[]; + kty: string; + }; + iv: string; + hashes: { + [key: string]: string; + }; + url: string; +} + +export interface IImageInfo { + h: number; + mimetype: string; + size: number; + thumbnail_info?: IThumbnailInfo; + thumbnail_url?: string; + thumbnail_file?: IEncryptedFile; + w: number; +} + +export interface IAudioInfo { + duration?: number; + mimetype?: string; + size?: number; +} + +export interface IRoomAvatarContent { + info: IImageInfo; + url?: string; +} + +export interface IRoomAvatarEvent extends IRoomStateEvent<IRoomAvatarContent> { } + +export function isRoomAvatarEvent(event: IRoomEvent): event is IRoomAvatarEvent { + return event.type === "m.room.avatar"; +} + +export interface ISpaceChildContent { + via: string[]; + order?: string; + suggested?: boolean; +} + +export interface ISpaceChildEvent extends IRoomStateEvent<ISpaceChildContent> { } + +export function isSpaceChildEvent(event: IRoomEvent): event is ISpaceChildEvent { + return event.type === "m.space.child"; +} + +export interface ISpaceParentContent { + via: string[]; + canonical?: boolean; +} + +export interface ISpaceParentEvent extends IRoomStateEvent<ISpaceParentContent> { } + +export function isSpaceParentEvent(event: IRoomEvent): event is ISpaceParentEvent { + return event.type === "m.space.parent"; +} + +export interface IRoomTopicContent { + topic: string; +} + +export interface IRoomTopicEvent extends IRoomStateEvent<IRoomTopicContent> { } + +export function isRoomTopicEvent(event: IRoomEvent): event is IRoomTopicEvent { + return event.type === "m.room.topic"; +} +\ No newline at end of file diff --git a/src/app/sdk/api/slidingSync.ts b/src/app/sdk/api/slidingSync.ts @@ -0,0 +1,122 @@ +import { IRoomEvent, IRoomStateEvent } from "./events"; + +export interface ISlidingSyncReq { + txn_id?: string; + lists?: { + [key: string]: { + ranges?: number[][]; + slow_get_all_rooms?: boolean; + sort?: string[]; + required_state?: string[][]; + timeline_limit?: number; + filters?: { + [key: string]: any; + }; + }; + } + bump_event_types?: string[]; + extensions?: { + [key: string]: any; + }; + room_subscriptions?: { + [key: string]: { + sort?: string[]; + required_state?: string[][]; + timeline_limit?: number; + filters?: { + [key: string]: any; + }; + }; + }; +}; + + +export interface ISlidingSyncResp { + lists?: { + [key: string]: List; + }; + rooms?: { + [key: string]: RoomJson; + }; + extensions?: Extensions; + pos: string; + txn_id: string; +} + +export interface Extensions { + e2ee?: E2EEExtension; + to_device?: ToDeviceExtension; +} + +export interface E2EEExtension { + device_one_time_keys_count?: { + [key: string]: number; + }; + device_lists?: { + changed?: string[]; + left?: string[]; + }; + device_unused_fallback_key_types?: string[]; +} + +export interface ToDeviceExtension { + next_batch: string; + events?: any[]; +} + +export interface List { + ops?: (SYNC_OP | INSERT_OP | INVALIDATE_OP | DELETE_OP)[]; + count: number; +} + +export function isSyncOp(op: any): op is SYNC_OP { + return op.op === "SYNC"; +} + +export function isInsertOp(op: any): op is INSERT_OP { + return op.op === "INSERT"; +} + +export function isInvalidateOp(op: any): op is INVALIDATE_OP { + return op.op === "INVALIDATE"; +} + +export function isDeleteOp(op: any): op is DELETE_OP { + return op.op === "DELETE"; +} + +export interface DELETE_OP { + op: string; + index: number; +} + +export interface INVALIDATE_OP { + op: string; + range: number[]; +} + +export interface INSERT_OP { + op: string; + index: number; + room_id: string; +} + +export interface SYNC_OP { + op: string; + range: number[]; + room_ids: string[]; +} + +export interface RoomJson { + name?: string, + // List of events + timeline?: IRoomEvent[], + required_state?: IRoomStateEvent[], + notification_count: number, + highlight_count: number, + initial: boolean, + joined_count: number, + invited_count: number, + prev_batch: string, + is_dm?: boolean, +} +\ No newline at end of file diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts @@ -4,23 +4,12 @@ import { useEffect, useState } from "react"; +import { OwnUser } from "./ownUser"; import { - IErrorResp, - ILoginFlows, - ILoginResponse, IProfileInfo, IRateLimitError, - IRoomEvent, - IRoomStateEvent, - ISlidingSyncReq, - ISlidingSyncResp, - IWellKnown, - isDeleteOp, - isInsertOp, - isInvalidateOp, - isRoomStateEvent, - isSyncOp } from "./api/apiTypes"; +import { IRoomEvent, IRoomStateEvent } from './api/events'; import { Room } from "./room"; import EventEmitter from "events"; import { @@ -29,10 +18,8 @@ import { deleteDB, openDB } from "idb"; -import { DeviceId, DeviceLists, KeysBackupRequest, KeysUploadRequest, OlmMachine, RequestType, RoomId, RoomMessageRequest, SignatureUploadRequest, UserId } from "@mtrnord/matrix-sdk-crypto-js"; -import { KeysQueryRequest } from "@mtrnord/matrix-sdk-crypto-js"; -import { KeysClaimRequest } from "@mtrnord/matrix-sdk-crypto-js"; -import { ToDeviceRequest } from "@mtrnord/matrix-sdk-crypto-js"; +import { DeviceId, UserId } from "@mtrnord/matrix-sdk-crypto-js"; +import { MatrixSlidingSync } from "./slidingSync"; export interface MatrixClientEvents { // Used to notify about changes to the room list @@ -102,50 +89,42 @@ interface MatrixDB extends DBSchema { export class MatrixClient extends EventEmitter { private static _instance: MatrixClient; - private access_token?: string; - private device_id?: string; - public mxid?: string; - // Hostname including "https://" - private hostname?: string; - private slidingSyncHostname?: string; - private syncing = false; - private roomsInView: string[] = []; - private spacesInView: string[] = []; - private spaceOpen: string[] = []; - private rooms: Set<Room> = new Set(); - private syncPos?: string; - private initialSync = true; - private database?: IDBPDatabase<MatrixDB>; + public roomsInView: string[] = []; + public spacesInView: string[] = []; + public spaceOpen: string[] = []; + public database?: IDBPDatabase<MatrixDB>; private profileInfo?: IProfileInfo; - private lastRanges?: { [key: string]: number[][] }; - private lastTxnID?: string; - private to_device_since?: string; - public olmMachine?: OlmMachine; - private currentRoom?: string; - private abortController = new AbortController(); - private mustUpdateTxnID = true; - private outgoingRequestsBeingProcessed = false; - private missingSessionsBeingRequested = false; + public currentRoom?: string; + private user: OwnUser = new OwnUser(this); + private sync: MatrixSlidingSync = new MatrixSlidingSync(this, this.user); public get accessToken(): string | undefined { - return this.access_token; + return this.user.access_token; } public get isLoggedIn(): boolean { - return this.access_token !== undefined; + return this.user.access_token !== undefined; + } + + public get mxid(): string | undefined { + return this.user.mxid; + } + + public async passwordLogin(username: string, password: string) { + await this.user.passwordLogin(username, password); } public convertMXC(url: string): string { - return `${this.hostname}/_matrix/media/r0/download/${url.substring(6)}`; + return `${this.user.hostname}/_matrix/media/r0/download/${url.substring(6)}`; } public setCurrentRoom(roomID?: string) { if (roomID !== this.currentRoom) { this.currentRoom = roomID; console.log("Current room changed to", roomID, "restarting sync"); - this.mustUpdateTxnID = true; + this.sync.mustUpdateTxnID = true; //this.abortController.abort(); - this.abortController = new AbortController(); + //this.abortController = new AbortController(); } } @@ -162,30 +141,26 @@ export class MatrixClient extends EventEmitter { const loginInfo = await tx?.store.getAll(); await tx?.done; if (loginInfo && loginInfo.length > 0) { - instance.mxid = loginInfo[0].userId; - instance.hostname = loginInfo[0].hostname; - instance.slidingSyncHostname = loginInfo[0].slidingSyncHostname; - instance.access_token = loginInfo[0].access_token; - instance.device_id = loginInfo[0].device_id; + instance.user.mxid = loginInfo[0].userId; + instance.user.hostname = loginInfo[0].hostname; + instance.user.slidingSyncHostname = loginInfo[0].slidingSyncHostname; + instance.user.access_token = loginInfo[0].access_token; + instance.user.device_id = loginInfo[0].device_id; instance.profileInfo = { avatar_url: loginInfo[0].avatarUrl, displayname: loginInfo[0].displayName, }; - if (instance.mxid && instance.hostname && instance.access_token && instance.device_id) { - instance.olmMachine = await OlmMachine.initialize(new UserId(instance.mxid), new DeviceId(instance.device_id), "cetirizine-crypto"); + if (instance.user.mxid && instance.user.hostname && instance.user.access_token && instance.user.device_id) { + await instance.user.e2ee.initOlmMachine(new UserId(instance.user.mxid), new DeviceId(instance.user.device_id)); } // Load sync info const syncTx = instance.database?.transaction('syncInfo', 'readonly'); - const syncInfo = await syncTx?.store.get(instance.mxid!); + const syncInfo = await syncTx?.store.get(instance.user.mxid!); await syncTx?.done; if (syncInfo) { - instance.syncPos = syncInfo.syncPos; - instance.initialSync = syncInfo.initialSync; - instance.lastRanges = syncInfo.lastRanges; - instance.lastTxnID = syncInfo.lastTxnID; - instance.to_device_since = syncInfo.to_device_since; + instance.sync.applyStoredSyncInfo(syncInfo); } // Load rooms @@ -194,8 +169,8 @@ export class MatrixClient extends EventEmitter { await roomTx?.done; if (rooms) { - instance.rooms = new Set(rooms.map(room => { - const roomObj = new Room(room.roomID, instance.hostname!, instance); + instance.sync.rooms = new Set(rooms.map(room => { + const roomObj = new Room(room.roomID, instance.user.hostname!, instance, instance.user.e2ee); roomObj.windowPos = room.windowPos; roomObj.setInvitedCount(room.invited_count); roomObj.setJoinedCount(room.joined_count); @@ -213,16 +188,19 @@ export class MatrixClient extends EventEmitter { } return roomObj; })) - instance.emit("rooms", instance.rooms); + instance.emit("rooms", instance.sync.rooms); } } + instance.sync.on("rooms", (rooms) => { + instance.emit("rooms", rooms); + }); } return instance; } - private async createDatabase() { + public async createDatabase() { this.database = await openDB<MatrixDB>("matrix", 4, { upgrade(db, oldVersion) { if (oldVersion < 1) { @@ -243,956 +221,36 @@ export class MatrixClient extends EventEmitter { }); } - private async setHostname(hostname: string) { - if (!hostname.startsWith("https://")) { - throw Error("Hostname must start with 'https://'"); - } - if (!this.database) { - await this.createDatabase(); - } - - // Write to database - const tx = this.database?.transaction('loginInfo', 'readwrite'); - await tx?.store.put({ - userId: this.mxid!, - hostname: hostname, - slidingSyncHostname: this.slidingSyncHostname, - access_token: this.access_token, - device_id: this.device_id, - }); - await tx?.done - - // Set in memory - this.hostname = hostname; - - } - - public async startSync() { - if (!this.isLoggedIn) { - throw Error("Not logged in"); - } - if (!this.database) { - await this.createDatabase(); - } - if (this.syncing) { - return; - } - this.syncing = true; - while (this.syncing) { - try { - await this.sync(); - } catch (e) { - console.error(e); - } - } - } - - public stopSync() { - this.syncing = false; - } - - private isIndexInRange(index: number, ranges: number[][]): boolean { - for (const r of ranges) { - if (r[0] < index && index <= r[1]) { - return true - } - } - return false - } - - private shiftRight(listKey: string, ranges: number[][], hi: number, low: number) { - // l h - // 0,1,2,3,4 <- before - // 0,1,2,2,3 <- after, hi is deleted and low is duplicated - for (let i = hi - 1; i > low - 1; i--) { - if (this.isIndexInRange(i, ranges)) { - const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i + 1); - if (roomObj) { - roomObj.windowPos[listKey] = (i); - } - } - } - } - - private shiftLeft(listKey: string, ranges: number[][], hi: number, low: number) { - // l h - // 0,1,2,3,4 <- before - // 0,1,3,4,4 <- after, low is deleted and hi is duplicated - for (let i = low + 1; i < hi + 1; i++) { - if (this.isIndexInRange(i, ranges)) { - const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i - 1); - if (roomObj) { - roomObj.windowPos[listKey] = (i); - } - } - } - - } - - private async removeEntry(listKey: string, ranges: number[][], index: number) { - // work out the max index - let max = -1; - const indexes = [...this.rooms].map(room => room.windowPos[listKey]); - for (const n in indexes) { - if (Number(n) > max) { - max = Number(n); - } - } - // TODO: Unclear if this is needed or working. Probably wrong? - // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === index); - // if (roomObj) { - // const tx = this.database?.transaction('rooms', 'readwrite'); - // await tx?.store.delete(roomObj.roomID); - // await tx?.done; - // this.rooms.delete(roomObj) - // } - if (max < 0 || index > max) { - return; - } - // Everything higher than the gap needs to be shifted left. - this.shiftLeft(listKey, ranges, max, index); - } - - private addEntry(listKey: string, ranges: number[][], index: number): void { - // work out the max index - let max = -1; - const indexes = [...this.rooms].map(room => room.windowPos[listKey]); - for (const n in indexes) { - if (Number(n) > max) { - max = Number(n); - } - } - if (max < 0 || index > max) { - return; - } - // Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element - this.shiftRight(listKey, ranges, max + 1, index); - } - - private async sendIdentifyAndOneTimeKeys() { - if (!this.isLoggedIn) { - throw Error("Not logged in"); - } - if (!this.slidingSyncHostname) { - throw Error("Hostname must be set first"); - } - if (!this.olmMachine) { - throw Error("Olm machine must be set first"); - } - - if (this.outgoingRequestsBeingProcessed) { - return; - } - this.outgoingRequestsBeingProcessed = true; - - const outgoing_requests = await this.olmMachine.outgoingRequests(); - - for (const request of outgoing_requests) { - await this.processRequest(request); - } - - await this.getMissingSessions(); - this.outgoingRequestsBeingProcessed = false; - } - - public async shareKeysForRoom(room: Room) { + public async decryptRoomEvent(roomID: string, event: IRoomEvent): Promise<IRoomEvent> { if (!this.isLoggedIn) { throw Error("Not logged in"); } - if (!this.slidingSyncHostname) { - throw Error("Hostname must be set first"); - } - if (!this.olmMachine) { - throw Error("Olm machine must be set first"); - } - const encryptionSettings = room.getEncryptionSettings(); - if (encryptionSettings) { - const requests = await this.olmMachine.shareRoomKey(new RoomId(room.roomID), room.getJoinedMemberIDs().map(id => new UserId(id)), encryptionSettings); - for (const request of requests) { - await this.processRequest(request); - } - } + const decryptedEvent = this.user.e2ee.decryptRoomEvent(roomID, event); + return decryptedEvent; } - public async getMissingSessions() { - if (!this.isLoggedIn) { - throw Error("Not logged in"); - } - if (!this.slidingSyncHostname) { - throw Error("Hostname must be set first"); - } - if (!this.olmMachine) { - throw Error("Olm machine must be set first"); - } - - if (this.missingSessionsBeingRequested) { - return; + public async logout() { + this.sync.logout(); + await this.user.logout(); + this.user.e2ee.logoutE2ee(); + if (this.user.mxid) { + const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite'); + await syncInfoTX?.store.delete(this.user.mxid); + await syncInfoTX?.done; + const loginInfoTX = this.database?.transaction('loginInfo', 'readwrite'); + await loginInfoTX?.store.delete(this.user.mxid); + await loginInfoTX?.done; } - this.missingSessionsBeingRequested = true; - - const encryptedRooms = [...this.rooms].filter(room => room.isEncrypted()); - const users = encryptedRooms.map(room => room.getJoinedMemberIDs().map(id => new UserId(id))).flat(); - const request = await this.olmMachine?.getMissingSessions(users); - if (request) { - await this.processRequest(request); - } - - this.missingSessionsBeingRequested = false; - } - - private async logout() { - this.stopSync(); - this.abortController.abort(); - this.access_token = undefined; - this.device_id = undefined; - this.initialSync = true; - this.rooms = new Set(); - this.slidingSyncHostname = undefined; - this.syncPos = undefined; - this.to_device_since = undefined; - this.outgoingRequestsBeingProcessed = false; - this.missingSessionsBeingRequested = false; - const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite'); - await syncInfoTX?.store.delete(this.mxid!); - await syncInfoTX?.done; - const loginInfoTX = this.database?.transaction('loginInfo', 'readwrite'); - await loginInfoTX?.store.delete(this.mxid!); - await loginInfoTX?.done; const roomTX = this.database?.transaction('rooms', 'readwrite'); await roomTX?.store.clear(); await roomTX?.done; - await deleteDB("cetirizine-crypto"); - this.mxid = undefined; - } - - private async processRequest(request: any) { - if (!this.isLoggedIn) { - throw Error("Not logged in"); - } - if (!this.slidingSyncHostname) { - throw Error("Hostname must be set first"); - } - if (!this.olmMachine) { - throw Error("Olm machine must be set first"); - } - // Check which type the request is - if (request.type === RequestType.KeysUpload) { - // Send the key - const request_typed = request as KeysUploadRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/keys/upload`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to upload keys", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } else if (request.type === RequestType.KeysQuery) { - const request_typed = request as KeysQueryRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/keys/query`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to query keys", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } else if (request.type === RequestType.KeysClaim) { - const request_typed = request as KeysClaimRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/keys/claim`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to claim keys", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } else if (request.type === RequestType.ToDevice) { - const request_typed = request as ToDeviceRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/sendToDevice/${request_typed.event_type}/${request_typed.txn_id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to send to device", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id ?? request_typed.txn_id, request_typed.type, await response.text()); - } else if (request.type === RequestType.SignatureUpload) { - const request_typed = request as SignatureUploadRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/keys/signatures/upload`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to upload signatures", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } else if (request.type === RequestType.RoomMessage) { - const request_typed = request as RoomMessageRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/rooms/${request_typed.room_id}/send/${request_typed.event_type}/${request_typed.txn_id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to send message", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } else if (request.type === RequestType.KeysBackup) { - const request_typed = request as KeysBackupRequest; - const response = await fetch( - `${this.hostname}/_matrix/client/v3/room_keys/keys`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: request_typed.body - } - ) - if (!response.ok) { - if (response.status === 401) { - await this.logout(); - console.error(response); - } - console.error("Failed to backup keys", response); - return; - } - this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); - } - } - - private async sync() { - if (!this.isLoggedIn) { - throw Error("Not logged in"); - } - if (!this.slidingSyncHostname) { - throw Error("Hostname must be set first"); - } - - await this.sendIdentifyAndOneTimeKeys(); - - // This is the initial sync case for each list - let lists_ranges: { - "overview": number[][]; - "spaces": number[][]; - [key: string]: number[][]; - } = { - "overview": [[0, 20]], - "spaces": [[0, 20]] - }; - for (const space of this.spaceOpen) { - if (space === "other") { continue } - lists_ranges[space] = [[0, 20]]; - } - - let timeline_limit = 1; - let subscription_limit = 10; - if (!this.initialSync) { - for (const list in lists_ranges) { - // Set higher timeline limit for subsequent syncs - timeline_limit = 10; - subscription_limit = 50; - // Calculate overlap between this.roomsInView and this.roomToRoomID and then - // calculate the ranges for each list - let rawRangeInView = new Set([...this.rooms] - .filter(room => this.roomsInView.includes(room.roomID)) - .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) - - if (this.getSpaces().find(r => r.roomID === list)) { - // If we are syncing the spaces list, we need to use the spaceInView list instead - rawRangeInView = new Set([...this.rooms] - .filter(room => this.spacesInView.includes(room.roomID)) - .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) - } - - if (rawRangeInView.size !== 0) { - const minimum = Math.min(...rawRangeInView); - const maximum = Math.max(...rawRangeInView); - - lists_ranges[list] = [[Math.max(minimum - 10, 0), maximum + 10]]; - } - } - lists_ranges["e2ee"] = lists_ranges["overview"]; - } - - - if (this.lastRanges && Object.entries(lists_ranges).toString() !== Object.entries(this.lastRanges).toString()) { - console.log("Ranges changed, resetting sync txn_id", lists_ranges) - this.lastRanges = lists_ranges; - this.lastTxnID = Date.now().toString(); - } - - if (!this.lastRanges) { - this.lastRanges = lists_ranges; - this.lastTxnID = Date.now().toString(); - } - - if (this.mustUpdateTxnID) { - this.lastTxnID = Date.now().toString(); - this.mustUpdateTxnID = false; - } - - - let url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=5000`; - if (this.syncPos) { - url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=5000&pos=${this.syncPos}` - } - - const body: ISlidingSyncReq = { - // allows clients to know what request params reached the server, - // functionally similar to txn IDs on /send for events. - txn_id: this.lastTxnID, - - // a delta token to remember information between sessions. - // See "Bandwidth optimisations for persistent clients" for more information. - // TODO: This isnt implemented anywhere yet - //delta_token: "opaque-server-provided-string", - - // Sliding Window API - lists: { - "spaces": { - ranges: this.lastRanges["spaces"], - // slow_get_all_rooms: true, - sort: ["by_name"], - required_state: [ - // needed to build sections - ["m.space.child", "*"], - ["m.space.parent", "*"], - ["m.room.create", ""], - ["m.room.tombstone", ""], - // Room Avatar - ["m.room.avatar", "*"], - // Room Topic - ["m.room.topic", "*"], - // Request only the m.room.member events required to render events in the timeline. - // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for - // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". - ["m.room.member", "$LAZY"], - // E2EE - ["m.room.encryption", ""], - ["m.room.history_visibility", ""], - ], - timeline_limit: 0, - filters: { - room_types: ["m.space"] - } - }, - "overview": { - ranges: this.lastRanges["overview"], - sort: ["by_notification_level", "by_recency", "by_name"], - required_state: [ - // needed to build sections - ["m.space.child", "*"], - ["m.space.parent", "*"], - ["m.room.create", ""], - ["m.room.tombstone", ""], - // Room Avatar - ["m.room.avatar", "*"], - // Room Topic - ["m.room.topic", "*"], - // Request only the m.room.member events required to render events in the timeline. - // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for - // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". - ["m.room.member", "$LAZY"], - // E2EE - ["m.room.encryption", ""], - ["m.room.history_visibility", ""], - ], - timeline_limit: timeline_limit, - filters: { - not_room_types: ["m.space"], - } - }, - "e2ee": { - ranges: this.lastRanges["overview"], - sort: ["by_notification_level", "by_recency", "by_name"], - required_state: [ - // needed to build sections - ["m.space.child", "*"], - ["m.space.parent", "*"], - ["m.room.create", ""], - ["m.room.tombstone", ""], - // Room Avatar - ["m.room.avatar", "*"], - // Room Topic - ["m.room.topic", "*"], - ["m.room.member", "*"], - // E2EE - ["m.room.encryption", ""], - ["m.room.history_visibility", ""], - ], - timeline_limit: timeline_limit, - filters: { - not_room_types: ["m.space"], - is_encrypted: true, - } - }, + this.user.mxid = undefined; + this.sync.resetAbortController(); + await deleteDB("cetirizine-crypto", { + blocked() { + location.reload(); }, - bump_event_types: ["m.room.message", "m.room.encrypted"], - - extensions: { - e2ee: { - enabled: true, - }, - to_device: { - enabled: true, - since: this.to_device_since - } - }, - }; - - for (const space of this.spaceOpen) { - if (space === "other") { continue } - if (!body.lists) { - body.lists = {}; - } - body.lists[space] = { - slow_get_all_rooms: true, - ranges: this.lastRanges[space], - sort: ["by_notification_level", "by_recency", "by_name"], - required_state: [ - // needed to build sections - ["m.space.child", "*"], - ["m.space.parent", "*"], - ["m.room.create", ""], - ["m.room.tombstone", ""], - // Room Avatar - ["m.room.avatar", "*"], - // Room Topic - ["m.room.topic", "*"], - // Request only the m.room.member events required to render events in the timeline. - // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for - // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". - ["m.room.member", "$LAZY"], - // E2EE - ["m.room.encryption", ""], - ["m.room.history_visibility", ""], - ], - timeline_limit: timeline_limit, - filters: { - "spaces": [space] - } - } - } - - if (this.currentRoom) { - body.room_subscriptions = {}; - body.room_subscriptions[this.currentRoom] = { - sort: ["by_notification_level", "by_recency", "by_name"], - required_state: [ - // needed to build sections - ["m.space.child", "*"], - ["m.space.parent", "*"], - ["m.room.create", ""], - ["m.room.tombstone", ""], - // Room Avatar - ["m.room.avatar", "*"], - // Room Topic - ["m.room.topic", "*"], - // Request only the m.room.member events required to render events in the timeline. - // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for - // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". - ["m.room.member", "$LAZY"], - // E2EE - ["m.room.encryption", ""], - ["m.room.history_visibility", ""], - ], - timeline_limit: subscription_limit, - filters: {} - } - } - - const resp = await fetch(url, { - method: "POST", - signal: this.abortController.signal, - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.access_token}` - }, - body: JSON.stringify(body) }); - if (!resp.ok) { - if (resp.status === 400) { - if ((await resp.json()).errcode === "M_UNKNOWN_POS") { - this.syncPos = undefined; - const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite'); - await syncInfoTX?.store.put({ - userId: this.mxid!, - syncPos: this.syncPos, - initialSync: this.initialSync, - lastRanges: this.lastRanges, - lastTxnID: this.lastTxnID, - }); - await syncInfoTX?.done; - } - return; - } else if (resp.status === 401) { - await this.logout(); - console.error(resp); - console.error("Error syncing. See console for error."); - } - } - const json = await resp.json() as ISlidingSyncResp; - this.syncPos = json.pos; - - if (json.extensions?.to_device) { - await this.olmMachine?.receiveSyncChanges( - JSON.stringify(json.extensions.to_device.events || []), - new DeviceLists( - json.extensions.e2ee?.device_lists?.changed?.map( - user_id => new UserId(user_id) - ), - json.extensions.e2ee?.device_lists?.left?.map( - user_id => new UserId(user_id) - ) - ), - new Map(Object.entries(json.extensions.e2ee?.device_one_time_keys_count || [])), - new Set(json.extensions.e2ee?.device_unused_fallback_key_types) - ); - this.to_device_since = json.extensions.to_device.next_batch; - } - - await this.sendIdentifyAndOneTimeKeys(); - - - const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite'); - await syncInfoTX?.store.put({ - userId: this.mxid!, - syncPos: this.syncPos, - initialSync: this.initialSync, - lastRanges: this.lastRanges, - lastTxnID: this.lastTxnID, - to_device_since: this.to_device_since, - }); - await syncInfoTX?.done; - - let gapIndex = -1; - for (const listKey in json.lists) { - const list = json.lists[listKey]; - if (list.ops) { - for (const op of list.ops) { - if (isSyncOp(op)) { - const tx = this.database?.transaction('rooms', 'readwrite'); - for (let i = op.range[0]; i <= op.range[1]; i++) { - const roomID = op.room_ids[i - op.range[0]]; - if (!roomID) { - break; // we are at the end of list - } - - // Check if we already know this room and skip if we do. This is needed since we have 2 lists. - // The db would already do this but the obj list doesn't (even though its a Set. Thats a mystery yet to solve) - const roomObj = [...this.rooms].find(room => room.roomID === roomID); - if (roomObj) { - roomObj.windowPos[listKey] = i; - continue; - } - - const newRoom = new Room(roomID, this.hostname!, this); - // We start to remember the Room now. - newRoom.setName(roomID); - newRoom.windowPos[listKey] = i; - - this.rooms.add(newRoom); - await tx?.store.put({ - windowPos: newRoom.windowPos, - roomID: newRoom.roomID, - name: newRoom.getName(), - notification_count: newRoom.getNotificationCount(), - highlight_count: newRoom.getNotificationHighlightCount(), - joined_count: newRoom.getJoinedCount(), - invited_count: newRoom.getInvitedCount(), - avatarUrl: newRoom.getAvatarURL(), - isSpace: newRoom.isSpace(), - isDM: newRoom.isDM(), - stateEvents: newRoom.getStateEvents(), - events: newRoom.getEvents(), - }); - } - await tx?.done; - } else if (isInsertOp(op)) { - console.log("Got INSERT OP", op); - const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === op.index); - if (roomObj) { - if (gapIndex < 0) { - // we haven't been told where to shift from, so make way for a new room entry. - this.addEntry(listKey, this.lastRanges[listKey], op.index); - } else if (gapIndex > op.index) { - // the gap is further down the list, shift every element to the right - // starting at the gap so we can just shift each element in turn: - // [A,B,C,_] gapIndex=3, op.index=0 - // [A,B,C,C] i=3 - // [A,B,B,C] i=2 - // [A,A,B,C] i=1 - // Terminate. We'll assign into op.index next. - this.shiftRight(listKey, this.lastRanges[listKey], gapIndex, op.index); - } else if (gapIndex < op.index) { - // the gap is further up the list, shift every element to the left - // starting at the gap so we can just shift each element in turn - this.shiftLeft(listKey, this.lastRanges[listKey], op.index, gapIndex); - } - } - gapIndex = -1; - const tx = this.database?.transaction('rooms', 'readwrite'); - // We start to remember the Room now. - const foundRoom = [...this.rooms].find(room => room.roomID === op.room_id); - if (foundRoom) { - foundRoom.windowPos[listKey] = op.index; - await tx?.store.put({ - windowPos: foundRoom.windowPos, - roomID: foundRoom.roomID, - name: foundRoom.getName(), - notification_count: foundRoom.getNotificationCount(), - highlight_count: foundRoom.getNotificationHighlightCount(), - joined_count: foundRoom.getJoinedCount(), - invited_count: foundRoom.getInvitedCount(), - avatarUrl: foundRoom.getAvatarURL(), - isSpace: foundRoom.isSpace(), - isDM: foundRoom.isDM(), - stateEvents: foundRoom.getStateEvents(), - events: foundRoom.getEvents(), - }); - } else { - const roomFromDB = await tx?.store.get(op.room_id); - let newRoom = new Room(op.room_id, this.hostname!, this); - newRoom.setName(op.room_id); - newRoom.windowPos[listKey] = op.index; - if (roomFromDB) { - console.warn("Room in db but not in obj list.", op.room_id, "Updating obj list."); - newRoom = new Room(op.room_id, this.hostname!, this) - newRoom.setName(roomFromDB.name); - newRoom.setNotificationCount(roomFromDB.notification_count); - newRoom.setNotificationHighlightCount(roomFromDB.highlight_count); - newRoom.setJoinedCount(roomFromDB.joined_count); - newRoom.setInvitedCount(roomFromDB.invited_count); - newRoom.setDM(roomFromDB.isDM || false); - } - this.rooms.add(newRoom); - await tx?.store.put({ - windowPos: newRoom.windowPos, - roomID: newRoom.roomID, - name: newRoom.getName(), - notification_count: newRoom.getNotificationCount(), - highlight_count: newRoom.getNotificationHighlightCount(), - joined_count: newRoom.getJoinedCount(), - invited_count: newRoom.getInvitedCount(), - avatarUrl: newRoom.getAvatarURL(), - isSpace: newRoom.isSpace(), - isDM: newRoom.isDM(), - stateEvents: newRoom.getStateEvents(), - events: newRoom.getEvents(), - }); - } - - const roomIDs2 = [...this.rooms].map(room => room.roomID); - // Check if we generated any duplicates and log them. - const duplicates = roomIDs2.filter((item, index) => roomIDs2.indexOf(item) != index); - if (duplicates.length > 0) { - console.error("Duplicates found", duplicates); - } - await tx?.done; - } else if (isDeleteOp(op)) { - console.log("Got DELETE OP", op); - - if (gapIndex !== -1) { - // we already have a DELETE operation to process, so process it. - await this.removeEntry(listKey, this.lastRanges[listKey], gapIndex); - } - gapIndex = op.index; - } else if (isInvalidateOp(op)) { - // TODO: Figure out if this is needed in reality - // const tx = this.database?.transaction('rooms', 'readwrite'); - // for (let i = op.range[0]; i <= op.range[1]; i++) { - // // We shall first forget about these and "startover" - // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i); - // if (roomObj) { - // await tx?.store.delete(roomObj.roomID); - // this.rooms.delete(roomObj) - // } - // } - // await tx?.done; - } - } - if (gapIndex !== -1) { - // we already have a DELETE operation to process, so process it - // Everything higher than the gap needs to be shifted left. - await this.removeEntry(listKey, this.lastRanges[listKey], gapIndex); - } - } - } - for (const roomID in json.rooms) { - const room = json.rooms[roomID]; - const name = room.name; - const notification_count = room.notification_count; - const notification_highlight_count = room.highlight_count; - const joined_count = room.joined_count; - const invited_count = room.invited_count; - const events = room.timeline; - const state_events = events?.filter(event => isRoomStateEvent(event)).map(event => event as IRoomStateEvent); - const normal_events = events?.filter(event => !isRoomStateEvent(event)).map(event => event as IRoomEvent); - const required_state = room.required_state; - const is_dm = room.is_dm; - - let roomObj = [...this.rooms].find(room => room.roomID === roomID); - if (!roomObj) { - // Warn, check in the db and if that fails, create a new one. - console.warn("Could not find roomObj for roomID:", roomID); - - const tx = this.database?.transaction('rooms', 'readwrite'); - const roomFromDB = await tx?.store.get(roomID); - await tx?.done; - - if (roomFromDB) { - console.warn("Room in db but not in obj list.", roomID, "Updating obj list."); - - roomObj = new Room(roomID, this.hostname!, this); - roomObj.setName(roomFromDB.name); - roomObj.setNotificationCount(roomFromDB.notification_count); - roomObj.setNotificationHighlightCount(roomFromDB.highlight_count); - roomObj.setJoinedCount(roomFromDB.joined_count); - roomObj.setInvitedCount(roomFromDB.invited_count); - roomObj.setDM(roomFromDB.isDM || false); - if (roomFromDB.events) { - roomObj.addEvents(roomFromDB.events); - } - if (roomFromDB.stateEvents) { - roomObj.addStateEvents(roomFromDB.stateEvents); - } - roomObj.windowPos = roomFromDB.windowPos; - } else { - console.warn("Could not find room in db. Creating new one."); - roomObj = new Room(roomID, this.hostname!, this); - this.rooms.add(roomObj); - } - } - - if (name) { - roomObj.setName(name); - } - roomObj.setNotificationCount(notification_count); - roomObj.setNotificationHighlightCount(notification_highlight_count); - roomObj.setJoinedCount(joined_count); - roomObj.setInvitedCount(invited_count); - if (normal_events) { - roomObj.addEvents(normal_events); - } - if (required_state) { - roomObj.addStateEvents(required_state); - } - if (state_events) { - roomObj.addStateEvents(state_events); - } - if (required_state || state_events) { - if (roomObj.isEncrypted() && roomObj.isJoined()) { - const joinEvents = [...(required_state || []), ...(state_events || [])] - .filter(event => event.type === "m.room.member" && event.content.membership === "join"); - const memberIds = joinEvents.map(event => new UserId(event.state_key)); - await this.olmMachine?.updateTrackedUsers(memberIds); - } - } - if (is_dm) { - roomObj.setDM(is_dm); - } - - - const tx = this.database?.transaction('rooms', 'readwrite'); - // Write to database - await tx?.store.put({ - windowPos: roomObj.windowPos, - roomID: roomObj.roomID, - name: roomObj.getName(), - notification_count: roomObj.getNotificationCount(), - highlight_count: roomObj.getNotificationHighlightCount(), - joined_count: roomObj.getJoinedCount(), - invited_count: roomObj.getInvitedCount(), - events: roomObj.getEvents(), - stateEvents: roomObj.getStateEvents(), - avatarUrl: roomObj.getAvatarURL(), - isSpace: roomObj.isSpace(), - isDM: roomObj.isDM(), - }); - await tx?.done - } - - if (this.initialSync) { - this.initialSync = false; - console.log("initialSyncComplete"); - } - if (json.rooms && Object.keys(json.rooms).length > 0) { - this.emit("rooms", this.rooms); - } } /** @@ -1228,9 +286,9 @@ export class MatrixClient extends EventEmitter { this.spaceOpen.push(roomID); console.log("Space opened", roomID, "restarting sync"); - this.mustUpdateTxnID = true; + this.sync.mustUpdateTxnID = true; //this.abortController.abort(); - this.abortController = new AbortController(); + //this.abortController = new AbortController(); } public removeSpaceOpen(roomID: string) { @@ -1238,17 +296,17 @@ export class MatrixClient extends EventEmitter { return; } this.spaceOpen = this.spaceOpen.filter(room => room !== roomID); - this.mustUpdateTxnID = true; + this.sync.mustUpdateTxnID = true; // We intentionally do not restart the sync here since it will update in the next sync anyway. } public getRooms(): Set<Room> { - return this.rooms; + return this.sync.rooms; } - private getSpaces(): Room[] { - return [...this.rooms].filter(room => room.isSpace() && !room.isTombstoned()).sort((a: Room, b: Room) => { + public getSpaces(): Room[] { + return [...this.sync.rooms].filter(room => room.isSpace() && !room.isTombstoned()).sort((a: Room, b: Room) => { if (a.getName() < b.getName()) { return -1; } @@ -1308,138 +366,26 @@ export class MatrixClient extends EventEmitter { return result; } - private async getLoginFlows(): Promise<ILoginFlows> { - if (!this.hostname) { - throw Error("Hostname must be set first"); - } - const resp = await fetch(`${this.hostname}/_matrix/client/v3/login`); - if (!resp.ok) { - console.error(resp); - throw Error("Error requesting login flows. See console for error."); - } - const json = await resp.json() as ILoginFlows; - return json; - } - - private async getWellKnown(): Promise<IWellKnown> { - if (!this.hostname) { - throw Error("Hostname must be set first"); - } - const resp = await fetch(`${this.hostname}/.well-known/matrix/client`); - if (!resp.ok) { - console.error(resp); - throw Error("Error requesting login flows. See console for error."); - } - const json = await resp.json() as IWellKnown; - return json; - } - - public async passwordLogin(username: string, password: string, triesLeft = 5) { - if (!this.database) { - await this.createDatabase(); - } - if (!username) { - throw Error("Username must be set"); - } - if (!password) { - throw Error("Password must be set"); - } - this.mxid = username; - await this.setHostname(`https://${username.split(':')[1]}`); - - try { - const well_known = await this.getWellKnown(); - if (well_known["m.homeserver"]?.base_url) { - await this.setHostname(well_known["m.homeserver"].base_url); - } - if (well_known["org.matrix.msc3575.proxy"]?.url) { - // Write to database - const tx = this.database?.transaction('loginInfo', 'readwrite'); - await tx?.store.put({ - userId: this.mxid!, - hostname: this.hostname, - slidingSyncHostname: well_known["org.matrix.msc3575.proxy"].url, - access_token: this.access_token, - device_id: this.device_id, - }); - await tx?.done - - // Set the sliding sync proxy - this.slidingSyncHostname = well_known["org.matrix.msc3575.proxy"].url; - } else { - throw Error("No sliding sync proxy found"); - } - } catch (e: any) { - console.warn(`No well-known found for ${this.hostname}:\n${e}`); - } - - const loginFlows = await this.getLoginFlows(); - if ((loginFlows.flows.filter((flow) => flow.type === 'm.login.password')?.length || 0) == 0) { - throw Error("Password login is not supported by this homeserver"); - } - - const resp = await fetch(`${this.hostname}/_matrix/client/r0/login`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - type: "m.login.password", - identifier: { - type: 'm.id.user', - user: username, - }, - user: username, - password: password - }) - }); - if (!resp.ok) { - console.error(resp); - throw Error("Error logging in. See console for error."); - } - const json = await resp.json(); - if (isErrorResp(json)) { - throw Error(`Error logging in: ${json.errcode}: ${json.error}`); - } - if (isRateLimitError(json)) { - console.error(`Rate limited. Retrying in ${json.retry_after_ms}ms. ${triesLeft} tries left.`); - await this.passwordLogin(username, password, triesLeft - 1); - } - if (isLoginResponse(json)) { - // Write to database - const tx = this.database?.transaction('loginInfo', 'readwrite'); - await tx?.store.put({ - userId: json.user_id!, - hostname: this.hostname, - slidingSyncHostname: this.slidingSyncHostname, - access_token: json.access_token, - device_id: json.device_id, - }); - await tx?.done - this.access_token = json.access_token; - this.device_id = json.device_id; - this.mxid = json.user_id; - - this.olmMachine = await OlmMachine.initialize(new UserId(this.mxid), new DeviceId(this.device_id!), "cetirizine-crypto"); - } + public async startSync() { + await this.sync.startSync(); } public async fetchProfileInfo(userId: string): Promise<IProfileInfo> { if (this.profileInfo) { return this.profileInfo; } - if (!this.hostname) { + if (!this.user.hostname) { throw Error("Hostname must be set first"); } if (!this.database) { await this.createDatabase(); } - if (!this.access_token) { + if (!this.user.access_token) { throw Error("Access token must be set first"); } - const resp = await fetch(`${this.hostname}/_matrix/client/r0/profile/${userId}`, { + const resp = await fetch(`${this.user.hostname}/_matrix/client/r0/profile/${userId}`, { headers: { - "Authorization": `Bearer ${this.access_token}` + "Authorization": `Bearer ${this.user.access_token}` } }); if (!resp.ok) { @@ -1450,15 +396,15 @@ export class MatrixClient extends EventEmitter { throw Error("Error fetching profile info. See console for error."); } const json = await resp.json() as IProfileInfo; - json.avatar_url = json.avatar_url?.replace("mxc://", `${this.hostname}/_matrix/media/r0/download/`); + json.avatar_url = json.avatar_url?.replace("mxc://", `${this.user.hostname}/_matrix/media/r0/download/`); this.profileInfo = json; const tx = this.database?.transaction('loginInfo', 'readwrite'); await tx?.store.put({ - userId: this.mxid!, - device_id: this.device_id!, - hostname: this.hostname, - slidingSyncHostname: this.slidingSyncHostname, - access_token: this.access_token, + userId: this.user.mxid!, + device_id: this.user.device_id!, + hostname: this.user.hostname, + slidingSyncHostname: this.user.slidingSyncHostname, + access_token: this.user.access_token, displayName: json.displayname, avatarUrl: json.avatar_url, }); @@ -1468,18 +414,10 @@ export class MatrixClient extends EventEmitter { } } -function isRateLimitError(arg: any): arg is IRateLimitError { +export function isRateLimitError(arg: any): arg is IRateLimitError { return arg.retry_after_ms !== undefined; } -function isLoginResponse(arg: any): arg is ILoginResponse { - return arg.access_token !== undefined; -} - -function isErrorResp(arg: any): arg is IErrorResp { - return arg.errcode !== undefined; -} - export const defaultMatrixClient: MatrixClient = await MatrixClient.Instance(); export const MatrixContext = createContext<MatrixClient>(defaultMatrixClient); diff --git a/src/app/sdk/e2ee.ts b/src/app/sdk/e2ee.ts @@ -0,0 +1,311 @@ +import { + DeviceId, + DeviceLists, + KeysBackupRequest, + KeysClaimRequest, + KeysQueryRequest, + KeysUploadRequest, + OlmMachine, + RequestType, + RoomId, + RoomMessageRequest, + SignatureUploadRequest, + ToDeviceRequest, + UserId +} from "@mtrnord/matrix-sdk-crypto-js"; +import { MatrixClient } from "./client"; +import { OwnUser } from "./ownUser"; +import { Room } from "./room"; +import { IRoomEvent } from "./api/events"; + +export class MatrixE2EE { + private olmMachine?: OlmMachine; + private outgoingRequestsBeingProcessed = false; + private missingSessionsBeingRequested = false; + + constructor(private client: MatrixClient, private user: OwnUser) { } + + public async decryptRoomEvent(roomID: string, event: IRoomEvent<any>) { + return await this.olmMachine?.decryptRoomEvent(JSON.stringify(event), new RoomId(roomID)); + } + + public async receiveSyncData( + to_device_events: string, + changed_devices: DeviceLists, + one_time_key_counts: Map<any, any>, + unused_fallback_keys?: Set<any> + ) { + await this.olmMachine?.receiveSyncChanges( + to_device_events, + changed_devices, + one_time_key_counts, + unused_fallback_keys + ); + } + + public async encryptRoomEvent(roomID: RoomId, type: string, content: any): Promise<any> { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.hostname) { + throw Error("Hostname must be set first"); + } + if (!this.olmMachine) { + throw Error("Olm machine must be set first"); + } + await this.olmMachine?.encryptRoomEvent(roomID, type, content); + } + + public async initOlmMachine(userID: UserId, deviceID: DeviceId, storePassphrase?: string) { + this.olmMachine = await OlmMachine.initialize(userID, deviceID, "cetirizine-crypto", storePassphrase); + } + + public async updateTrackedUsers(users: any[]) { + await this.olmMachine?.updateTrackedUsers(users); + } + + public async sendIdentifyAndOneTimeKeys() { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + if (!this.olmMachine) { + throw Error("Olm machine must be set first"); + } + + if (this.outgoingRequestsBeingProcessed) { + return; + } + this.outgoingRequestsBeingProcessed = true; + + const outgoing_requests = await this.olmMachine.outgoingRequests(); + + for (const request of outgoing_requests) { + await this.processRequest(request); + } + + await this.getMissingSessions(); + this.outgoingRequestsBeingProcessed = false; + } + + public async shareKeysForRoom(room: Room) { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + if (!this.olmMachine) { + throw Error("Olm machine must be set first"); + } + const encryptionSettings = room.getEncryptionSettings(); + if (encryptionSettings) { + const requests = await this.olmMachine.shareRoomKey(new RoomId(room.roomID), room.getJoinedMemberIDs().map(id => new UserId(id)), encryptionSettings); + for (const request of requests) { + await this.processRequest(request); + } + } + } + + public async getMissingSessions() { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + if (!this.olmMachine) { + throw Error("Olm machine must be set first"); + } + + if (this.missingSessionsBeingRequested) { + return; + } + this.missingSessionsBeingRequested = true; + + const encryptedRooms = [...this.client.getRooms()].filter(room => room.isEncrypted()); + const users = encryptedRooms.map(room => room.getJoinedMemberIDs().map(id => new UserId(id))).flat(); + const request = await this.olmMachine?.getMissingSessions(users); + if (request) { + await this.processRequest(request); + } + + this.missingSessionsBeingRequested = false; + } + + private async processRequest(request: any) { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + if (!this.olmMachine) { + throw Error("Olm machine must be set first"); + } + // Check which type the request is + if (request.type === RequestType.KeysUpload) { + // Send the key + const request_typed = request as KeysUploadRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/keys/upload`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to upload keys", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } else if (request.type === RequestType.KeysQuery) { + const request_typed = request as KeysQueryRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/keys/query`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to query keys", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } else if (request.type === RequestType.KeysClaim) { + const request_typed = request as KeysClaimRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/keys/claim`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to claim keys", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } else if (request.type === RequestType.ToDevice) { + const request_typed = request as ToDeviceRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/sendToDevice/${request_typed.event_type}/${request_typed.txn_id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to send to device", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id ?? request_typed.txn_id, request_typed.type, await response.text()); + } else if (request.type === RequestType.SignatureUpload) { + const request_typed = request as SignatureUploadRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/keys/signatures/upload`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to upload signatures", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } else if (request.type === RequestType.RoomMessage) { + const request_typed = request as RoomMessageRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/rooms/${request_typed.room_id}/send/${request_typed.event_type}/${request_typed.txn_id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to send message", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } else if (request.type === RequestType.KeysBackup) { + const request_typed = request as KeysBackupRequest; + const response = await fetch( + `${this.user.hostname}/_matrix/client/v3/room_keys/keys`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: request_typed.body + } + ) + if (!response.ok) { + if (response.status === 401) { + await this.client.logout(); + console.error(response); + } + console.error("Failed to backup keys", response); + return; + } + this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text()); + } + } + + public logoutE2ee() { + this.missingSessionsBeingRequested = false; + this.outgoingRequestsBeingProcessed = false; + } +} +\ No newline at end of file diff --git a/src/app/sdk/ownUser.ts b/src/app/sdk/ownUser.ts @@ -0,0 +1,195 @@ +import { DeviceId, UserId } from "@mtrnord/matrix-sdk-crypto-js"; +import { IErrorResp, ILoginFlows, ILoginResponse, IWellKnown } from "./api/apiTypes"; +import { MatrixClient, isRateLimitError } from "./client"; +import { MatrixE2EE } from "./e2ee"; + +export class OwnUser { + public access_token?: string; + public device_id?: string; + public mxid?: string; + // Hostname including "https://" + public hostname?: string; + public slidingSyncHostname?: string; + public e2ee: MatrixE2EE; + + constructor(private client: MatrixClient) { + this.e2ee = new MatrixE2EE(this.client, this); + } + + // TODO: call logout endpoint on logout + public async logout() { + if (!this.mxid) { + throw Error("Not logged in"); + } + if (!this.access_token) { + throw Error("Not logged in"); + } + if (!this.hostname) { + throw Error("Hostname must be set first"); + } + const resp = await fetch(`${this.hostname}/_matrix/client/v3/logout`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.access_token}` + }, + }); + if (!resp.ok) { + console.error(resp); + throw Error("Error logging out. See console for error."); + } + + this.access_token = undefined; + this.device_id = undefined; + this.slidingSyncHostname = undefined; + } + + + + public async setHostname(hostname: string) { + if (!hostname.startsWith("https://")) { + throw Error("Hostname must start with 'https://'"); + } + if (!this.client.database) { + await this.client.createDatabase(); + } + + // Write to database + const tx = this.client.database?.transaction('loginInfo', 'readwrite'); + await tx?.store.put({ + userId: this.mxid!, + hostname: hostname, + slidingSyncHostname: this.slidingSyncHostname, + access_token: this.access_token, + device_id: this.device_id, + }); + await tx?.done + + // Set in memory + this.hostname = hostname; + } + + private async getLoginFlows(): Promise<ILoginFlows> { + if (!this.hostname) { + throw Error("Hostname must be set first"); + } + const resp = await fetch(`${this.hostname}/_matrix/client/v3/login`); + if (!resp.ok) { + console.error(resp); + throw Error("Error requesting login flows. See console for error."); + } + const json = await resp.json() as ILoginFlows; + return json; + } + + private async getWellKnown(): Promise<IWellKnown> { + if (!this.hostname) { + throw Error("Hostname must be set first"); + } + const resp = await fetch(`${this.hostname}/.well-known/matrix/client`); + if (!resp.ok) { + console.error(resp); + throw Error("Error requesting login flows. See console for error."); + } + const json = await resp.json() as IWellKnown; + return json; + } + + public async passwordLogin(username: string, password: string, triesLeft = 5) { + if (!this.client.database) { + await this.client.createDatabase(); + } + if (!username) { + throw Error("Username must be set"); + } + if (!password) { + throw Error("Password must be set"); + } + this.mxid = username; + await this.setHostname(`https://${username.split(':')[1]}`); + + try { + const well_known = await this.getWellKnown(); + if (well_known["m.homeserver"]?.base_url) { + await this.setHostname(well_known["m.homeserver"].base_url); + } + if (well_known["org.matrix.msc3575.proxy"]?.url) { + // Write to database + const tx = this.client.database?.transaction('loginInfo', 'readwrite'); + await tx?.store.put({ + userId: this.mxid!, + hostname: this.hostname, + slidingSyncHostname: well_known["org.matrix.msc3575.proxy"].url, + access_token: this.access_token, + device_id: this.device_id, + }); + await tx?.done + + // Set the sliding sync proxy + this.slidingSyncHostname = well_known["org.matrix.msc3575.proxy"].url; + } else { + throw Error("No sliding sync proxy found"); + } + } catch (e: any) { + console.warn(`No well-known found for ${this.hostname}:\n${e}`); + } + + const loginFlows = await this.getLoginFlows(); + if ((loginFlows.flows.filter((flow) => flow.type === 'm.login.password')?.length || 0) == 0) { + throw Error("Password login is not supported by this homeserver"); + } + + const resp = await fetch(`${this.hostname}/_matrix/client/r0/login`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { + type: 'm.id.user', + user: username, + }, + user: username, + password: password + }) + }); + if (!resp.ok) { + console.error(resp); + throw Error("Error logging in. See console for error."); + } + const json = await resp.json(); + if (isErrorResp(json)) { + throw Error(`Error logging in: ${json.errcode}: ${json.error}`); + } + if (isRateLimitError(json)) { + console.error(`Rate limited. Retrying in ${json.retry_after_ms}ms. ${triesLeft} tries left.`); + await this.passwordLogin(username, password, triesLeft - 1); + } + if (isLoginResponse(json)) { + // Write to database + const tx = this.client.database?.transaction('loginInfo', 'readwrite'); + await tx?.store.put({ + userId: json.user_id!, + hostname: this.hostname, + slidingSyncHostname: this.slidingSyncHostname, + access_token: json.access_token, + device_id: json.device_id, + }); + await tx?.done + this.access_token = json.access_token; + this.device_id = json.device_id; + this.mxid = json.user_id; + + await this.e2ee.initOlmMachine(new UserId(this.mxid), new DeviceId(this.device_id)); + } + } +} + +function isLoginResponse(arg: any): arg is ILoginResponse { + return arg.access_token !== undefined; +} + +function isErrorResp(arg: any): arg is IErrorResp { + return arg.errcode !== undefined; +} +\ No newline at end of file diff --git a/src/app/sdk/room.ts b/src/app/sdk/room.ts @@ -1,9 +1,18 @@ import EventEmitter from "events"; -import { IRoomEvent, IRoomStateEvent, isRoomAvatarEvent, isRoomCreateEvent, isRoomTopicEvent, isSpaceChildEvent, isSpaceParentEvent } from "./api/apiTypes"; +import { + IRoomEvent, + IRoomStateEvent, + isRoomAvatarEvent, + isRoomCreateEvent, + isRoomTopicEvent, + isSpaceChildEvent, + isSpaceParentEvent +} from "./api/events"; import { MatrixClient } from "./client"; import { useEffect, useState } from "react"; import { EncryptionAlgorithm, EncryptionSettings, RoomId } from "@mtrnord/matrix-sdk-crypto-js"; import { OnlineState } from "./api/otherEnums"; +import { MatrixE2EE } from "./e2ee"; export interface RoomEvents { // Used to notify about changes to the event list @@ -37,7 +46,7 @@ export class Room extends EventEmitter { } = {} - constructor(public roomID: string, private hostname: string, private client: MatrixClient) { + constructor(public roomID: string, private hostname: string, private client: MatrixClient, private e2ee: MatrixE2EE) { super(); } @@ -248,9 +257,9 @@ export class Room extends EventEmitter { return json.event_id; } else { console.log("Sending encrypted message"); - await this.client.getMissingSessions(); - await this.client.shareKeysForRoom(this); - const encrypted = await this.client.olmMachine?.encryptRoomEvent( + await this.e2ee.getMissingSessions(); + await this.e2ee.shareKeysForRoom(this); + const encrypted = await this.e2ee.encryptRoomEvent( new RoomId(this.roomID), "m.room.message", JSON.stringify({ @@ -296,9 +305,9 @@ export class Room extends EventEmitter { return json.event_id; } else { console.log("Sending encrypted message2"); - await this.client.getMissingSessions(); - await this.client.shareKeysForRoom(this); - const encrypted = await this.client.olmMachine?.encryptRoomEvent( + await this.e2ee.getMissingSessions(); + await this.e2ee.shareKeysForRoom(this); + const encrypted = await this.e2ee.encryptRoomEvent( new RoomId(this.roomID), "m.room.message", JSON.stringify({ diff --git a/src/app/sdk/slidingSync.ts b/src/app/sdk/slidingSync.ts @@ -0,0 +1,730 @@ +import { MatrixClient } from "./client"; +import { ISlidingSyncReq, ISlidingSyncResp, isDeleteOp, isInsertOp, isInvalidateOp, isSyncOp } from './api/slidingSync'; +import EventEmitter from "events"; +import { Room } from "./room"; +import { OwnUser } from "./ownUser"; +import { DeviceLists, UserId } from "@mtrnord/matrix-sdk-crypto-js"; +import { IRoomEvent, IRoomStateEvent, isRoomStateEvent } from "./api/events"; + +export interface MatrixSlidingSyncEvents { + // Used to notify about changes to the room list + 'rooms': (rooms: Set<Room>) => void; + //'delete': (changedCount: number) => void; +} + +export declare interface MatrixSlidingSync { + on<U extends keyof MatrixSlidingSyncEvents>( + event: U, listener: MatrixSlidingSyncEvents[U] + ): this; + + emit<U extends keyof MatrixSlidingSyncEvents>( + event: U, ...args: Parameters<MatrixSlidingSyncEvents[U]> + ): boolean; +} + +export class MatrixSlidingSync extends EventEmitter { + private syncing = false; + private syncPos?: string; + private initialSync = true; + private lastRanges?: { [key: string]: number[][] }; + private lastTxnID?: string; + private to_device_since?: string; + public mustUpdateTxnID = true; + public rooms: Set<Room> = new Set(); + private abortController = new AbortController(); + + constructor(private client: MatrixClient, private user: OwnUser) { super() } + + public applyStoredSyncInfo(syncInfo: { + userId: string; + syncPos?: string; + initialSync: boolean; + lastRanges?: { + [key: string]: number[][]; + }; + lastTxnID?: string; + to_device_since?: string; + }) { + this.syncPos = syncInfo.syncPos; + this.initialSync = syncInfo.initialSync; + this.lastRanges = syncInfo.lastRanges; + this.lastTxnID = syncInfo.lastTxnID; + this.to_device_since = syncInfo.to_device_since; + } + + public logout() { + this.stopSync(); + this.abortController.abort(); + this.rooms = new Set(); + this.initialSync = true; + this.syncPos = undefined; + this.to_device_since = undefined; + } + + public resetAbortController() { + this.abortController = new AbortController(); + } + + public async startSync() { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.client.database) { + await this.client.createDatabase(); + } + if (this.syncing) { + return; + } + this.syncing = true; + while (this.syncing) { + try { + await this.sync(); + } catch (e) { + console.error(e); + } + } + } + + public stopSync() { + this.syncing = false; + } + + private isIndexInRange(index: number, ranges: number[][]): boolean { + for (const r of ranges) { + if (r[0] < index && index <= r[1]) { + return true + } + } + return false + } + + private shiftRight(listKey: string, ranges: number[][], hi: number, low: number) { + // l h + // 0,1,2,3,4 <- before + // 0,1,2,2,3 <- after, hi is deleted and low is duplicated + for (let i = hi - 1; i > low - 1; i--) { + if (this.isIndexInRange(i, ranges)) { + const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i + 1); + if (roomObj) { + roomObj.windowPos[listKey] = (i); + } + } + } + } + + private shiftLeft(listKey: string, ranges: number[][], hi: number, low: number) { + // l h + // 0,1,2,3,4 <- before + // 0,1,3,4,4 <- after, low is deleted and hi is duplicated + for (let i = low + 1; i < hi + 1; i++) { + if (this.isIndexInRange(i, ranges)) { + const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i - 1); + if (roomObj) { + roomObj.windowPos[listKey] = (i); + } + } + } + + } + + private async removeEntry(listKey: string, ranges: number[][], index: number) { + // work out the max index + let max = -1; + const indexes = [...this.rooms].map(room => room.windowPos[listKey]); + for (const n in indexes) { + if (Number(n) > max) { + max = Number(n); + } + } + // TODO: Unclear if this is needed or working. Probably wrong? + // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === index); + // if (roomObj) { + // const tx = this.database?.transaction('rooms', 'readwrite'); + // await tx?.store.delete(roomObj.roomID); + // await tx?.done; + // this.rooms.delete(roomObj) + // } + if (max < 0 || index > max) { + return; + } + // Everything higher than the gap needs to be shifted left. + this.shiftLeft(listKey, ranges, max, index); + } + + private addEntry(listKey: string, ranges: number[][], index: number): void { + // work out the max index + let max = -1; + const indexes = [...this.rooms].map(room => room.windowPos[listKey]); + for (const n in indexes) { + if (Number(n) > max) { + max = Number(n); + } + } + if (max < 0 || index > max) { + return; + } + // Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element + this.shiftRight(listKey, ranges, max + 1, index); + } + + private async sync() { + if (!this.client.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.user.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + + await this.user.e2ee.sendIdentifyAndOneTimeKeys(); + + // This is the initial sync case for each list + let lists_ranges: { + "overview": number[][]; + "spaces": number[][]; + [key: string]: number[][]; + } = { + "overview": [[0, 20]], + "spaces": [[0, 20]] + }; + for (const space of this.client.spaceOpen) { + if (space === "other") { continue } + lists_ranges[space] = [[0, 20]]; + } + + let timeline_limit = 1; + let subscription_limit = 10; + if (!this.initialSync) { + for (const list in lists_ranges) { + // Set higher timeline limit for subsequent syncs + timeline_limit = 10; + subscription_limit = 50; + // Calculate overlap between this.roomsInView and this.roomToRoomID and then + // calculate the ranges for each list + let rawRangeInView = new Set([...this.rooms] + .filter(room => this.client.roomsInView.includes(room.roomID)) + .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) + + if (this.client.getSpaces().find(r => r.roomID === list)) { + // If we are syncing the spaces list, we need to use the spaceInView list instead + rawRangeInView = new Set([...this.rooms] + .filter(room => this.client.spacesInView.includes(room.roomID)) + .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) + } + + if (rawRangeInView.size !== 0) { + const minimum = Math.min(...rawRangeInView); + const maximum = Math.max(...rawRangeInView); + + lists_ranges[list] = [[Math.max(minimum - 10, 0), maximum + 10]]; + } + } + lists_ranges["e2ee"] = lists_ranges["overview"]; + } + + + if (this.lastRanges && Object.entries(lists_ranges).toString() !== Object.entries(this.lastRanges).toString()) { + console.log("Ranges changed, resetting sync txn_id", lists_ranges) + this.lastRanges = lists_ranges; + this.lastTxnID = Date.now().toString(); + } + + if (!this.lastRanges) { + this.lastRanges = lists_ranges; + this.lastTxnID = Date.now().toString(); + } + + if (this.mustUpdateTxnID) { + this.lastTxnID = Date.now().toString(); + this.mustUpdateTxnID = false; + } + + + let url = `${this.user.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=5000`; + if (this.syncPos) { + url = `${this.user.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=5000&pos=${this.syncPos}` + } + + const body: ISlidingSyncReq = { + // allows clients to know what request params reached the server, + // functionally similar to txn IDs on /send for events. + txn_id: this.lastTxnID, + + // a delta token to remember information between sessions. + // See "Bandwidth optimisations for persistent clients" for more information. + // TODO: This isnt implemented anywhere yet + //delta_token: "opaque-server-provided-string", + + // Sliding Window API + lists: { + "spaces": { + ranges: this.lastRanges["spaces"], + // slow_get_all_rooms: true, + sort: ["by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + ["m.room.tombstone", ""], + // Room Avatar + ["m.room.avatar", "*"], + // Room Topic + ["m.room.topic", "*"], + // Request only the m.room.member events required to render events in the timeline. + // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for + // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". + ["m.room.member", "$LAZY"], + // E2EE + ["m.room.encryption", ""], + ["m.room.history_visibility", ""], + ], + timeline_limit: 0, + filters: { + room_types: ["m.space"] + } + }, + "overview": { + ranges: this.lastRanges["overview"], + sort: ["by_notification_level", "by_recency", "by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + ["m.room.tombstone", ""], + // Room Avatar + ["m.room.avatar", "*"], + // Room Topic + ["m.room.topic", "*"], + // Request only the m.room.member events required to render events in the timeline. + // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for + // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". + ["m.room.member", "$LAZY"], + // E2EE + ["m.room.encryption", ""], + ["m.room.history_visibility", ""], + ], + timeline_limit: timeline_limit, + filters: { + not_room_types: ["m.space"], + } + }, + "e2ee": { + ranges: this.lastRanges["overview"], + sort: ["by_notification_level", "by_recency", "by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + ["m.room.tombstone", ""], + // Room Avatar + ["m.room.avatar", "*"], + // Room Topic + ["m.room.topic", "*"], + ["m.room.member", "*"], + // E2EE + ["m.room.encryption", ""], + ["m.room.history_visibility", ""], + ], + timeline_limit: timeline_limit, + filters: { + not_room_types: ["m.space"], + is_encrypted: true, + } + }, + }, + bump_event_types: ["m.room.message", "m.room.encrypted"], + + extensions: { + e2ee: { + enabled: true, + }, + to_device: { + enabled: true, + since: this.to_device_since + } + }, + }; + + for (const space of this.client.spaceOpen) { + if (space === "other") { continue } + if (!body.lists) { + body.lists = {}; + } + body.lists[space] = { + slow_get_all_rooms: true, + ranges: this.lastRanges[space], + sort: ["by_notification_level", "by_recency", "by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + ["m.room.tombstone", ""], + // Room Avatar + ["m.room.avatar", "*"], + // Room Topic + ["m.room.topic", "*"], + // Request only the m.room.member events required to render events in the timeline. + // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for + // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". + ["m.room.member", "$LAZY"], + // E2EE + ["m.room.encryption", ""], + ["m.room.history_visibility", ""], + ], + timeline_limit: timeline_limit, + filters: { + "spaces": [space] + } + } + } + + if (this.client.currentRoom) { + body.room_subscriptions = {}; + body.room_subscriptions[this.client.currentRoom] = { + sort: ["by_notification_level", "by_recency", "by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + ["m.room.create", ""], + ["m.room.tombstone", ""], + // Room Avatar + ["m.room.avatar", "*"], + // Room Topic + ["m.room.topic", "*"], + // Request only the m.room.member events required to render events in the timeline. + // The "$LAZY" value is a special sentinel value meaning "lazy loading" and is only valid for + // the "m.room.member" event type. For more information on the semantics, see "Lazy-Loading Room Members". + ["m.room.member", "$LAZY"], + // E2EE + ["m.room.encryption", ""], + ["m.room.history_visibility", ""], + ], + timeline_limit: subscription_limit, + filters: {} + } + } + + const resp = await fetch(url, { + method: "POST", + signal: this.abortController.signal, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.user.access_token}` + }, + body: JSON.stringify(body) + }); + if (!resp.ok) { + if (resp.status === 400) { + if ((await resp.json()).errcode === "M_UNKNOWN_POS") { + this.syncPos = undefined; + const syncInfoTX = this.client.database?.transaction('syncInfo', 'readwrite'); + await syncInfoTX?.store.put({ + userId: this.user.mxid!, + syncPos: this.syncPos, + initialSync: this.initialSync, + lastRanges: this.lastRanges, + lastTxnID: this.lastTxnID, + }); + await syncInfoTX?.done; + } + return; + } else if (resp.status === 401) { + await this.logout(); + console.error(resp); + console.error("Error syncing. See console for error."); + } + } + const json = await resp.json() as ISlidingSyncResp; + this.syncPos = json.pos; + + if (json.extensions?.to_device) { + await this.user.e2ee.receiveSyncData( + JSON.stringify(json.extensions.to_device.events || []), + new DeviceLists( + json.extensions.e2ee?.device_lists?.changed?.map( + user_id => new UserId(user_id) + ), + json.extensions.e2ee?.device_lists?.left?.map( + user_id => new UserId(user_id) + ) + ), + new Map(Object.entries(json.extensions.e2ee?.device_one_time_keys_count || [])), + new Set(json.extensions.e2ee?.device_unused_fallback_key_types) + ) + this.to_device_since = json.extensions.to_device.next_batch; + } + + await this.user.e2ee.sendIdentifyAndOneTimeKeys(); + + + const syncInfoTX = this.client.database?.transaction('syncInfo', 'readwrite'); + await syncInfoTX?.store.put({ + userId: this.user.mxid!, + syncPos: this.syncPos, + initialSync: this.initialSync, + lastRanges: this.lastRanges, + lastTxnID: this.lastTxnID, + to_device_since: this.to_device_since, + }); + await syncInfoTX?.done; + + let gapIndex = -1; + for (const listKey in json.lists) { + const list = json.lists[listKey]; + if (list.ops) { + for (const op of list.ops) { + if (isSyncOp(op)) { + const tx = this.client.database?.transaction('rooms', 'readwrite'); + for (let i = op.range[0]; i <= op.range[1]; i++) { + const roomID = op.room_ids[i - op.range[0]]; + if (!roomID) { + break; // we are at the end of list + } + + // Check if we already know this room and skip if we do. This is needed since we have 2 lists. + // The db would already do this but the obj list doesn't (even though its a Set. Thats a mystery yet to solve) + const roomObj = [...this.rooms].find(room => room.roomID === roomID); + if (roomObj) { + roomObj.windowPos[listKey] = i; + continue; + } + + const newRoom = new Room(roomID, this.user.hostname!, this.client, this.user.e2ee); + // We start to remember the Room now. + newRoom.setName(roomID); + newRoom.windowPos[listKey] = i; + + this.rooms.add(newRoom); + await tx?.store.put({ + windowPos: newRoom.windowPos, + roomID: newRoom.roomID, + name: newRoom.getName(), + notification_count: newRoom.getNotificationCount(), + highlight_count: newRoom.getNotificationHighlightCount(), + joined_count: newRoom.getJoinedCount(), + invited_count: newRoom.getInvitedCount(), + avatarUrl: newRoom.getAvatarURL(), + isSpace: newRoom.isSpace(), + isDM: newRoom.isDM(), + stateEvents: newRoom.getStateEvents(), + events: newRoom.getEvents(), + }); + } + await tx?.done; + } else if (isInsertOp(op)) { + console.log("Got INSERT OP", op); + const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === op.index); + if (roomObj) { + if (gapIndex < 0) { + // we haven't been told where to shift from, so make way for a new room entry. + this.addEntry(listKey, this.lastRanges[listKey], op.index); + } else if (gapIndex > op.index) { + // the gap is further down the list, shift every element to the right + // starting at the gap so we can just shift each element in turn: + // [A,B,C,_] gapIndex=3, op.index=0 + // [A,B,C,C] i=3 + // [A,B,B,C] i=2 + // [A,A,B,C] i=1 + // Terminate. We'll assign into op.index next. + this.shiftRight(listKey, this.lastRanges[listKey], gapIndex, op.index); + } else if (gapIndex < op.index) { + // the gap is further up the list, shift every element to the left + // starting at the gap so we can just shift each element in turn + this.shiftLeft(listKey, this.lastRanges[listKey], op.index, gapIndex); + } + } + gapIndex = -1; + const tx = this.client.database?.transaction('rooms', 'readwrite'); + // We start to remember the Room now. + const foundRoom = [...this.rooms].find(room => room.roomID === op.room_id); + if (foundRoom) { + foundRoom.windowPos[listKey] = op.index; + await tx?.store.put({ + windowPos: foundRoom.windowPos, + roomID: foundRoom.roomID, + name: foundRoom.getName(), + notification_count: foundRoom.getNotificationCount(), + highlight_count: foundRoom.getNotificationHighlightCount(), + joined_count: foundRoom.getJoinedCount(), + invited_count: foundRoom.getInvitedCount(), + avatarUrl: foundRoom.getAvatarURL(), + isSpace: foundRoom.isSpace(), + isDM: foundRoom.isDM(), + stateEvents: foundRoom.getStateEvents(), + events: foundRoom.getEvents(), + }); + } else { + const roomFromDB = await tx?.store.get(op.room_id); + let newRoom = new Room(op.room_id, this.user.hostname!, this.client, this.user.e2ee); + newRoom.setName(op.room_id); + newRoom.windowPos[listKey] = op.index; + if (roomFromDB) { + console.warn("Room in db but not in obj list.", op.room_id, "Updating obj list."); + newRoom = new Room(op.room_id, this.user.hostname!, this.client, this.user.e2ee) + newRoom.setName(roomFromDB.name); + newRoom.setNotificationCount(roomFromDB.notification_count); + newRoom.setNotificationHighlightCount(roomFromDB.highlight_count); + newRoom.setJoinedCount(roomFromDB.joined_count); + newRoom.setInvitedCount(roomFromDB.invited_count); + newRoom.setDM(roomFromDB.isDM || false); + } + this.rooms.add(newRoom); + await tx?.store.put({ + windowPos: newRoom.windowPos, + roomID: newRoom.roomID, + name: newRoom.getName(), + notification_count: newRoom.getNotificationCount(), + highlight_count: newRoom.getNotificationHighlightCount(), + joined_count: newRoom.getJoinedCount(), + invited_count: newRoom.getInvitedCount(), + avatarUrl: newRoom.getAvatarURL(), + isSpace: newRoom.isSpace(), + isDM: newRoom.isDM(), + stateEvents: newRoom.getStateEvents(), + events: newRoom.getEvents(), + }); + } + + const roomIDs2 = [...this.rooms].map(room => room.roomID); + // Check if we generated any duplicates and log them. + const duplicates = roomIDs2.filter((item, index) => roomIDs2.indexOf(item) != index); + if (duplicates.length > 0) { + console.error("Duplicates found", duplicates); + } + await tx?.done; + } else if (isDeleteOp(op)) { + console.log("Got DELETE OP", op); + + if (gapIndex !== -1) { + // we already have a DELETE operation to process, so process it. + await this.removeEntry(listKey, this.lastRanges[listKey], gapIndex); + } + gapIndex = op.index; + } else if (isInvalidateOp(op)) { + // TODO: Figure out if this is needed in reality + // const tx = this.database?.transaction('rooms', 'readwrite'); + // for (let i = op.range[0]; i <= op.range[1]; i++) { + // // We shall first forget about these and "startover" + // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i); + // if (roomObj) { + // await tx?.store.delete(roomObj.roomID); + // this.rooms.delete(roomObj) + // } + // } + // await tx?.done; + } + } + if (gapIndex !== -1) { + // we already have a DELETE operation to process, so process it + // Everything higher than the gap needs to be shifted left. + await this.removeEntry(listKey, this.lastRanges[listKey], gapIndex); + } + } + } + for (const roomID in json.rooms) { + const room = json.rooms[roomID]; + const name = room.name; + const notification_count = room.notification_count; + const notification_highlight_count = room.highlight_count; + const joined_count = room.joined_count; + const invited_count = room.invited_count; + const events = room.timeline; + const state_events = events?.filter(event => isRoomStateEvent(event)).map(event => event as IRoomStateEvent); + const normal_events = events?.filter(event => !isRoomStateEvent(event)).map(event => event as IRoomEvent); + const required_state = room.required_state; + const is_dm = room.is_dm; + + let roomObj = [...this.rooms].find(room => room.roomID === roomID); + if (!roomObj) { + // Warn, check in the db and if that fails, create a new one. + console.warn("Could not find roomObj for roomID:", roomID); + + const tx = this.client.database?.transaction('rooms', 'readwrite'); + const roomFromDB = await tx?.store.get(roomID); + await tx?.done; + + if (roomFromDB) { + console.warn("Room in db but not in obj list.", roomID, "Updating obj list."); + + roomObj = new Room(roomID, this.user.hostname!, this.client, this.user.e2ee); + roomObj.setName(roomFromDB.name); + roomObj.setNotificationCount(roomFromDB.notification_count); + roomObj.setNotificationHighlightCount(roomFromDB.highlight_count); + roomObj.setJoinedCount(roomFromDB.joined_count); + roomObj.setInvitedCount(roomFromDB.invited_count); + roomObj.setDM(roomFromDB.isDM || false); + if (roomFromDB.events) { + roomObj.addEvents(roomFromDB.events); + } + if (roomFromDB.stateEvents) { + roomObj.addStateEvents(roomFromDB.stateEvents); + } + roomObj.windowPos = roomFromDB.windowPos; + } else { + console.warn("Could not find room in db. Creating new one."); + roomObj = new Room(roomID, this.user.hostname!, this.client, this.user.e2ee); + this.rooms.add(roomObj); + } + } + + if (name) { + roomObj.setName(name); + } + roomObj.setNotificationCount(notification_count); + roomObj.setNotificationHighlightCount(notification_highlight_count); + roomObj.setJoinedCount(joined_count); + roomObj.setInvitedCount(invited_count); + if (normal_events) { + roomObj.addEvents(normal_events); + } + if (required_state) { + roomObj.addStateEvents(required_state); + } + if (state_events) { + roomObj.addStateEvents(state_events); + } + if (required_state || state_events) { + if (roomObj.isEncrypted() && roomObj.isJoined()) { + const joinEvents = [...(required_state || []), ...(state_events || [])] + .filter(event => event.type === "m.room.member" && event.content.membership === "join"); + const memberIds = joinEvents.map(event => new UserId(event.state_key)); + await this.user.e2ee.updateTrackedUsers(memberIds); + } + } + if (is_dm) { + roomObj.setDM(is_dm); + } + + + const tx = this.client.database?.transaction('rooms', 'readwrite'); + // Write to database + await tx?.store.put({ + windowPos: roomObj.windowPos, + roomID: roomObj.roomID, + name: roomObj.getName(), + notification_count: roomObj.getNotificationCount(), + highlight_count: roomObj.getNotificationHighlightCount(), + joined_count: roomObj.getJoinedCount(), + invited_count: roomObj.getInvitedCount(), + events: roomObj.getEvents(), + stateEvents: roomObj.getStateEvents(), + avatarUrl: roomObj.getAvatarURL(), + isSpace: roomObj.isSpace(), + isDM: roomObj.isDM(), + }); + await tx?.done + } + + if (this.initialSync) { + this.initialSync = false; + console.log("initialSyncComplete"); + } + if (json.rooms && Object.keys(json.rooms).length > 0) { + this.emit("rooms", this.rooms); + } + } +} +\ No newline at end of file diff --git a/src/components/events/memberEvent.tsx b/src/components/events/memberEvent.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { IRoomMemberEvent } from "../../app/sdk/api/apiTypes"; +import { IRoomMemberEvent } from "../../app/sdk/api/events"; import { FC } from "react"; type MemberEventProps = { diff --git a/src/components/events/messageEvent.tsx b/src/components/events/messageEvent.tsx @@ -1,8 +1,8 @@ import { memo, useContext, useEffect, useState } from "react"; -import { IRoomEvent, isRoomMessageAudioEvent, isRoomMessageImageEvent, isRoomMessageNoticeEvent, isRoomMessageTextEvent } from "../../app/sdk/api/apiTypes"; +import { IRoomEvent, isRoomMessageAudioEvent, isRoomMessageImageEvent, isRoomMessageNoticeEvent, isRoomMessageTextEvent } from "../../app/sdk/api/events"; import { FC } from "react"; import Avatar from "../avatar/avatar"; -import { MatrixContext, useRoom } from "../../app/sdk/client"; +import { MatrixClient, MatrixContext, useRoom } from "../../app/sdk/client"; import Linkify from "linkify-react"; import linkifyHtml from 'linkify-html'; import DOMPurify from "dompurify"; @@ -39,6 +39,43 @@ const linkifyOptions = { className: "text-blue-500 hover:text-blue-700 active:text-blue-700 visited:text-blue-500" } +const decryptMedia = (client: MatrixClient, event: IRoomEvent, decryptedCallback: (url: string) => void, failureCallback: (error: string) => void) => { + console.log("Downloading media:", event.event_id); + fetch(client.convertMXC(event.content.file.url), { + headers: { + Authorization: `Bearer ${client.accessToken}` + } + }).then((response) => { + if (!response.ok) { + // TODO: display error? + console.log("Unable to decrypt media:", response.text()); + return; + } + console.log("Downloaded media:", event.event_id); + response.arrayBuffer().then((responseData) => { + // Decrypt the array buffer using the information taken from the event content. + decryptAttachment(responseData, event.content.file).then((dataArray) => { + // Turn the array into a Blob and give it the correct MIME-type. + + // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise + // they introduce XSS attacks if the Blob URI is viewed directly in the + // browser (e.g. by copying the URI into a new tab or window.) + // See warning at top of file. + let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; + mimetype = getBlobSafeMimeType(mimetype); + + const blob = new Blob([dataArray], { type: mimetype }); + // TODO: Cache media in indexeddb + decryptedCallback(URL.createObjectURL(blob)); + console.log("Decrypted media:", event.event_id); + }).catch((e: any) => { + console.log("Unable to decrypt media due to decryption error:", e); + failureCallback(`Unable to decrypt media due to decryption error: ${e}`); + }); + }); + }); +} + const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEvent, reactions }) => { const client = useContext(MatrixContext); const room = useRoom(roomID); @@ -53,43 +90,6 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv const [url, setUrl] = useState<string | undefined>(undefined); const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); - const decryptImage = (event: IRoomEvent) => { - console.log("Downloading image:", event.event_id); - fetch(client.convertMXC(event.content.file.url), { - headers: { - Authorization: `Bearer ${client.accessToken}` - } - }).then((response) => { - if (!response.ok) { - // TODO: display error? - console.log("Unable to decrypt image:", response.text()); - return; - } - console.log("Downloaded image:", event.event_id); - response.arrayBuffer().then((responseData) => { - // Decrypt the array buffer using the information taken from the event content. - decryptAttachment(responseData, event.content.file).then((dataArray) => { - // Turn the array into a Blob and give it the correct MIME-type. - - // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise - // they introduce XSS attacks if the Blob URI is viewed directly in the - // browser (e.g. by copying the URI into a new tab or window.) - // See warning at top of file. - let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; - mimetype = getBlobSafeMimeType(mimetype); - - const blob = new Blob([dataArray], { type: mimetype }); - setUrl(URL.createObjectURL(blob)); - setUnableToDecrypt(false); - console.log("Decrypted image:", event.event_id); - }).catch((e: any) => { - console.log("Unable to decrypt image due to decryption error:", e); - setUnableToDecrypt(true); - }); - }); - }); - } - useEffect(() => { if (isRoomMessageImageEvent(event)) { if (event.content.url) { @@ -97,7 +97,17 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv } else { // Image is encrypted and we need to download and decrypt it if (event.content.file) { - decryptImage(event); + decryptMedia( + client, + event, + (url) => { + setUrl(url); + setUnableToDecrypt(false); + }, + (_error) => { + setUnableToDecrypt(true); + } + ); } } } @@ -131,42 +141,6 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv const [url, setUrl] = useState<string | undefined>(undefined); const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); - const decryptAudio = (event: IRoomEvent) => { - console.log("Downloading audio file:", event.event_id); - fetch(client.convertMXC(event.content.file.url), { - headers: { - Authorization: `Bearer ${client.accessToken}` - } - }).then((response) => { - if (!response.ok) { - // TODO: display error? - console.log("Unable to decrypt audio file:", response.text()); - return; - } - console.log("Downloaded audio file:", event.event_id); - response.arrayBuffer().then((responseData) => { - // Decrypt the array buffer using the information taken from the event content. - decryptAttachment(responseData, event.content.file).then((dataArray) => { - // Turn the array into a Blob and give it the correct MIME-type. - - // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise - // they introduce XSS attacks if the Blob URI is viewed directly in the - // browser (e.g. by copying the URI into a new tab or window.) - // See warning at top of file. - let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; - mimetype = getBlobSafeMimeType(mimetype); - - const blob = new Blob([dataArray], { type: mimetype }); - setUrl(URL.createObjectURL(blob)); - setUnableToDecrypt(false); - console.log("Decrypted audio file:", event.event_id); - }).catch((e: any) => { - console.log("Unable to decrypt audio file due to decryption error:", e); - setUnableToDecrypt(true); - }); - }); - }); - } useEffect(() => { if (isRoomMessageAudioEvent(event)) { @@ -175,7 +149,17 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv } else { // Audio is encrypted and we need to download and decrypt it if (event.content.file) { - decryptAudio(event); + decryptMedia( + client, + event, + (url) => { + setUrl(url); + setUnableToDecrypt(false); + }, + (_error) => { + setUnableToDecrypt(true); + } + ); } } } diff --git a/src/components/events/unknownEvent.tsx b/src/components/events/unknownEvent.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { IRoomEvent } from "../../app/sdk/api/apiTypes"; +import { IRoomEvent } from "../../app/sdk/api/events"; import { FC } from "react"; import { useRoom } from "../../app/sdk/client"; import Linkify from "linkify-react"; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx @@ -1,8 +1,31 @@ import './LoginPage.scss'; import Login from '../components/login/login'; -import { memo } from 'react'; +import { memo, useContext, useEffect, useState } from 'react'; +import { MatrixContext } from '../app/sdk/client'; const LoginPage = memo(() => { + const matrixClient = useContext(MatrixContext); + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (loading) { + // Ensure logout worked + matrixClient.logout().then(() => { + setLoading(false) + }); + } + }, [loading, matrixClient]) + + if (loading) { + return ( + <div className="flex flex-col items-center justify-center min-h-screen bg-img"> + <div className="flex flex-col rounded-md shadow p-4 bg-white gap-2 min-w-[30rem]"> + <h1 className="text-2xl font-bold text-center">Loading...</h1> + </div> + </div> + ); + } + return ( <div className="flex flex-col items-center justify-center min-h-screen bg-img"> <Login /> diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx @@ -11,9 +11,8 @@ import { useLocation, useParams } from 'react-router-dom'; import MessageEvent from '../components/events/messageEvent'; import UnknownEvent, { RedactedEvent, UndecryptableEvent } from '../components/events/unknownEvent'; import MemberEvent from '../components/events/memberEvent'; -import { IRoomEvent, IRoomMemberEvent } from '../app/sdk/api/apiTypes'; +import { IRoomEvent, IRoomMemberEvent } from '../app/sdk/api/events'; import Linkify from 'linkify-react'; -import { RoomId } from '@mtrnord/matrix-sdk-crypto-js'; import { OnlineState } from '../app/sdk/api/otherEnums'; type ChatViewProps = { @@ -96,9 +95,9 @@ const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => { // Decrypt the event if it is encrypte - if (event.type === "m.room.encrypted") { + if (event.type === "m.room.encrypted" && roomID) { try { - const decrypted_event = await client.olmMachine?.decryptRoomEvent(JSON.stringify(event), new RoomId(roomID || "")); + const decrypted_event = await client.decryptRoomEvent(roomID, event); if (decrypted_event) { event = JSON.parse(decrypted_event.event) as IRoomEvent; if (event.content["m.new_content"]) { @@ -122,9 +121,9 @@ const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => { } // Decrypt previousEvent if it is encrypted - if (previousEvent?.type === "m.room.encrypted") { + if (previousEvent?.type === "m.room.encrypted" && roomID) { try { - const decrypted_event = await client.olmMachine?.decryptRoomEvent(JSON.stringify(previousEvent), new RoomId(roomID || "")); + const decrypted_event = await client.decryptRoomEvent(roomID, event); if (decrypted_event) { previousEvent = JSON.parse(decrypted_event.event) as IRoomEvent; previousEventType = previousEvent.type;