From 3212410d6040a03de54943b15b9c9588ef485c7f Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 23 Jun 2022 18:09:21 -0400 Subject: [PATCH 01/10] Implement MSC3819: Allowing widgets to send/receive to-device messages --- src/stores/widgets/StopGapWidget.ts | 26 +++++++++++++++------ src/stores/widgets/StopGapWidgetDriver.ts | 28 ++++++++++++++++------- src/widgets/CapabilityText.tsx | 9 +++++--- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 794fa17a25c..eaf45b60c80 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -260,8 +260,11 @@ export class StopGapWidget extends EventEmitter { */ public startMessaging(iframe: HTMLIFrameElement): any { if (this.started) return; + + const client = MatrixClientPeg.get(); const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId); + this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); @@ -302,7 +305,7 @@ export class StopGapWidget extends EventEmitter { // Populate the map of "read up to" events for this widget with the current event in every room. // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget // requests timeline capabilities in other rooms down the road. It's just easier to manage here. - for (const room of MatrixClientPeg.get().getRooms()) { + for (const room of client.getRooms()) { // Timelines are most recent last const events = room.getLiveTimeline()?.getEvents() || []; const roomEvent = events[events.length - 1]; @@ -311,8 +314,9 @@ export class StopGapWidget extends EventEmitter { } // Attach listeners for feeding events - the underlying widget classes handle permissions for us - MatrixClientPeg.get().on(ClientEvent.Event, this.onEvent); - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + client.on(ClientEvent.Event, this.onEvent); + client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, (ev: CustomEvent) => { @@ -363,7 +367,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()), + client.getRoom(RoomViewStore.instance.getRoomId()), `type_${integType}`, integId, ); @@ -426,9 +430,11 @@ export class StopGapWidget extends EventEmitter { WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); this.messaging = null; - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().off(ClientEvent.Event, this.onEvent); - MatrixClientPeg.get().off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + const client = MatrixClientPeg.get(); + if (client) { + client.off(ClientEvent.Event, this.onEvent); + client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } } @@ -443,6 +449,12 @@ export class StopGapWidget extends EventEmitter { this.feedEvent(ev); }; + private onToDeviceEvent = async (ev: MatrixEvent) => { + await MatrixClientPeg.get().decryptEventIfNeeded(ev); + if (ev.isDecryptionFailure()) return; + this.messaging.feedToDevice(ev.getEffectiveEvent()); + }; + private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 3fcc10283eb..f1c4604bc30 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -20,6 +20,7 @@ import { IOpenIDCredentials, IOpenIDUpdate, ISendEventDetails, + IRoomEvent, MatrixCapabilities, OpenIDRequestState, SimpleObservable, @@ -182,6 +183,13 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } + public async sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: unknown } }, + ): Promise { + await MatrixClientPeg.get().sendToDevice(eventType, contentMap); + } + private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] { const client = MatrixClientPeg.get(); if (!client) throw new Error("Not attached to a client"); @@ -195,10 +203,12 @@ export class StopGapWidgetDriver extends WidgetDriver { public async readRoomEvents( eventType: string, msgtype: string | undefined, - limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + limit?: number, + ): Promise { + limit = limit === undefined + ? Number.MAX_SAFE_INTEGER + : Math.min(limit, Number.MAX_SAFE_INTEGER); // relatively arbitrary const rooms = this.pickRooms(roomIds); const allResults: IEvent[] = []; @@ -206,7 +216,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); // timelines are most recent last for (let i = events.length - 1; i > 0; i--) { - if (results.length >= limitPerRoom) break; + if (results.length >= limit) break; const ev = events[i]; if (ev.getType() !== eventType || ev.isState()) continue; @@ -222,10 +232,12 @@ export class StopGapWidgetDriver extends WidgetDriver { public async readStateEvents( eventType: string, stateKey: string | undefined, - limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + limit?: number, + ): Promise { + limit = limit === undefined + ? Number.MAX_SAFE_INTEGER + : Math.min(limit, Number.MAX_SAFE_INTEGER); // relatively arbitrary const rooms = this.pickRooms(roomIds); const allResults: IEvent[] = []; @@ -241,7 +253,7 @@ export class StopGapWidgetDriver extends WidgetDriver { } } - results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent())); + results.slice(0, limit).forEach(e => allResults.push(e.getEffectiveEvent())); } return allResults; } diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index cd442f213b8..e4790eaad2a 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -17,6 +17,7 @@ limitations under the License. import { Capability, EventDirection, + EventKind, getTimelineRoomIDFromCapability, isTimelineCapability, isTimelineCapabilityFor, @@ -134,7 +135,7 @@ export class CapabilityText { }; private static bylineFor(eventCap: WidgetEventCapability): TranslatedString { - if (eventCap.isState) { + if (eventCap.kind === EventKind.State) { return !eventCap.keyStr ? _t("with an empty state key") : _t("with state key %(stateKey)s", { stateKey: eventCap.keyStr }); @@ -143,6 +144,8 @@ export class CapabilityText { } public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText { + // TODO: Support MSC3819 (to-device capabilities) + // First see if we have a super simple line of text to provide back if (CapabilityText.simpleCaps[capability]) { const textForKind = CapabilityText.simpleCaps[capability]; @@ -184,13 +187,13 @@ export class CapabilityText { // Special case room messages so they show up a bit cleaner to the user. Result is // effectively "Send images" instead of "Send messages... of type images" if we were // to handle the msgtype nuances in this function. - if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) { + if (eventCap.kind === EventKind.Event && eventCap.eventType === EventType.RoomMessage) { return CapabilityText.forRoomMessageCap(eventCap, kind); } // See if we have a static line of text to provide for the given event type and // direction. The hope is that we do for common event types for friendlier copy. - const evSendRecv = eventCap.isState + const evSendRecv = eventCap.kind === EventKind.State ? CapabilityText.stateSendRecvCaps : CapabilityText.nonStateSendRecvCaps; if (evSendRecv[eventCap.eventType]) { From 1f2d0c51c25acac956f282ecce496613d5419aac Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 13 Jul 2022 17:48:03 -0400 Subject: [PATCH 02/10] Don't change the room events and state events drivers --- src/stores/widgets/StopGapWidgetDriver.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index f1827318ca9..615a7db814f 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -203,12 +203,10 @@ export class StopGapWidgetDriver extends WidgetDriver { public async readRoomEvents( eventType: string, msgtype: string | undefined, + limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - limit?: number, - ): Promise { - limit = limit === undefined - ? Number.MAX_SAFE_INTEGER - : Math.min(limit, Number.MAX_SAFE_INTEGER); // relatively arbitrary + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); const allResults: IEvent[] = []; @@ -216,7 +214,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); // timelines are most recent last for (let i = events.length - 1; i > 0; i--) { - if (results.length >= limit) break; + if (results.length >= limitPerRoom) break; const ev = events[i]; if (ev.getType() !== eventType || ev.isState()) continue; @@ -232,12 +230,10 @@ export class StopGapWidgetDriver extends WidgetDriver { public async readStateEvents( eventType: string, stateKey: string | undefined, + limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - limit?: number, - ): Promise { - limit = limit === undefined - ? Number.MAX_SAFE_INTEGER - : Math.min(limit, Number.MAX_SAFE_INTEGER); // relatively arbitrary + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); const allResults: IEvent[] = []; @@ -253,7 +249,7 @@ export class StopGapWidgetDriver extends WidgetDriver { } } - results.slice(0, limit).forEach(e => allResults.push(e.getEffectiveEvent())); + results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent())); } return allResults; } From c753a2f81eec85e49f91a52cef5017372030cc80 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 15 Jul 2022 18:19:32 -0400 Subject: [PATCH 03/10] Update to latest matrix-widget-api changes --- src/stores/widgets/StopGapWidget.ts | 2 +- src/stores/widgets/StopGapWidgetDriver.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index d1ce6ac6209..3c62f635d4c 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -454,7 +454,7 @@ export class StopGapWidget extends EventEmitter { private onToDeviceEvent = async (ev: MatrixEvent) => { await MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; - this.messaging.feedToDevice(ev.getEffectiveEvent()); + this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); }; private feedEvent(ev: MatrixEvent) { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 615a7db814f..29aa14f9a6f 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -185,9 +185,14 @@ export class StopGapWidgetDriver extends WidgetDriver { public async sendToDevice( eventType: string, + encrypt: boolean, contentMap: { [userId: string]: { [deviceId: string]: unknown } }, ): Promise { - await MatrixClientPeg.get().sendToDevice(eventType, contentMap); + if (encrypt) { + throw new Error("Encrypted to-device events not supported yet"); + } else { + await MatrixClientPeg.get().sendToDevice(eventType, contentMap); + } } private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] { @@ -205,7 +210,7 @@ export class StopGapWidgetDriver extends WidgetDriver { msgtype: string | undefined, limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { + ): Promise { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); @@ -232,7 +237,7 @@ export class StopGapWidgetDriver extends WidgetDriver { stateKey: string | undefined, limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { + ): Promise { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); From 4c2a0d0756d3402bbf3b66eecac870c97a1f42cf Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 28 Jul 2022 15:27:33 -0400 Subject: [PATCH 04/10] Support sending encrypted to-device messages --- src/stores/widgets/StopGapWidgetDriver.ts | 32 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 29aa14f9a6f..77c3b833184 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -185,13 +185,37 @@ export class StopGapWidgetDriver extends WidgetDriver { public async sendToDevice( eventType: string, - encrypt: boolean, + encrypted: boolean, contentMap: { [userId: string]: { [deviceId: string]: unknown } }, ): Promise { - if (encrypt) { - throw new Error("Encrypted to-device events not supported yet"); + const client = MatrixClientPeg.get(); + + if (encrypted) { + const deviceInfoMap = await client.crypto.deviceList.downloadKeys(Object.keys(contentMap), false); + + await Promise.all( + Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(async ([deviceId, content]) => { + if (deviceId === "*") { + // Send the message to all devices we have keys for + await client.encryptAndSendToDevices( + Object.values(deviceInfoMap[userId]).map(deviceInfo => ({ + userId, deviceInfo, + })), + content, + ); + } else { + // Send the message to a specific device + await client.encryptAndSendToDevices( + [{ userId, deviceInfo: deviceInfoMap[userId][deviceId] }], + content, + ); + } + }), + ), + ); } else { - await MatrixClientPeg.get().sendToDevice(eventType, contentMap); + await client.sendToDevice(eventType, contentMap); } } From e0555128d559cee9e051ead7bc221a95c3351d5d Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 3 Aug 2022 18:36:00 -0400 Subject: [PATCH 05/10] Use queueToDevice for better reliability --- src/stores/widgets/StopGapWidgetDriver.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 77c3b833184..8e32811365e 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -215,7 +215,14 @@ export class StopGapWidgetDriver extends WidgetDriver { ), ); } else { - await client.sendToDevice(eventType, contentMap); + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => + ({ userId, deviceId, payload: content }), + ), + ), + }); } } From ceb7fc0b80c25a841a66b475b0278da196987178 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 3 Aug 2022 18:36:50 -0400 Subject: [PATCH 06/10] Update types for latest WidgetDriver changes --- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 8e32811365e..ee69f0ca9c2 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -186,7 +186,7 @@ export class StopGapWidgetDriver extends WidgetDriver { public async sendToDevice( eventType: string, encrypted: boolean, - contentMap: { [userId: string]: { [deviceId: string]: unknown } }, + contentMap: { [userId: string]: { [deviceId: string]: object } }, ): Promise { const client = MatrixClientPeg.get(); From 15789d80484faddd6bca2ec73276be4719373c6f Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 4 Aug 2022 15:22:40 -0400 Subject: [PATCH 07/10] Upgrade matrix-widget-api --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1973a2f5b58..ff812b15a48 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.18", + "matrix-widget-api": "^1.0.0", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", diff --git a/yarn.lock b/yarn.lock index de8172c7287..a66f1cc7e8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6808,10 +6808,10 @@ matrix-web-i18n@^1.3.0: "@babel/traverse" "^7.18.5" walk "^2.3.15" -matrix-widget-api@^0.1.0-beta.18: - version "0.1.0-beta.18" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.18.tgz#4efd30edec3eeb4211285985464c062fcab59795" - integrity sha512-kCpcs6rrB94Mmr2/1gBJ+6auWyZ5UvOMOn5K2VFafz2/NDMzZg9OVWj9KFYnNAuwwBE5/tCztYEj6OQ+hgbwOQ== +matrix-widget-api@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.0.0.tgz#0cde6839cca66ad817ab12aca3490ccc8bac97d1" + integrity sha512-cy8p/8EteRPTFIAw7Q9EgPUJc2jD19ZahMR8bMKf2NkILDcjuPMC0UWnsJyB3fSnlGw+VbGepttRpULM31zX8Q== dependencies: "@types/events" "^3.0.0" events "^3.2.0" From 95c426aa18fcb90db887459934dc36005aeb34b3 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 4 Aug 2022 18:01:16 -0400 Subject: [PATCH 08/10] Add tests --- .../widgets/StopGapWidgetDriver-test.ts | 79 ++++++++++++++++++ .../StopGapWidgetDriver-test.ts.snap | 82 +++++++++++++++++++ test/test-utils/test-utils.ts | 9 ++ 3 files changed, 170 insertions(+) create mode 100644 test/stores/widgets/StopGapWidgetDriver-test.ts create mode 100644 test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts new file mode 100644 index 00000000000..7dab35052b4 --- /dev/null +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -0,0 +1,79 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked, MockedObject } from "jest-mock"; +import { Widget, WidgetKind, WidgetDriver } from "matrix-widget-api"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; + +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { StopGapWidgetDriver } from "../../../src/stores/widgets/StopGapWidgetDriver"; +import { stubClient } from "../../test-utils"; + +describe("StopGapWidgetDriver", () => { + let client: MockedObject; + let driver: WidgetDriver; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.get()); + + driver = new StopGapWidgetDriver( + [], + new Widget({ + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org", + }), + WidgetKind.Room, + ); + }); + + describe("sendToDevice", () => { + const contentMap = { + "@alice:example.org": { + "*": { + hello: "alice", + }, + }, + "@bob:example.org": { + "bobDesktop": { + hello: "bob", + }, + }, + }; + + it("sends unencrypted messages", async () => { + await driver.sendToDevice("org.example.foo", false, contentMap); + expect(client.queueToDevice.mock.calls).toMatchSnapshot(); + }); + + it("sends encrypted messages", async () => { + const aliceWeb = new DeviceInfo("aliceWeb"); + const aliceMobile = new DeviceInfo("aliceMobile"); + const bobDesktop = new DeviceInfo("bobDesktop"); + + mocked(client.crypto.deviceList).downloadKeys.mockResolvedValue({ + "@alice:example.org": { aliceWeb, aliceMobile }, + "@bob:example.org": { bobDesktop }, + }); + + await driver.sendToDevice("org.example.foo", true, contentMap); + expect(client.encryptAndSendToDevices.mock.calls).toMatchSnapshot(); + }); + }); +}); diff --git a/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap b/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap new file mode 100644 index 00000000000..5f19dbb793d --- /dev/null +++ b/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StopGapWidgetDriver sendToDevice sends encrypted messages 1`] = ` +Array [ + Array [ + Array [ + Object { + "deviceInfo": DeviceInfo { + "algorithms": undefined, + "deviceId": "aliceWeb", + "keys": Object {}, + "known": false, + "signatures": Object {}, + "unsigned": Object {}, + "verified": 0, + }, + "userId": "@alice:example.org", + }, + Object { + "deviceInfo": DeviceInfo { + "algorithms": undefined, + "deviceId": "aliceMobile", + "keys": Object {}, + "known": false, + "signatures": Object {}, + "unsigned": Object {}, + "verified": 0, + }, + "userId": "@alice:example.org", + }, + ], + Object { + "hello": "alice", + }, + ], + Array [ + Array [ + Object { + "deviceInfo": DeviceInfo { + "algorithms": undefined, + "deviceId": "bobDesktop", + "keys": Object {}, + "known": false, + "signatures": Object {}, + "unsigned": Object {}, + "verified": 0, + }, + "userId": "@bob:example.org", + }, + ], + Object { + "hello": "bob", + }, + ], +] +`; + +exports[`StopGapWidgetDriver sendToDevice sends unencrypted messages 1`] = ` +Array [ + Array [ + Object { + "batch": Array [ + Object { + "deviceId": "*", + "payload": Object { + "hello": "alice", + }, + "userId": "@alice:example.org", + }, + Object { + "deviceId": "bobDesktop", + "payload": Object { + "hello": "bob", + }, + "userId": "@bob:example.org", + }, + ], + "eventType": "org.example.foo", + }, + ], +] +`; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ef2b163d425..905684b9ea1 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -91,6 +91,12 @@ export function createTestClient(): MatrixClient { removeRoom: jest.fn(), }, + crypto: { + deviceList: { + downloadKeys: jest.fn(), + }, + }, + getPushActionsForEvent: jest.fn(), getRoom: jest.fn().mockImplementation(mkStubRoom), getRooms: jest.fn().mockReturnValue([]), @@ -162,6 +168,9 @@ export function createTestClient(): MatrixClient { downloadKeys: jest.fn(), fetchRoomEvent: jest.fn(), makeTxnId: jest.fn().mockImplementation(() => `t${txnId++}`), + sendToDevice: jest.fn().mockResolvedValue(undefined), + queueToDevice: jest.fn().mockResolvedValue(undefined), + encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined), } as unknown as MatrixClient; } From e9b5528d169774beceede049d1e7a9394cc3e898 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 5 Aug 2022 11:53:08 -0400 Subject: [PATCH 09/10] Test StopGapWidget --- src/stores/widgets/StopGapWidget.ts | 2 +- test/stores/widgets/StopGapWidget-test.ts | 70 +++++++++++++++++++++++ test/test-utils/test-utils.ts | 2 +- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 test/stores/widgets/StopGapWidget-test.ts diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 3c62f635d4c..0309afb2a00 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -454,7 +454,7 @@ export class StopGapWidget extends EventEmitter { private onToDeviceEvent = async (ev: MatrixEvent) => { await MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; - this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); + await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); }; private feedEvent(ev: MatrixEvent) { diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts new file mode 100644 index 00000000000..40292e451be --- /dev/null +++ b/test/stores/widgets/StopGapWidget-test.ts @@ -0,0 +1,70 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; +import { ClientWidgetApi } from "matrix-widget-api"; + +import { stubClient, mkRoom, mkEvent } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget"; + +jest.mock("matrix-widget-api/lib/ClientWidgetApi"); + +describe("StopGapWidget", () => { + let client: MockedObject; + let widget: StopGapWidget; + let messaging: MockedObject; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.get()); + + widget = new StopGapWidget({ + app: { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org", + }, + room: mkRoom(client, "!1:example.org"), + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.startMessaging(null as unknown as HTMLIFrameElement); + messaging = mocked(mocked(ClientWidgetApi).mock.instances[0]); + }); + + afterEach(() => { + widget.stopMessaging(); + }); + + it("feeds incoming to-device messages to the widget", async () => { + const event = mkEvent({ + event: true, + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + }); + + client.emit(ClientEvent.ToDeviceEvent, event); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false); + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 905684b9ea1..1c9dd0211ba 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -184,7 +184,7 @@ type MakeEventPassThruProps = { type MakeEventProps = MakeEventPassThruProps & { type: string; content: IContent; - room: Room["roomId"]; + room?: Room["roomId"]; // to-device messages are roomless // eslint-disable-next-line camelcase prev_content?: IContent; unsigned?: IUnsigned; From 24823ba7dd38265d706a1e60ff45e1b20eb64dc3 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 5 Aug 2022 12:01:27 -0400 Subject: [PATCH 10/10] Fix a potential memory leak --- src/stores/widgets/StopGapWidget.ts | 35 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 0309afb2a00..889a050ebfd 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -33,6 +33,7 @@ import { WidgetKind, } from "matrix-widget-api"; import { EventEmitter } from "events"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -148,6 +149,7 @@ export class ElementWidget extends Widget { } export class StopGapWidget extends EventEmitter { + private client: MatrixClient; private messaging: ClientWidgetApi; private mockWidget: ElementWidget; private scalarToken: string; @@ -157,12 +159,13 @@ export class StopGapWidget extends EventEmitter { constructor(private appTileProps: IAppTileProps) { super(); - let app = appTileProps.app; + this.client = MatrixClientPeg.get(); + let app = appTileProps.app; // Backwards compatibility: not all old widgets have a creatorUserId if (!app.creatorUserId) { app = objectShallowClone(app); // clone to prevent accidental mutation - app.creatorUserId = MatrixClientPeg.get().getUserId(); + app.creatorUserId = this.client.getUserId(); } this.mockWidget = new ElementWidget(app); @@ -203,7 +206,7 @@ export class StopGapWidget extends EventEmitter { const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {}; const defaults: ITemplateParams = { widgetRoomId: this.roomId, - currentUserId: MatrixClientPeg.get().getUserId(), + currentUserId: this.client.getUserId(), userDisplayName: OwnProfileStore.instance.displayName, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), clientId: ELEMENT_CLIENT_ID, @@ -261,7 +264,6 @@ export class StopGapWidget extends EventEmitter { public startMessaging(iframe: HTMLIFrameElement): any { if (this.started) return; - const client = MatrixClientPeg.get(); const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId); @@ -305,7 +307,7 @@ export class StopGapWidget extends EventEmitter { // Populate the map of "read up to" events for this widget with the current event in every room. // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget // requests timeline capabilities in other rooms down the road. It's just easier to manage here. - for (const room of client.getRooms()) { + for (const room of this.client.getRooms()) { // Timelines are most recent last const events = room.getLiveTimeline()?.getEvents() || []; const roomEvent = events[events.length - 1]; @@ -314,9 +316,9 @@ export class StopGapWidget extends EventEmitter { } // Attach listeners for feeding events - the underlying widget classes handle permissions for us - client.on(ClientEvent.Event, this.onEvent); - client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); - client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + this.client.on(ClientEvent.Event, this.onEvent); + this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, (ev: CustomEvent) => { @@ -367,7 +369,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - client.getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(RoomViewStore.instance.getRoomId()), `type_${integType}`, integId, ); @@ -432,16 +434,13 @@ export class StopGapWidget extends EventEmitter { WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); this.messaging = null; - const client = MatrixClientPeg.get(); - if (client) { - client.off(ClientEvent.Event, this.onEvent); - client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); - client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); - } + this.client.off(ClientEvent.Event, this.onEvent); + this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } private onEvent = (ev: MatrixEvent) => { - MatrixClientPeg.get().decryptEventIfNeeded(ev); + this.client.decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; this.feedEvent(ev); }; @@ -452,7 +451,7 @@ export class StopGapWidget extends EventEmitter { }; private onToDeviceEvent = async (ev: MatrixEvent) => { - await MatrixClientPeg.get().decryptEventIfNeeded(ev); + await this.client.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); }; @@ -477,7 +476,7 @@ export class StopGapWidget extends EventEmitter { // Timelines are most recent last, so reverse the order and limit ourselves to 100 events // to avoid overusing the CPU. - const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline(); + const timeline = this.client.getRoom(ev.getRoomId()).getLiveTimeline(); const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); for (const timelineEvent of events) {