Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 103b60d

Browse files
authored
Implement MSC3819: Allowing widgets to send/receive to-device messages (#8885)
* Implement MSC3819: Allowing widgets to send/receive to-device messages * Don't change the room events and state events drivers * Update to latest matrix-widget-api changes * Support sending encrypted to-device messages * Use queueToDevice for better reliability * Update types for latest WidgetDriver changes * Upgrade matrix-widget-api * Add tests * Test StopGapWidget * Fix a potential memory leak
1 parent 3d0982e commit 103b60d

File tree

9 files changed

+322
-24
lines changed

9 files changed

+322
-24
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"matrix-encrypt-attachment": "^1.0.3",
9595
"matrix-events-sdk": "^0.0.1-beta.7",
9696
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
97-
"matrix-widget-api": "^0.1.0-beta.18",
97+
"matrix-widget-api": "^1.0.0",
9898
"minimist": "^1.2.5",
9999
"opus-recorder": "^8.0.3",
100100
"pako": "^2.0.3",

src/stores/widgets/StopGapWidget.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
WidgetKind,
3434
} from "matrix-widget-api";
3535
import { EventEmitter } from "events";
36+
import { MatrixClient } from "matrix-js-sdk/src/client";
3637
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
3738
import { logger } from "matrix-js-sdk/src/logger";
3839
import { ClientEvent } from "matrix-js-sdk/src/client";
@@ -148,6 +149,7 @@ export class ElementWidget extends Widget {
148149
}
149150

150151
export class StopGapWidget extends EventEmitter {
152+
private client: MatrixClient;
151153
private messaging: ClientWidgetApi;
152154
private mockWidget: ElementWidget;
153155
private scalarToken: string;
@@ -157,12 +159,13 @@ export class StopGapWidget extends EventEmitter {
157159

158160
constructor(private appTileProps: IAppTileProps) {
159161
super();
160-
let app = appTileProps.app;
162+
this.client = MatrixClientPeg.get();
161163

164+
let app = appTileProps.app;
162165
// Backwards compatibility: not all old widgets have a creatorUserId
163166
if (!app.creatorUserId) {
164167
app = objectShallowClone(app); // clone to prevent accidental mutation
165-
app.creatorUserId = MatrixClientPeg.get().getUserId();
168+
app.creatorUserId = this.client.getUserId();
166169
}
167170

168171
this.mockWidget = new ElementWidget(app);
@@ -203,7 +206,7 @@ export class StopGapWidget extends EventEmitter {
203206
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
204207
const defaults: ITemplateParams = {
205208
widgetRoomId: this.roomId,
206-
currentUserId: MatrixClientPeg.get().getUserId(),
209+
currentUserId: this.client.getUserId(),
207210
userDisplayName: OwnProfileStore.instance.displayName,
208211
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
209212
clientId: ELEMENT_CLIENT_ID,
@@ -260,8 +263,10 @@ export class StopGapWidget extends EventEmitter {
260263
*/
261264
public startMessaging(iframe: HTMLIFrameElement): any {
262265
if (this.started) return;
266+
263267
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
264268
const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
269+
265270
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
266271
this.messaging.on("preparing", () => this.emit("preparing"));
267272
this.messaging.on("ready", () => this.emit("ready"));
@@ -302,7 +307,7 @@ export class StopGapWidget extends EventEmitter {
302307
// Populate the map of "read up to" events for this widget with the current event in every room.
303308
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
304309
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
305-
for (const room of MatrixClientPeg.get().getRooms()) {
310+
for (const room of this.client.getRooms()) {
306311
// Timelines are most recent last
307312
const events = room.getLiveTimeline()?.getEvents() || [];
308313
const roomEvent = events[events.length - 1];
@@ -311,8 +316,9 @@ export class StopGapWidget extends EventEmitter {
311316
}
312317

313318
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
314-
MatrixClientPeg.get().on(ClientEvent.Event, this.onEvent);
315-
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
319+
this.client.on(ClientEvent.Event, this.onEvent);
320+
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
321+
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
316322

317323
this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
318324
(ev: CustomEvent<IStickyActionRequest>) => {
@@ -363,7 +369,7 @@ export class StopGapWidget extends EventEmitter {
363369

364370
// noinspection JSIgnoredPromiseFromCall
365371
IntegrationManagers.sharedInstance().getPrimaryManager().open(
366-
MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()),
372+
this.client.getRoom(RoomViewStore.instance.getRoomId()),
367373
`type_${integType}`,
368374
integId,
369375
);
@@ -428,14 +434,13 @@ export class StopGapWidget extends EventEmitter {
428434
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
429435
this.messaging = null;
430436

431-
if (MatrixClientPeg.get()) {
432-
MatrixClientPeg.get().off(ClientEvent.Event, this.onEvent);
433-
MatrixClientPeg.get().off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
434-
}
437+
this.client.off(ClientEvent.Event, this.onEvent);
438+
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
439+
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
435440
}
436441

437442
private onEvent = (ev: MatrixEvent) => {
438-
MatrixClientPeg.get().decryptEventIfNeeded(ev);
443+
this.client.decryptEventIfNeeded(ev);
439444
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
440445
this.feedEvent(ev);
441446
};
@@ -445,6 +450,12 @@ export class StopGapWidget extends EventEmitter {
445450
this.feedEvent(ev);
446451
};
447452

453+
private onToDeviceEvent = async (ev: MatrixEvent) => {
454+
await this.client.decryptEventIfNeeded(ev);
455+
if (ev.isDecryptionFailure()) return;
456+
await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted());
457+
};
458+
448459
private feedEvent(ev: MatrixEvent) {
449460
if (!this.messaging) return;
450461

@@ -465,7 +476,7 @@ export class StopGapWidget extends EventEmitter {
465476

466477
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
467478
// to avoid overusing the CPU.
468-
const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
479+
const timeline = this.client.getRoom(ev.getRoomId()).getLiveTimeline();
469480
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
470481

471482
for (const timelineEvent of events) {

src/stores/widgets/StopGapWidgetDriver.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
IOpenIDCredentials,
2121
IOpenIDUpdate,
2222
ISendEventDetails,
23+
IRoomEvent,
2324
MatrixCapabilities,
2425
OpenIDRequestState,
2526
SimpleObservable,
@@ -182,6 +183,49 @@ export class StopGapWidgetDriver extends WidgetDriver {
182183
return { roomId, eventId: r.event_id };
183184
}
184185

186+
public async sendToDevice(
187+
eventType: string,
188+
encrypted: boolean,
189+
contentMap: { [userId: string]: { [deviceId: string]: object } },
190+
): Promise<void> {
191+
const client = MatrixClientPeg.get();
192+
193+
if (encrypted) {
194+
const deviceInfoMap = await client.crypto.deviceList.downloadKeys(Object.keys(contentMap), false);
195+
196+
await Promise.all(
197+
Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
198+
Object.entries(userContentMap).map(async ([deviceId, content]) => {
199+
if (deviceId === "*") {
200+
// Send the message to all devices we have keys for
201+
await client.encryptAndSendToDevices(
202+
Object.values(deviceInfoMap[userId]).map(deviceInfo => ({
203+
userId, deviceInfo,
204+
})),
205+
content,
206+
);
207+
} else {
208+
// Send the message to a specific device
209+
await client.encryptAndSendToDevices(
210+
[{ userId, deviceInfo: deviceInfoMap[userId][deviceId] }],
211+
content,
212+
);
213+
}
214+
}),
215+
),
216+
);
217+
} else {
218+
await client.queueToDevice({
219+
eventType,
220+
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
221+
Object.entries(userContentMap).map(([deviceId, content]) =>
222+
({ userId, deviceId, payload: content }),
223+
),
224+
),
225+
});
226+
}
227+
}
228+
185229
private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
186230
const client = MatrixClientPeg.get();
187231
if (!client) throw new Error("Not attached to a client");
@@ -197,7 +241,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
197241
msgtype: string | undefined,
198242
limitPerRoom: number,
199243
roomIds: (string | Symbols.AnyRoom)[] = null,
200-
): Promise<object[]> {
244+
): Promise<IRoomEvent[]> {
201245
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
202246

203247
const rooms = this.pickRooms(roomIds);
@@ -224,7 +268,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
224268
stateKey: string | undefined,
225269
limitPerRoom: number,
226270
roomIds: (string | Symbols.AnyRoom)[] = null,
227-
): Promise<object[]> {
271+
): Promise<IRoomEvent[]> {
228272
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
229273

230274
const rooms = this.pickRooms(roomIds);

src/widgets/CapabilityText.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
import {
1818
Capability,
1919
EventDirection,
20+
EventKind,
2021
getTimelineRoomIDFromCapability,
2122
isTimelineCapability,
2223
isTimelineCapabilityFor,
@@ -134,7 +135,7 @@ export class CapabilityText {
134135
};
135136

136137
private static bylineFor(eventCap: WidgetEventCapability): TranslatedString {
137-
if (eventCap.isState) {
138+
if (eventCap.kind === EventKind.State) {
138139
return !eventCap.keyStr
139140
? _t("with an empty state key")
140141
: _t("with state key %(stateKey)s", { stateKey: eventCap.keyStr });
@@ -143,6 +144,8 @@ export class CapabilityText {
143144
}
144145

145146
public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText {
147+
// TODO: Support MSC3819 (to-device capabilities)
148+
146149
// First see if we have a super simple line of text to provide back
147150
if (CapabilityText.simpleCaps[capability]) {
148151
const textForKind = CapabilityText.simpleCaps[capability];
@@ -184,13 +187,13 @@ export class CapabilityText {
184187
// Special case room messages so they show up a bit cleaner to the user. Result is
185188
// effectively "Send images" instead of "Send messages... of type images" if we were
186189
// to handle the msgtype nuances in this function.
187-
if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) {
190+
if (eventCap.kind === EventKind.Event && eventCap.eventType === EventType.RoomMessage) {
188191
return CapabilityText.forRoomMessageCap(eventCap, kind);
189192
}
190193

191194
// See if we have a static line of text to provide for the given event type and
192195
// direction. The hope is that we do for common event types for friendlier copy.
193-
const evSendRecv = eventCap.isState
196+
const evSendRecv = eventCap.kind === EventKind.State
194197
? CapabilityText.stateSendRecvCaps
195198
: CapabilityText.nonStateSendRecvCaps;
196199
if (evSendRecv[eventCap.eventType]) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { mocked, MockedObject } from "jest-mock";
18+
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
19+
import { ClientWidgetApi } from "matrix-widget-api";
20+
21+
import { stubClient, mkRoom, mkEvent } from "../../test-utils";
22+
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
23+
import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget";
24+
25+
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
26+
27+
describe("StopGapWidget", () => {
28+
let client: MockedObject<MatrixClient>;
29+
let widget: StopGapWidget;
30+
let messaging: MockedObject<ClientWidgetApi>;
31+
32+
beforeEach(() => {
33+
stubClient();
34+
client = mocked(MatrixClientPeg.get());
35+
36+
widget = new StopGapWidget({
37+
app: {
38+
id: "test",
39+
creatorUserId: "@alice:example.org",
40+
type: "example",
41+
url: "https://example.org",
42+
},
43+
room: mkRoom(client, "!1:example.org"),
44+
userId: "@alice:example.org",
45+
creatorUserId: "@alice:example.org",
46+
waitForIframeLoad: true,
47+
userWidget: false,
48+
});
49+
// Start messaging without an iframe, since ClientWidgetApi is mocked
50+
widget.startMessaging(null as unknown as HTMLIFrameElement);
51+
messaging = mocked(mocked(ClientWidgetApi).mock.instances[0]);
52+
});
53+
54+
afterEach(() => {
55+
widget.stopMessaging();
56+
});
57+
58+
it("feeds incoming to-device messages to the widget", async () => {
59+
const event = mkEvent({
60+
event: true,
61+
type: "org.example.foo",
62+
user: "@alice:example.org",
63+
content: { hello: "world" },
64+
});
65+
66+
client.emit(ClientEvent.ToDeviceEvent, event);
67+
await Promise.resolve(); // flush promises
68+
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
69+
});
70+
});

0 commit comments

Comments
 (0)