commit 921aee17054ececb00f604c324b72c6423ab084d
parent 882942f9737bf5f96965f3160bd5f2ad4e4cdcbd
Author: MTRNord <mtrnord1@gmail.com>
Date: Fri, 5 May 2023 21:38:36 +0200
Implement sending encrypted messages
Diffstat:
5 files changed, 791 insertions(+), 162 deletions(-)
diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts
@@ -170,6 +170,37 @@ export interface ISyncResponse {
}
// 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;
diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts
@@ -12,6 +12,7 @@ import {
IRateLimitError,
IRoomEvent,
IRoomStateEvent,
+ ISlidingSyncReq,
ISlidingSyncResp,
IWellKnown,
isDeleteOp,
@@ -27,7 +28,7 @@ import {
IDBPDatabase,
openDB
} from "idb";
-import { DeviceId, DeviceLists, KeysBackupRequest, KeysUploadRequest, OlmMachine, RequestType, RoomMessageRequest, SignatureUploadRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
+import { DeviceId, DeviceLists, KeysBackupRequest, KeysUploadRequest, OlmMachine, RequestType, RoomId, RoomMessageRequest, SignatureUploadRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { KeysQueryRequest } from "@matrix-org/matrix-sdk-crypto-js";
import { KeysClaimRequest } from "@matrix-org/matrix-sdk-crypto-js";
import { ToDeviceRequest } from "@matrix-org/matrix-sdk-crypto-js";
@@ -119,11 +120,27 @@ export class MatrixClient extends EventEmitter {
private lastTxnID?: string;
private to_device_since?: string;
public olmMachine?: OlmMachine;
+ private currentRoom?: string;
+ private abortController = new AbortController();
+
+ public get accessToken(): string | undefined {
+ return this.access_token;
+ }
public get isLoggedIn(): boolean {
return this.access_token !== undefined;
}
+ public setCurrentRoom(roomID?: string) {
+ if (roomID !== this.currentRoom) {
+ this.currentRoom = roomID;
+ console.log("Current room changed to", roomID, "restarting sync");
+ this.lastTxnID = Date.now().toString();
+ this.abortController.abort();
+ this.abortController = new AbortController();
+ }
+ }
+
public static async Instance() {
let instance = this._instance;
// Load from database if not done
@@ -158,7 +175,6 @@ export class MatrixClient extends EventEmitter {
instance.initialSync = syncInfo.initialSync;
instance.lastRanges = syncInfo.lastRanges;
instance.lastTxnID = syncInfo.lastTxnID;
- console.log("to_device_since:", syncInfo.to_device_since)
instance.to_device_since = syncInfo.to_device_since;
}
@@ -169,7 +185,7 @@ export class MatrixClient extends EventEmitter {
if (rooms) {
instance.rooms = new Set(rooms.map(room => {
- const roomObj = new Room(room.roomID, instance.hostname!);
+ const roomObj = new Room(room.roomID, instance.hostname!, instance);
roomObj.windowPos = room.windowPos;
roomObj.setInvitedCount(room.invited_count);
roomObj.setJoinedCount(room.joined_count);
@@ -257,7 +273,6 @@ export class MatrixClient extends EventEmitter {
await this.sync();
} catch (e) {
console.error(e);
- return;
}
}
}
@@ -426,7 +441,149 @@ export class MatrixClient extends EventEmitter {
if (!response.ok) {
console.error("Failed to send to device", response);
}
+ 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) {
+ console.error("Failed to upload signatures", response);
+ }
+ 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) {
+ console.error("Failed to send message", response);
+ }
+ 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) {
+ console.error("Failed to backup keys", response);
+ }
+ this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text());
+ }
+ }
+
+ await this.shareKeys();
+ }
+
+ public async shareKeys() {
+ 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");
+ }
+ for (const room of [...this.rooms].filter(room => room.isEncrypted())) {
+ const request = await this.olmMachine?.getMissingSessions(room.getJoinedMemberIDs().map(id => new UserId(id)));
+ if (!request) {
+ continue;
+ }
+ // 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) {
+ console.error("Failed to upload keys", response);
+ }
+ 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) {
+ console.error("Failed to query keys", response);
+ }
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) {
+ console.error("Failed to claim keys", response);
+ }
+ 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) {
+ console.error("Failed to send to device", response);
+ }
+ 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(
@@ -479,6 +636,137 @@ export class MatrixClient extends EventEmitter {
}
this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text());
}
+
+
+ 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) {
+ // 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) {
+ console.error("Failed to upload keys", response);
+ }
+ 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) {
+ console.error("Failed to query keys", response);
+ }
+ 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) {
+ console.error("Failed to claim keys", response);
+ }
+ 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) {
+ console.error("Failed to send to device", response);
+ }
+ console.log(request_typed);
+ 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) {
+ console.error("Failed to upload signatures", response);
+ }
+ 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) {
+ console.error("Failed to send message", response);
+ }
+ 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) {
+ console.error("Failed to backup keys", response);
+ }
+ this.olmMachine.markRequestAsSent(request_typed.id!, request_typed.type, await response.text());
+ }
+ }
+ }
}
}
@@ -502,10 +790,12 @@ export class MatrixClient extends EventEmitter {
"spaces": [[0, Number.MAX_SAFE_INTEGER]]
};
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
const rawRangeInView = new Set([...this.rooms]
@@ -545,17 +835,12 @@ export class MatrixClient extends EventEmitter {
const sorted = rangesInView.sort((a, b) => a[0] - b[0]);
// Deduplicate ranges
- const deduped = sorted.reduce((acc, cur, i, arr) => {
- if (i === 0) {
- acc.push(cur);
- return acc;
- }
- if (cur[0] === arr[i - 1][0] && cur[1] === arr[i - 1][1]) {
- return acc;
- }
- acc.push(cur);
- return acc;
- }, [] as [number, number][]);
+ let known = new Set()
+ let deduped = sorted.map(subarray =>
+ subarray.filter(item => !known.has(item) && known.add(item))
+ )
+ .filter(subarray => subarray.length === 2)
+ .filter(subarray => subarray[0] !== undefined && subarray[1] !== undefined && subarray[0] !== null && subarray[1] !== null)
lists_ranges[list] = deduped;
}
@@ -580,82 +865,113 @@ export class MatrixClient extends EventEmitter {
url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=30000&pos=${this.syncPos}`
}
- console.log("to_device_since:", this.to_device_since)
+ 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": {
+ slow_get_all_rooms: true,
+ sort: ["by_name"],
+ required_state: [
+ // needed to build sections
+ ["m.space.child", "*"],
+ ["m.space.parent", "*"],
+ ["m.room.create", ""],
+ // 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: {
+ 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", ""],
+ // 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: {}
+ },
+ },
+ bump_event_types: ["m.room.message", "m.room.encrypted"],
+
+ extensions: {
+ e2ee: {
+ enabled: true,
+ },
+ to_device: {
+ enabled: true,
+ since: this.to_device_since || null
+ }
+ },
+ };
+
+ 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", ""],
+ // 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: {
"Authorization": `Bearer ${this.access_token}`
},
- body: JSON.stringify({
- // allows clients to know what request params reached the server,
- // functionally similar to txn IDs on /send for events.
- // TODO: check resp
- 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: {
- // TODO: We need a list that fetches all spaces
- "spaces": {
- slow_get_all_rooms: true,
- sort: ["by_name"],
- required_state: [
- // needed to build sections
- ["m.space.child", "*"],
- ["m.space.parent", "*"],
- ["m.room.create", ""],
- // 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"],
- ],
- timeline_limit: timeline_limit,
- 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", ""],
- // 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"],
- ],
- timeline_limit: timeline_limit,
- filters: {}
- },
- },
- bump_event_types: ["m.room.message", "m.room.encrypted"],
-
- extensions: {
- e2ee: {
- enabled: true,
- },
- to_device: {
- enabled: true,
- // TODO: if not initial sync add since token
- since: this.to_device_since || null
- }
- }
- })
+ body: JSON.stringify(body)
});
if (!resp.ok) {
if (resp.status === 400) {
@@ -671,6 +987,7 @@ export class MatrixClient extends EventEmitter {
});
await syncInfoTX?.done;
}
+ return;
}
console.error(resp);
console.error("Error syncing. See console for error.");
@@ -679,7 +996,6 @@ export class MatrixClient extends EventEmitter {
this.syncPos = json.pos;
if (json.extensions?.to_device) {
- console.log("Processing to_device events")
await this.olmMachine?.receiveSyncChanges(
JSON.stringify(json.extensions.to_device.events || []),
new DeviceLists(
@@ -731,7 +1047,7 @@ export class MatrixClient extends EventEmitter {
continue;
}
- const newRoom = new Room(roomID, this.hostname!);
+ const newRoom = new Room(roomID, this.hostname!, this);
// We start to remember the Room now.
newRoom.setName(roomID);
newRoom.windowPos[listKey] = i;
@@ -797,12 +1113,12 @@ export class MatrixClient extends EventEmitter {
});
} else {
const roomFromDB = await tx?.store.get(op.room_id);
- let newRoom = new Room(op.room_id, this.hostname!);
+ 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!)
+ newRoom = new Room(op.room_id, this.hostname!, this)
newRoom.setName(roomFromDB.name);
newRoom.setNotificationCount(roomFromDB.notification_count);
newRoom.setNotificationHighlightCount(roomFromDB.highlight_count);
@@ -862,7 +1178,6 @@ export class MatrixClient extends EventEmitter {
}
}
}
- const tx = this.database?.transaction('rooms', 'readwrite');
for (const roomID in json.rooms) {
const room = json.rooms[roomID];
const name = room.name;
@@ -880,11 +1195,15 @@ export class MatrixClient extends EventEmitter {
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!);
+ roomObj = new Room(roomID, this.hostname!, this);
roomObj.setName(roomFromDB.name);
roomObj.setNotificationCount(roomFromDB.notification_count);
roomObj.setNotificationHighlightCount(roomFromDB.highlight_count);
@@ -900,7 +1219,7 @@ export class MatrixClient extends EventEmitter {
roomObj.windowPos = roomFromDB.windowPos;
} else {
console.warn("Could not find room in db. Creating new one.");
- roomObj = new Room(roomID, this.hostname!);
+ roomObj = new Room(roomID, this.hostname!, this);
this.rooms.add(roomObj);
}
}
@@ -933,6 +1252,8 @@ export class MatrixClient extends EventEmitter {
roomObj.setDM(is_dm);
}
+
+ const tx = this.database?.transaction('rooms', 'readwrite');
// Write to database
await tx?.store.put({
windowPos: roomObj.windowPos,
@@ -948,8 +1269,8 @@ export class MatrixClient extends EventEmitter {
isSpace: roomObj.isSpace(),
isDM: roomObj.isDM(),
});
+ await tx?.done
}
- await tx?.done
if (this.initialSync) {
this.initialSync = false;
diff --git a/src/app/sdk/room.ts b/src/app/sdk/room.ts
@@ -1,6 +1,26 @@
+import EventEmitter from "events";
import { IRoomEvent, IRoomStateEvent, isRoomAvatarEvent, isRoomCreateEvent, isRoomTopicEvent, isSpaceChildEvent, isSpaceParentEvent } from "./api/apiTypes";
+import { MatrixClient } from "./client";
+import { useEffect, useState } from "react";
+import { EncryptionAlgorithm, EncryptionSettings, RoomId } from "@matrix-org/matrix-sdk-crypto-js";
-export class Room {
+export interface RoomEvents {
+ // Used to notify about changes to the event list
+ 'events': (events: IRoomEvent[]) => void;
+ 'state_events': (stateEvents: IRoomStateEvent[]) => void;
+}
+
+export declare interface Room {
+ on<U extends keyof RoomEvents>(
+ event: U, listener: RoomEvents[U]
+ ): this;
+
+ emit<U extends keyof RoomEvents>(
+ event: U, ...args: Parameters<RoomEvents[U]>
+ ): boolean;
+}
+
+export class Room extends EventEmitter {
private events: IRoomEvent[] = [];
private stateEvents: IRoomStateEvent[] = [];
private name?: string;
@@ -16,30 +36,29 @@ export class Room {
} = {}
- constructor(public roomID: string, private hostname: string) { }
+ constructor(public roomID: string, private hostname: string, private client: MatrixClient) {
+ super();
+ }
public addEvents(events: IRoomEvent[]) {
- // if the event id is already known then we update the event instead of pushing it on to the Array
events.forEach((newEvent) => {
- const index = this.events.findIndex((oldEvent) => oldEvent.event_id === newEvent.event_id);
- if (index !== -1) {
- this.events[index] = newEvent;
- } else {
- this.events.push(newEvent);
- }
+ this.events.push(newEvent);
});
+
+ this.emit("events", this.events);
}
public addStateEvents(state: IRoomStateEvent[]) {
// if the state event id is already known then we update the event instead of pushing it on to the Array
state.forEach((newEvent) => {
- const index = this.stateEvents.findIndex((oldEvent) => oldEvent.event_id === newEvent.event_id);
+ const index = this.stateEvents.findIndex((oldEvent) => oldEvent.state_key === newEvent.state_key && oldEvent.type === newEvent.type);
if (index !== -1) {
this.stateEvents[index] = newEvent;
} else {
this.stateEvents.push(newEvent);
}
});
+ this.emit("state_events", this.stateEvents);
}
public getStateEvents(): IRoomStateEvent[] {
@@ -195,4 +214,178 @@ export class Room {
});
return isEncrypted;
}
+
+ public async sendHtmlMessage(html: string, plainText: string): Promise<string> {
+ if (!this.isEncrypted()) {
+ const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.message/${Date.now().toString()}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${this.client.accessToken}`
+ },
+ body: JSON.stringify({
+ "msgtype": "m.text",
+ "body": plainText,
+ "format": "org.matrix.custom.html",
+ "formatted_body": html
+ })
+ });
+ if (!resp.ok) {
+ throw new Error(`Failed to send message: ${resp.status} ${resp.statusText}`);
+ }
+ const json = await resp.json();
+ return json.event_id;
+ } else {
+ console.log("Sending encrypted message");
+ await this.client.shareKeys();
+ const encrypted = await this.client.olmMachine?.encryptRoomEvent(
+ new RoomId(this.roomID),
+ "m.room.message",
+ JSON.stringify({
+ "msgtype": "m.text",
+ "body": plainText,
+ "format": "org.matrix.custom.html",
+ "formatted_body": html
+ })
+ );
+ const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.encrypted/${Date.now().toString()}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${this.client.accessToken}`
+ },
+ body: encrypted
+ });
+ if (!resp.ok) {
+ throw new Error(`Failed to send message: ${resp.status} ${resp.statusText}`);
+ }
+ const json = await resp.json();
+ return json.event_id;
+ }
+ }
+
+ public async sendTextMessage(text: string): Promise<string> {
+ if (!this.isEncrypted()) {
+ const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.message/${Date.now().toString()}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${this.client.accessToken}`
+ },
+ body: JSON.stringify({
+ "msgtype": "m.text",
+ "body": text
+ })
+ });
+ if (!resp.ok) {
+ throw new Error(`Failed to send message: ${resp.status} ${resp.statusText}`);
+ }
+ const json = await resp.json();
+ return json.event_id;
+ } else {
+ console.log("Sending encrypted message2");
+ await this.client.shareKeys();
+ const encrypted = await this.client.olmMachine?.encryptRoomEvent(
+ new RoomId(this.roomID),
+ "m.room.message",
+ JSON.stringify({
+ "msgtype": "m.text",
+ "body": text
+ })
+ );
+ const resp = await fetch(`${this.hostname}/_matrix/client/v3/rooms/${this.roomID}/send/m.room.encrypted/${Date.now().toString()}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${this.client.accessToken}`
+ },
+ body: encrypted
+ });
+ if (!resp.ok) {
+ throw new Error(`Failed to send message: ${resp.status} ${resp.statusText}`);
+ }
+ const json = await resp.json();
+ return json.event_id;
+ }
+ }
+
+ public getJoinedMemberIDs(): string[] {
+ const members: string[] = [];
+ this.stateEvents.forEach((event) => {
+ if (event.type === "m.room.member" && event.content.membership === "join") {
+ members.push(event.state_key);
+ }
+ });
+ return members;
+ }
+
+ public getEncryptionSettings(): EncryptionSettings | undefined {
+ let settings: EncryptionSettings | undefined = undefined;
+ this.stateEvents.forEach((event) => {
+ if (event.type === "m.room.encryption" && event.state_key === "") {
+ if (!settings) {
+ settings = new EncryptionSettings();
+ }
+ settings.algorithm = event.content.algorithm === "m.megolm.v1.aes-sha2" ? EncryptionAlgorithm.MegolmV1AesSha2 : EncryptionAlgorithm.OlmV1Curve25519AesSha2;
+ if (event.content.rotation_period_ms) {
+ settings.rotationPeriod = BigInt(event.content.rotation_period_ms);
+ }
+ if (event.content.rotation_period_msgs) {
+ settings.rotationPeriodMessages = BigInt(event.content.rotation_period_msgs);
+ }
+ }
+ if (event.type === "m.room.history_visibility" && event.state_key === "") {
+ if (!settings) {
+ settings = new EncryptionSettings();
+ }
+ settings.historyVisibility = event.content.history_visibility;
+ }
+ });
+ if (settings) {
+ (settings as EncryptionSettings).onlyAllowTrustedDevices = false;
+ }
+ return settings;
+ }
+}
+
+export function useEvents(room?: Room) {
+ const [events, setEvents] = useState<IRoomEvent[]>(room?.getEvents() || []);
+
+ useEffect(() => {
+ if (room) {
+ setEvents(room?.getEvents() || []);
+ // Listen for event updates
+ const listenForEvents = (events: IRoomEvent[]) => {
+ setEvents([...events]);
+ };
+ room.on("events", listenForEvents);
+ return () => {
+ room.removeListener("events", listenForEvents);
+ }
+ } else {
+ setEvents([]);
+ }
+ }, [room])
+ return events;
+}
+
+export function useStateEvents(room?: Room) {
+ const [events, setEvents] = useState<IRoomStateEvent[]>(room?.getStateEvents() || []);
+
+ useEffect(() => {
+ if (room) {
+ setEvents(room?.getStateEvents() || []);
+ // Listen for event updates
+ const listenForStateEvents = (events: IRoomStateEvent[]) => {
+ setEvents(events);
+ };
+ room.on("state_events", listenForStateEvents);
+ return () => {
+ room.removeListener("state_events", listenForStateEvents);
+ }
+ } else {
+ setEvents([]);
+ }
+ }, [room])
+ return events;
}
\ No newline at end of file
diff --git a/src/components/input/chat/input.tsx b/src/components/input/chat/input.tsx
@@ -1,5 +1,5 @@
-import { EditorState, LexicalEditor } from 'lexical';
+import { $generateHtmlFromNodes, } from '@lexical/html';
import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
@@ -13,7 +13,8 @@ import { ListItemNode, ListNode } from "@lexical/list";
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
-import { TRANSFORMERS } from "@lexical/markdown";
+import { ClearEditorPlugin } from "@lexical/react/LexicalClearEditorPlugin";
+import { TRANSFORMERS, $convertToMarkdownString } from "@lexical/markdown";
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
import ToolbarPlugin from "./plugins/ToolbarPlugin";
@@ -21,7 +22,12 @@ import CodeHighlightPlugin from './plugins/CodeHighlightPlugin';
import EditorTheme from './theme';
import './input.scss';
-import { FC, memo } from 'react';
+import { FC, memo, useEffect, useState } from 'react';
+import { Send } from 'lucide-react';
+import { useRoom } from '../../../app/sdk/client';
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import { CLEAR_EDITOR_COMMAND } from 'lexical';
+import { useLocation } from 'react-router-dom';
type ChatInputProps = {
/**
@@ -29,24 +35,108 @@ type ChatInputProps = {
*/
namespace: string
/**
- * Handler for the onChange event
+ * The current Room
*/
- onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void;
- /**
- * Handler for the onError event
- */
- onError: (error: Error, editor: LexicalEditor) => void;
+ roomID?: string
};
function Placeholder() {
return <div className="editor-placeholder" id="editor-placeholder">Enter message...</div>;
}
-const ChatInput: FC<ChatInputProps> = memo(({ namespace, onChange, onError }: ChatInputProps) => {
+type SendButtonProps = {
+ /**
+ * The current Room
+ */
+ roomID?: string
+ /**
+ * The HTML message
+ */
+ htmlMessage: string
+ /**
+ * The plain text message
+ */
+ plainMessage: string
+};
+
+const SendButton: FC<SendButtonProps> = memo(({ roomID, htmlMessage, plainMessage }: SendButtonProps) => {
+ const room = useRoom(roomID || "");
+ const [editor] = useLexicalComposerContext();
+
+ return <Send size={45} stroke='unset' className='stroke-slate-600 rounded m-4 hover:bg-slate-300 hover:stroke-slate-500 p-2 cursor-pointer' onClick={async () => {
+ // TODO: Sanitize the html and send message to room
+ if (!room) {
+ return;
+ }
+ console.log("Sending message to: ", roomID)
+
+ // TODO: local echo
+ if (htmlMessage === "" && plainMessage === "") {
+ return;
+ }
+ if (htmlMessage !== '<p class="editor-paragraph"><br></p>') {
+ try {
+ await room.sendHtmlMessage(htmlMessage, plainMessage);
+ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
+ localStorage.removeItem(`editor-${roomID}`);
+ } catch (e: any) {
+ console.log(e);
+ }
+ } else {
+ try {
+ await room.sendTextMessage(plainMessage);
+ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
+ localStorage.removeItem(`editor-${roomID}`);
+ } catch (e: any) {
+ console.log(e);
+ }
+ }
+ }} />
+});
+
+
+type RoomChangeProps = {
+ /**
+ * The current Room
+ */
+ roomID?: string
+};
+
+const RoomChangePlugin: FC<RoomChangeProps> = ({ roomID }) => {
+ const [editor] = useLexicalComposerContext();
+ const { pathname } = useLocation();
+ const [prevRoom, setPrevRoom] = useState<string | undefined>(undefined);
+
+ useEffect(() => {
+ if (roomID) {
+ if (roomID !== prevRoom) {
+ console.log("Saving editor state")
+ // Save the editor state to local storage
+ const editorState = editor.getEditorState();
+ localStorage.setItem(`editor-${prevRoom}`, JSON.stringify(editorState.toJSON()));
+ }
+ setPrevRoom(roomID);
+ const savedHtml = localStorage.getItem(`editor-${roomID}`);
+ if (savedHtml) {
+ const initialEditorState = editor.parseEditorState(savedHtml)
+ editor.setEditorState(initialEditorState)
+ } else {
+ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
+ }
+ }
+ }, [pathname, roomID]);
+
+ return <></>
+}
+
+const ChatInput: FC<ChatInputProps> = memo(({ namespace, roomID }: ChatInputProps) => {
+ const [htmlMessage, setHtmlMessage] = useState<string>("");
+ const [plainMessage, setPlainMessage] = useState<string>("");
+
const initialConfig: InitialConfigType = {
namespace: namespace,
theme: EditorTheme,
- onError,
+ onError: (e) => console.error(e),
nodes: [
HeadingNode,
ListNode,
@@ -62,24 +152,41 @@ const ChatInput: FC<ChatInputProps> = memo(({ namespace, onChange, onError }: Ch
]
}
return (
- <LexicalComposer initialConfig={initialConfig}>
- <div className="editor-container flex-1">
- <ToolbarPlugin />
- <div className="editor-inner">
- <RichTextPlugin
- contentEditable={<ContentEditable className="editor-input" ariaLabelledBy='editor-placeholder' />}
- placeholder={<Placeholder />}
- ErrorBoundary={LexicalErrorBoundary}
- />
- <OnChangePlugin onChange={onChange} />
- <HistoryPlugin />
- <LinkPlugin />
- <CodeHighlightPlugin />
- <AutoLinkPlugin />
- <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
+ <div className='flex flex-row items-end'>
+ <LexicalComposer initialConfig={initialConfig}>
+ <div className="editor-container flex-1">
+ <ToolbarPlugin />
+ <div className="editor-inner">
+ <RichTextPlugin
+ contentEditable={<ContentEditable className="editor-input" ariaLabelledBy='editor-placeholder' />}
+ placeholder={<Placeholder />}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ <OnChangePlugin onChange={(editorState, editor) => {
+ // Convert editor state to both html and markdown.
+ // If there is no formatting then just use the plain text.
+ editorState.read(() => {
+ const html = $generateHtmlFromNodes(editor);
+ // TODO: Make sure that we strip any non matrix stuff
+ setHtmlMessage(html);
+ const markdown = $convertToMarkdownString(TRANSFORMERS);
+ setPlainMessage(markdown);
+ });
+ // TODO: we need some send button
+ }} />
+ <HistoryPlugin />
+ <LinkPlugin />
+ <CodeHighlightPlugin />
+ <AutoLinkPlugin />
+ <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
+ <ClearEditorPlugin />
+ <RoomChangePlugin roomID={roomID} />
+ </div>
</div>
- </div>
- </LexicalComposer>
+ <SendButton roomID={roomID} htmlMessage={htmlMessage} plainMessage={plainMessage} />
+ </LexicalComposer>
+
+ </div>
);
})
diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx
@@ -1,15 +1,13 @@
-import { Send, Settings } from 'lucide-react';
+import { Settings } from 'lucide-react';
import Avatar from '../components/avatar/avatar';
import ChatInput from '../components/input/chat/input';
import RoomList, { Section } from '../components/roomList/roomList';
import './MainPage.scss';
import { useProfile, useRoom, useRooms, useSpaces } from '../app/sdk/client';
-import { Room } from '../app/sdk/room';
+import { Room, useEvents } from '../app/sdk/room';
import { FC, memo, useContext, useEffect, useRef, useState } from 'react';
import { MatrixContext } from '../app/sdk/client';
import { useLocation, useParams } from 'react-router-dom';
-import { $generateHtmlFromNodes } from '@lexical/html';
-import { $convertToMarkdownString, TRANSFORMERS } from '@lexical/markdown';
import MessageEvent from '../components/events/messageEvent';
import UnknownEvent from '../components/events/unknownEvent';
import MemberEvent from '../components/events/memberEvent';
@@ -36,7 +34,7 @@ type ChatViewProps = {
const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => {
const room = useRoom(decodeURIComponent(roomID || ""));
const client = useContext(MatrixContext);
- const events = room?.getEvents();
+ const events = useEvents(room);
const { pathname } = useLocation();
const [renderedEvents, setRenderedEvents] = useState<JSX.Element[]>([]);
@@ -84,7 +82,6 @@ const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => {
}
}, [events]);
-
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current?.scrollHeight);
}, [pathname]);
@@ -114,9 +111,8 @@ const MainPage = memo(() => {
const client = useContext(MatrixContext);
let params = useParams();
const room = useRoom(decodeURIComponent(params.roomIdOrAlias || ""));
+ client.setCurrentRoom(params.roomIdOrAlias ? decodeURIComponent(params.roomIdOrAlias) : undefined)
- const [_htmlMessage, setHtmlMessage] = useState<string>("");
- const [_plainMessage, setPlainMessage] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
// Filter toplevel spaces.
@@ -240,34 +236,15 @@ const MainPage = memo(() => {
room && <div className='flex-1 flex flex-col'>
<div className='pb-2 flex flex-row items-center border-b-2 mt-4 ml-2'>
<Avatar displayname={room.getName()} avatarUrl={room.getAvatarURL()} dm={room.isDM()} online={room.isOnline()} />
- <div className='flex flex-row items-center'>
+ <div className='flex flex-row items-start'>
<h1 className='text-black font-semibold text-lg flex-shrink-0'>{room.getName()}</h1>
- <Linkify options={linkifyOptions} as='p' className="ml-4 text-slate-700 font-normal text-base">{room.getTopic()}</Linkify>
+ <Linkify options={linkifyOptions} as='p' className="ml-4 text-slate-700 font-normal text-base line-clamp-2 text-ellipsis">{room.getTopic()}</Linkify>
</div>
</div>
<div ref={scrollRef} className='overflow-y-auto overflow-x-hidden scrollbarSmall mr-2 my-1 flex-1 w-full flex flex-col-reverse'>
<ChatView roomID={params.roomIdOrAlias} scrollRef={scrollRef} />
</div>
- <div className='flex flex-row items-end'>
- <ChatInput namespace='Editor' onChange={(editorState, editor) => {
- // Convert editor state to both html and markdown.
- // If there is no formatting then just use the plain text.
- editorState.read(() => {
- const html = $generateHtmlFromNodes(editor);
- // TODO: Make sure that we strip any non matrix stuff
- setHtmlMessage(html);
- console.log(html);
- const markdown = $convertToMarkdownString(TRANSFORMERS);
- setPlainMessage(markdown);
- console.log(markdown);
- });
- // TODO: we need some send button
- }} onError={(e) => console.error(e)} />
- <Send size={45} stroke='unset' className='stroke-slate-600 rounded m-4 hover:bg-slate-300 hover:stroke-slate-500 p-2 cursor-pointer' onClick={() => {
- // TODO: Sanitize the html and send message to room
- // TODO: encrypt if room is encrypted
- }} />
- </div>
+ <ChatInput namespace='Editor' roomID={decodeURIComponent(params.roomIdOrAlias || "")} />
</div>
}
</div >