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
This commit is contained in:
parent
3d0982e9a6
commit
103b60dfb5
9 changed files with 322 additions and 24 deletions
|
@ -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,
|
||||
|
@ -260,8 +263,10 @@ export class StopGapWidget extends EventEmitter {
|
|||
*/
|
||||
public startMessaging(iframe: HTMLIFrameElement): any {
|
||||
if (this.started) return;
|
||||
|
||||
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 +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 MatrixClientPeg.get().getRooms()) {
|
||||
for (const room of this.client.getRooms()) {
|
||||
// Timelines are most recent last
|
||||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||
const roomEvent = events[events.length - 1];
|
||||
|
@ -311,8 +316,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);
|
||||
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<IStickyActionRequest>) => {
|
||||
|
@ -363,7 +369,7 @@ export class StopGapWidget extends EventEmitter {
|
|||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()),
|
||||
this.client.getRoom(RoomViewStore.instance.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
|
@ -428,14 +434,13 @@ 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);
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
@ -445,6 +450,12 @@ export class StopGapWidget extends EventEmitter {
|
|||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onToDeviceEvent = async (ev: MatrixEvent) => {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted());
|
||||
};
|
||||
|
||||
private feedEvent(ev: MatrixEvent) {
|
||||
if (!this.messaging) return;
|
||||
|
||||
|
@ -465,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) {
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
IOpenIDCredentials,
|
||||
IOpenIDUpdate,
|
||||
ISendEventDetails,
|
||||
IRoomEvent,
|
||||
MatrixCapabilities,
|
||||
OpenIDRequestState,
|
||||
SimpleObservable,
|
||||
|
@ -182,6 +183,49 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
|||
return { roomId, eventId: r.event_id };
|
||||
}
|
||||
|
||||
public async sendToDevice(
|
||||
eventType: string,
|
||||
encrypted: boolean,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: object } },
|
||||
): Promise<void> {
|
||||
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 client.queueToDevice({
|
||||
eventType,
|
||||
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
|
||||
Object.entries(userContentMap).map(([deviceId, content]) =>
|
||||
({ userId, deviceId, payload: content }),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) throw new Error("Not attached to a client");
|
||||
|
@ -197,7 +241,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
|||
msgtype: string | undefined,
|
||||
limitPerRoom: number,
|
||||
roomIds: (string | Symbols.AnyRoom)[] = null,
|
||||
): Promise<object[]> {
|
||||
): Promise<IRoomEvent[]> {
|
||||
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
|
||||
const rooms = this.pickRooms(roomIds);
|
||||
|
@ -224,7 +268,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
|||
stateKey: string | undefined,
|
||||
limitPerRoom: number,
|
||||
roomIds: (string | Symbols.AnyRoom)[] = null,
|
||||
): Promise<object[]> {
|
||||
): Promise<IRoomEvent[]> {
|
||||
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
|
||||
const rooms = this.pickRooms(roomIds);
|
||||
|
|
|
@ -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]) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue