cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit 921aee17054ececb00f604c324b72c6423ab084d
parent 882942f9737bf5f96965f3160bd5f2ad4e4cdcbd
Author: MTRNord <mtrnord1@gmail.com>
Date:   Fri,  5 May 2023 21:38:36 +0200

Implement sending encrypted messages

Diffstat:
Msrc/app/sdk/api/apiTypes.ts | 31+++++++++++++++++++++++++++++++
Msrc/app/sdk/client.ts | 509++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/app/sdk/room.ts | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/components/input/chat/input.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/pages/MainPage.tsx | 37+++++++------------------------------
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 >