commit 3d86c3e024c6de1b98cbd39f619b3a3b7e21f50b
parent 709dcc61e86de38ca750bd736e9795c7ad58521b
Author: MTRNord <mtrnord1@gmail.com>
Date: Tue, 9 May 2023 18:52:12 +0200
Refactor client
Diffstat:
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;