From ac15148fecf584b2a21ed739db4129dd0d30fb0d Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 23 Jan 2025 17:49:01 +0100 Subject: [PATCH] run prettier --- src/Symbols.ts | 2 +- src/WidgetApi.ts | 1757 +++--- src/interfaces/SetModalButtonEnabledAction.ts | 16 +- src/interfaces/StickerAction.ts | 40 +- src/interfaces/StickyAction.ts | 10 +- src/interfaces/SupportedVersionsAction.ts | 26 +- src/interfaces/ThemeChangeAction.ts | 8 +- src/interfaces/TurnServerActions.ts | 44 +- src/interfaces/UpdateDelayedEventAction.ts | 30 +- src/interfaces/UpdateStateAction.ts | 16 +- src/interfaces/UploadFileAction.ts | 22 +- src/interfaces/UserDirectorySearchAction.ts | 34 +- src/interfaces/VisibilityAction.ts | 8 +- src/interfaces/WidgetApiAction.ts | 133 +- src/interfaces/WidgetApiDirection.ts | 18 +- src/interfaces/WidgetConfigAction.ts | 11 +- src/interfaces/WidgetKind.ts | 6 +- src/interfaces/WidgetType.ts | 6 +- src/models/Widget.ts | 146 +- src/models/WidgetEventCapability.ts | 437 +- src/models/WidgetParser.ts | 233 +- src/models/validation/url.ts | 24 +- src/models/validation/utils.ts | 11 +- src/templating/url-template.ts | 88 +- src/transport/ITransport.ts | 150 +- src/transport/PostmessageTransport.ts | 335 +- src/util/SimpleObservable.ts | 28 +- test/ClientWidgetApi-test.ts | 4708 +++++++++-------- test/WidgetApi-test.ts | 1510 +++--- test/url-template-test.ts | 74 +- 30 files changed, 5261 insertions(+), 4670 deletions(-) diff --git a/src/Symbols.ts b/src/Symbols.ts index 85ca12e..04ee9d0 100644 --- a/src/Symbols.ts +++ b/src/Symbols.ts @@ -15,5 +15,5 @@ */ export enum Symbols { - AnyRoom = "*", + AnyRoom = "*", } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 44f0de9..4793bc6 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -17,96 +17,124 @@ import { EventEmitter } from "events"; import { Capability } from "./interfaces/Capabilities"; -import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest"; +import { + IWidgetApiRequest, + IWidgetApiRequestEmptyData, +} from "./interfaces/IWidgetApiRequest"; import { IWidgetApiAcknowledgeResponseData } from "./interfaces/IWidgetApiResponse"; import { WidgetApiDirection } from "./interfaces/WidgetApiDirection"; import { - ISupportedVersionsActionRequest, - ISupportedVersionsActionResponseData, + ISupportedVersionsActionRequest, + ISupportedVersionsActionResponseData, } from "./interfaces/SupportedVersionsAction"; -import { ApiVersion, CurrentApiVersions, UnstableApiVersion } from "./interfaces/ApiVersion"; import { - ICapabilitiesActionRequest, - ICapabilitiesActionResponseData, - INotifyCapabilitiesActionRequest, - IRenegotiateCapabilitiesRequestData, + ApiVersion, + CurrentApiVersions, + UnstableApiVersion, +} from "./interfaces/ApiVersion"; +import { + ICapabilitiesActionRequest, + ICapabilitiesActionResponseData, + INotifyCapabilitiesActionRequest, + IRenegotiateCapabilitiesRequestData, } from "./interfaces/CapabilitiesAction"; import { ITransport } from "./transport/ITransport"; import { PostmessageTransport } from "./transport/PostmessageTransport"; -import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; -import { IWidgetApiErrorResponseData, IWidgetApiErrorResponseDataDetails } from "./interfaces/IWidgetApiErrorResponse"; +import { + WidgetApiFromWidgetAction, + WidgetApiToWidgetAction, +} from "./interfaces/WidgetApiAction"; +import { + IWidgetApiErrorResponseData, + IWidgetApiErrorResponseDataDetails, +} from "./interfaces/IWidgetApiErrorResponse"; import { IStickerActionRequestData } from "./interfaces/StickerAction"; -import { IStickyActionRequestData, IStickyActionResponseData } from "./interfaces/StickyAction"; import { - IGetOpenIDActionRequestData, - IGetOpenIDActionResponse, - IOpenIDCredentials, - OpenIDRequestState, + IStickyActionRequestData, + IStickyActionResponseData, +} from "./interfaces/StickyAction"; +import { + IGetOpenIDActionRequestData, + IGetOpenIDActionResponse, + IOpenIDCredentials, + OpenIDRequestState, } from "./interfaces/GetOpenIDAction"; import { IOpenIDCredentialsActionRequest } from "./interfaces/OpenIDCredentialsAction"; import { MatrixWidgetType, WidgetType } from "./interfaces/WidgetType"; import { - BuiltInModalButtonID, - IModalWidgetCreateData, - IModalWidgetOpenRequestData, - IModalWidgetOpenRequestDataButton, - IModalWidgetReturnData, - ModalButtonID, + BuiltInModalButtonID, + IModalWidgetCreateData, + IModalWidgetOpenRequestData, + IModalWidgetOpenRequestDataButton, + IModalWidgetReturnData, + ModalButtonID, } from "./interfaces/ModalWidgetActions"; import { ISetModalButtonEnabledActionRequestData } from "./interfaces/SetModalButtonEnabledAction"; -import { ISendEventFromWidgetRequestData, ISendEventFromWidgetResponseData } from "./interfaces/SendEventAction"; import { - ISendToDeviceFromWidgetRequestData, - ISendToDeviceFromWidgetResponseData, + ISendEventFromWidgetRequestData, + ISendEventFromWidgetResponseData, +} from "./interfaces/SendEventAction"; +import { + ISendToDeviceFromWidgetRequestData, + ISendToDeviceFromWidgetResponseData, } from "./interfaces/SendToDeviceAction"; -import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; +import { + EventDirection, + WidgetEventCapability, +} from "./models/WidgetEventCapability"; import { INavigateActionRequestData } from "./interfaces/NavigateAction"; -import { IReadEventFromWidgetRequestData, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; import { - IReadRoomAccountDataFromWidgetRequestData, - IReadRoomAccountDataFromWidgetResponseData, + IReadEventFromWidgetRequestData, + IReadEventFromWidgetResponseData, +} from "./interfaces/ReadEventAction"; +import { + IReadRoomAccountDataFromWidgetRequestData, + IReadRoomAccountDataFromWidgetResponseData, } from "./interfaces/ReadRoomAccountDataAction"; import { IRoomEvent } from "./interfaces/IRoomEvent"; import { IRoomAccountData } from "./interfaces/IRoomAccountData"; -import { ITurnServer, IUpdateTurnServersRequest } from "./interfaces/TurnServerActions"; +import { + ITurnServer, + IUpdateTurnServersRequest, +} from "./interfaces/TurnServerActions"; import { Symbols } from "./Symbols"; import { - IReadRelationsFromWidgetRequestData, - IReadRelationsFromWidgetResponseData, + IReadRelationsFromWidgetRequestData, + IReadRelationsFromWidgetResponseData, } from "./interfaces/ReadRelationsAction"; import { - IUserDirectorySearchFromWidgetRequestData, - IUserDirectorySearchFromWidgetResponseData, + IUserDirectorySearchFromWidgetRequestData, + IUserDirectorySearchFromWidgetResponseData, } from "./interfaces/UserDirectorySearchAction"; import { - IGetMediaConfigActionFromWidgetRequestData, - IGetMediaConfigActionFromWidgetResponseData, + IGetMediaConfigActionFromWidgetRequestData, + IGetMediaConfigActionFromWidgetResponseData, } from "./interfaces/GetMediaConfigAction"; import { - IUploadFileActionFromWidgetRequestData, - IUploadFileActionFromWidgetResponseData, + IUploadFileActionFromWidgetRequestData, + IUploadFileActionFromWidgetResponseData, } from "./interfaces/UploadFileAction"; import { - IDownloadFileActionFromWidgetRequestData, - IDownloadFileActionFromWidgetResponseData, + IDownloadFileActionFromWidgetRequestData, + IDownloadFileActionFromWidgetResponseData, } from "./interfaces/DownloadFileAction"; import { - IUpdateDelayedEventFromWidgetRequestData, - IUpdateDelayedEventFromWidgetResponseData, - UpdateDelayedEventAction, + IUpdateDelayedEventFromWidgetRequestData, + IUpdateDelayedEventFromWidgetResponseData, + UpdateDelayedEventAction, } from "./interfaces/UpdateDelayedEventAction"; export class WidgetApiResponseError extends Error { - static { - this.prototype.name = this.name; - } - - public constructor( - message: string, - public readonly data: IWidgetApiErrorResponseDataDetails, - ) { - super(message); - } + static { + this.prototype.name = this.name; + } + + public constructor( + message: string, + public readonly data: IWidgetApiErrorResponseDataDetails, + ) { + super(message); + } } /** @@ -127,777 +155,930 @@ export class WidgetApiResponseError extends Error { * can be sent and the transport will be ready. */ export class WidgetApi extends EventEmitter { - public readonly transport: ITransport; - - private capabilitiesFinished = false; - private supportsMSC2974Renegotiate = false; - private requestedCapabilities: Capability[] = []; - private approvedCapabilities?: Capability[]; - private cachedClientVersions?: ApiVersion[]; - private turnServerWatchers = 0; - - /** - * Creates a new API handler for the given widget. - * @param {string} widgetId The widget ID to listen for. If not supplied then - * the API will use the widget ID from the first valid request it receives. - * @param {string} clientOrigin The origin of the client, or null if not known. - */ - public constructor( - widgetId: string | null = null, - private clientOrigin: string | null = null, - ) { - super(); - if (!window.parent) { - throw new Error("No parent window. This widget doesn't appear to be embedded properly."); - } - this.transport = new PostmessageTransport(WidgetApiDirection.FromWidget, widgetId, window.parent, window); - this.transport.targetOrigin = clientOrigin; - this.transport.on("message", this.handleMessage.bind(this)); - } - - /** - * Determines if the widget was granted a particular capability. Note that on - * clients where the capabilities are not fed back to the widget this function - * will rely on requested capabilities instead. - * @param {Capability} capability The capability to check for approval of. - * @returns {boolean} True if the widget has approval for the given capability. - */ - public hasCapability(capability: Capability): boolean { - if (Array.isArray(this.approvedCapabilities)) { - return this.approvedCapabilities.includes(capability); - } - return this.requestedCapabilities.includes(capability); - } - - /** - * Request a capability from the client. It is not guaranteed to be allowed, - * but will be asked for. - * @param {Capability} capability The capability to request. - * @throws Throws if the capabilities negotiation has already started and the - * widget is unable to request additional capabilities. - */ - public requestCapability(capability: Capability): void { - if (this.capabilitiesFinished && !this.supportsMSC2974Renegotiate) { - throw new Error("Capabilities have already been negotiated"); - } - - this.requestedCapabilities.push(capability); - } - - /** - * Request capabilities from the client. They are not guaranteed to be allowed, - * but will be asked for if the negotiation has not already happened. - * @param {Capability[]} capabilities The capabilities to request. - * @throws Throws if the capabilities negotiation has already started. - */ - public requestCapabilities(capabilities: Capability[]): void { - capabilities.forEach((cap) => this.requestCapability(cap)); - } - - /** - * Requests the capability to interact with rooms other than the user's currently - * viewed room. Applies to event receiving and sending. - * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` to - * denote all known rooms. - */ - public requestCapabilityForRoomTimeline(roomId: string | Symbols.AnyRoom): void { - this.requestCapability(`org.matrix.msc2762.timeline:${roomId}`); - } - - /** - * Requests the capability to send a given state event with optional explicit - * state key. It is not guaranteed to be allowed, but will be asked for if the - * negotiation has not already happened. - * @param {string} eventType The state event type to ask for. - * @param {string} stateKey If specified, the specific state key to request. - * Otherwise all state keys will be requested. - */ - public requestCapabilityToSendState(eventType: string, stateKey?: string): void { - this.requestCapability(WidgetEventCapability.forStateEvent(EventDirection.Send, eventType, stateKey).raw); - } - - /** - * Requests the capability to receive a given state event with optional explicit - * state key. It is not guaranteed to be allowed, but will be asked for if the - * negotiation has not already happened. - * @param {string} eventType The state event type to ask for. - * @param {string} stateKey If specified, the specific state key to request. - * Otherwise all state keys will be requested. - */ - public requestCapabilityToReceiveState(eventType: string, stateKey?: string): void { - this.requestCapability(WidgetEventCapability.forStateEvent(EventDirection.Receive, eventType, stateKey).raw); - } - - /** - * Requests the capability to send a given to-device event. It is not - * guaranteed to be allowed, but will be asked for if the negotiation has - * not already happened. - * @param {string} eventType The room event type to ask for. - */ - public requestCapabilityToSendToDevice(eventType: string): void { - this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw); - } - - /** - * Requests the capability to receive a given to-device event. It is not - * guaranteed to be allowed, but will be asked for if the negotiation has - * not already happened. - * @param {string} eventType The room event type to ask for. - */ - public requestCapabilityToReceiveToDevice(eventType: string): void { - this.requestCapability(WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw); - } - - /** - * Requests the capability to send a given room event. It is not guaranteed to be - * allowed, but will be asked for if the negotiation has not already happened. - * @param {string} eventType The room event type to ask for. - */ - public requestCapabilityToSendEvent(eventType: string): void { - this.requestCapability(WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw); - } - - /** - * Requests the capability to receive a given room event. It is not guaranteed to be - * allowed, but will be asked for if the negotiation has not already happened. - * @param {string} eventType The room event type to ask for. - */ - public requestCapabilityToReceiveEvent(eventType: string): void { - this.requestCapability(WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw); - } - - /** - * Requests the capability to send a given message event with optional explicit - * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the - * negotiation has not already happened. - * @param {string} msgtype If specified, the specific msgtype to request. - * Otherwise all message types will be requested. - */ - public requestCapabilityToSendMessage(msgtype?: string): void { - this.requestCapability(WidgetEventCapability.forRoomMessageEvent(EventDirection.Send, msgtype).raw); + public readonly transport: ITransport; + + private capabilitiesFinished = false; + private supportsMSC2974Renegotiate = false; + private requestedCapabilities: Capability[] = []; + private approvedCapabilities?: Capability[]; + private cachedClientVersions?: ApiVersion[]; + private turnServerWatchers = 0; + + /** + * Creates a new API handler for the given widget. + * @param {string} widgetId The widget ID to listen for. If not supplied then + * the API will use the widget ID from the first valid request it receives. + * @param {string} clientOrigin The origin of the client, or null if not known. + */ + public constructor( + widgetId: string | null = null, + private clientOrigin: string | null = null, + ) { + super(); + if (!window.parent) { + throw new Error( + "No parent window. This widget doesn't appear to be embedded properly.", + ); } - - /** - * Requests the capability to receive a given message event with optional explicit - * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the - * negotiation has not already happened. - * @param {string} msgtype If specified, the specific msgtype to request. - * Otherwise all message types will be requested. - */ - public requestCapabilityToReceiveMessage(msgtype?: string): void { - this.requestCapability(WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, msgtype).raw); + this.transport = new PostmessageTransport( + WidgetApiDirection.FromWidget, + widgetId, + window.parent, + window, + ); + this.transport.targetOrigin = clientOrigin; + this.transport.on("message", this.handleMessage.bind(this)); + } + + /** + * Determines if the widget was granted a particular capability. Note that on + * clients where the capabilities are not fed back to the widget this function + * will rely on requested capabilities instead. + * @param {Capability} capability The capability to check for approval of. + * @returns {boolean} True if the widget has approval for the given capability. + */ + public hasCapability(capability: Capability): boolean { + if (Array.isArray(this.approvedCapabilities)) { + return this.approvedCapabilities.includes(capability); } - - /** - * Requests the capability to receive a given item in room account data. It is not guaranteed to be - * allowed, but will be asked for if the negotiation has not already happened. - * @param {string} eventType The state event type to ask for. - */ - public requestCapabilityToReceiveRoomAccountData(eventType: string): void { - this.requestCapability(WidgetEventCapability.forRoomAccountData(EventDirection.Receive, eventType).raw); + return this.requestedCapabilities.includes(capability); + } + + /** + * Request a capability from the client. It is not guaranteed to be allowed, + * but will be asked for. + * @param {Capability} capability The capability to request. + * @throws Throws if the capabilities negotiation has already started and the + * widget is unable to request additional capabilities. + */ + public requestCapability(capability: Capability): void { + if (this.capabilitiesFinished && !this.supportsMSC2974Renegotiate) { + throw new Error("Capabilities have already been negotiated"); } - /** - * Requests an OpenID Connect token from the client for the currently logged in - * user. This token can be validated server-side with the federation API. Note - * that the widget is responsible for validating the token and caching any results - * it needs. - * @returns {Promise} Resolves to a token for verification. - * @throws Throws if the user rejected the request or the request failed. - */ - public requestOpenIDConnectToken(): Promise { - return new Promise((resolve, reject) => { - this.transport - .sendComplete( - WidgetApiFromWidgetAction.GetOpenIDCredentials, - {}, - ) - .then((response) => { - const rdata = response.response; - if (rdata.state === OpenIDRequestState.Allowed) { - resolve(rdata); - } else if (rdata.state === OpenIDRequestState.Blocked) { - reject(new Error("User declined to verify their identity")); - } else if (rdata.state === OpenIDRequestState.PendingUserConfirmation) { - const handlerFn = (ev: CustomEvent): void => { - ev.preventDefault(); - const request = ev.detail; - if (request.data.original_request_id !== response.requestId) return; - if (request.data.state === OpenIDRequestState.Allowed) { - resolve(request.data); - this.transport.reply(request, {}); // ack - } else if (request.data.state === OpenIDRequestState.Blocked) { - reject(new Error("User declined to verify their identity")); - this.transport.reply(request, {}); // ack - } else { - reject(new Error("Invalid state on reply: " + rdata.state)); - this.transport.reply(request, { - error: { - message: "Invalid state", - }, - }); - } - this.off(`action:${WidgetApiToWidgetAction.OpenIDCredentials}`, handlerFn); - }; - this.on(`action:${WidgetApiToWidgetAction.OpenIDCredentials}`, handlerFn); - } else { - reject(new Error("Invalid state: " + rdata.state)); - } - }) - .catch(reject); - }); + this.requestedCapabilities.push(capability); + } + + /** + * Request capabilities from the client. They are not guaranteed to be allowed, + * but will be asked for if the negotiation has not already happened. + * @param {Capability[]} capabilities The capabilities to request. + * @throws Throws if the capabilities negotiation has already started. + */ + public requestCapabilities(capabilities: Capability[]): void { + capabilities.forEach((cap) => this.requestCapability(cap)); + } + + /** + * Requests the capability to interact with rooms other than the user's currently + * viewed room. Applies to event receiving and sending. + * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` to + * denote all known rooms. + */ + public requestCapabilityForRoomTimeline( + roomId: string | Symbols.AnyRoom, + ): void { + this.requestCapability(`org.matrix.msc2762.timeline:${roomId}`); + } + + /** + * Requests the capability to send a given state event with optional explicit + * state key. It is not guaranteed to be allowed, but will be asked for if the + * negotiation has not already happened. + * @param {string} eventType The state event type to ask for. + * @param {string} stateKey If specified, the specific state key to request. + * Otherwise all state keys will be requested. + */ + public requestCapabilityToSendState( + eventType: string, + stateKey?: string, + ): void { + this.requestCapability( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + eventType, + stateKey, + ).raw, + ); + } + + /** + * Requests the capability to receive a given state event with optional explicit + * state key. It is not guaranteed to be allowed, but will be asked for if the + * negotiation has not already happened. + * @param {string} eventType The state event type to ask for. + * @param {string} stateKey If specified, the specific state key to request. + * Otherwise all state keys will be requested. + */ + public requestCapabilityToReceiveState( + eventType: string, + stateKey?: string, + ): void { + this.requestCapability( + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + eventType, + stateKey, + ).raw, + ); + } + + /** + * Requests the capability to send a given to-device event. It is not + * guaranteed to be allowed, but will be asked for if the negotiation has + * not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToSendToDevice(eventType: string): void { + this.requestCapability( + WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType) + .raw, + ); + } + + /** + * Requests the capability to receive a given to-device event. It is not + * guaranteed to be allowed, but will be asked for if the negotiation has + * not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToReceiveToDevice(eventType: string): void { + this.requestCapability( + WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType) + .raw, + ); + } + + /** + * Requests the capability to send a given room event. It is not guaranteed to be + * allowed, but will be asked for if the negotiation has not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToSendEvent(eventType: string): void { + this.requestCapability( + WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw, + ); + } + + /** + * Requests the capability to receive a given room event. It is not guaranteed to be + * allowed, but will be asked for if the negotiation has not already happened. + * @param {string} eventType The room event type to ask for. + */ + public requestCapabilityToReceiveEvent(eventType: string): void { + this.requestCapability( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw, + ); + } + + /** + * Requests the capability to send a given message event with optional explicit + * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the + * negotiation has not already happened. + * @param {string} msgtype If specified, the specific msgtype to request. + * Otherwise all message types will be requested. + */ + public requestCapabilityToSendMessage(msgtype?: string): void { + this.requestCapability( + WidgetEventCapability.forRoomMessageEvent(EventDirection.Send, msgtype) + .raw, + ); + } + + /** + * Requests the capability to receive a given message event with optional explicit + * `msgtype`. It is not guaranteed to be allowed, but will be asked for if the + * negotiation has not already happened. + * @param {string} msgtype If specified, the specific msgtype to request. + * Otherwise all message types will be requested. + */ + public requestCapabilityToReceiveMessage(msgtype?: string): void { + this.requestCapability( + WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, msgtype) + .raw, + ); + } + + /** + * Requests the capability to receive a given item in room account data. It is not guaranteed to be + * allowed, but will be asked for if the negotiation has not already happened. + * @param {string} eventType The state event type to ask for. + */ + public requestCapabilityToReceiveRoomAccountData(eventType: string): void { + this.requestCapability( + WidgetEventCapability.forRoomAccountData( + EventDirection.Receive, + eventType, + ).raw, + ); + } + + /** + * Requests an OpenID Connect token from the client for the currently logged in + * user. This token can be validated server-side with the federation API. Note + * that the widget is responsible for validating the token and caching any results + * it needs. + * @returns {Promise} Resolves to a token for verification. + * @throws Throws if the user rejected the request or the request failed. + */ + public requestOpenIDConnectToken(): Promise { + return new Promise((resolve, reject) => { + this.transport + .sendComplete( + WidgetApiFromWidgetAction.GetOpenIDCredentials, + {}, + ) + .then((response) => { + const rdata = response.response; + if (rdata.state === OpenIDRequestState.Allowed) { + resolve(rdata); + } else if (rdata.state === OpenIDRequestState.Blocked) { + reject(new Error("User declined to verify their identity")); + } else if ( + rdata.state === OpenIDRequestState.PendingUserConfirmation + ) { + const handlerFn = ( + ev: CustomEvent, + ): void => { + ev.preventDefault(); + const request = ev.detail; + if (request.data.original_request_id !== response.requestId) + return; + if (request.data.state === OpenIDRequestState.Allowed) { + resolve(request.data); + this.transport.reply(request, {}); // ack + } else if (request.data.state === OpenIDRequestState.Blocked) { + reject(new Error("User declined to verify their identity")); + this.transport.reply(request, {}); // ack + } else { + reject(new Error("Invalid state on reply: " + rdata.state)); + this.transport.reply(request, { + error: { + message: "Invalid state", + }, + }); + } + this.off( + `action:${WidgetApiToWidgetAction.OpenIDCredentials}`, + handlerFn, + ); + }; + this.on( + `action:${WidgetApiToWidgetAction.OpenIDCredentials}`, + handlerFn, + ); + } else { + reject(new Error("Invalid state: " + rdata.state)); + } + }) + .catch(reject); + }); + } + + /** + * Asks the client for additional capabilities. Capabilities can be queued for this + * request with the requestCapability() functions. + * @returns {Promise} Resolves when complete. Note that the promise resolves when + * the capabilities request has gone through, not when the capabilities are approved/denied. + * Use the WidgetApiToWidgetAction.NotifyCapabilities action to detect changes. + */ + public updateRequestedCapabilities(): Promise { + return this.transport + .send(WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities, < + IRenegotiateCapabilitiesRequestData + >{ + capabilities: this.requestedCapabilities, + }) + .then(); + } + + /** + * Tell the client that the content has been loaded. + * @returns {Promise} Resolves when the client acknowledges the request. + */ + public sendContentLoaded(): Promise { + return this.transport + .send( + WidgetApiFromWidgetAction.ContentLoaded, + {}, + ) + .then(); + } + + /** + * Sends a sticker to the client. + * @param {IStickerActionRequestData} sticker The sticker to send. + * @returns {Promise} Resolves when the client acknowledges the request. + */ + public sendSticker(sticker: IStickerActionRequestData): Promise { + return this.transport + .send(WidgetApiFromWidgetAction.SendSticker, sticker) + .then(); + } + + /** + * Asks the client to set the always-on-screen status for this widget. + * @param {boolean} value The new state to request. + * @returns {Promise} Resolve with true if the client was able to fulfill + * the request, resolves to false otherwise. Rejects if an error occurred. + */ + public setAlwaysOnScreen(value: boolean): Promise { + return this.transport + .send< + IStickyActionRequestData, + IStickyActionResponseData + >(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, { value }) + .then((res) => res.success); + } + + /** + * Opens a modal widget. + * @param {string} url The URL to the modal widget. + * @param {string} name The name of the widget. + * @param {IModalWidgetOpenRequestDataButton[]} buttons The buttons to have on the widget. + * @param {IModalWidgetCreateData} data Data to supply to the modal widget. + * @param {WidgetType} type The type of modal widget. + * @returns {Promise} Resolves when the modal widget has been opened. + */ + public openModalWidget( + url: string, + name: string, + buttons: IModalWidgetOpenRequestDataButton[] = [], + data: IModalWidgetCreateData = {}, + type: WidgetType = MatrixWidgetType.Custom, + ): Promise { + return this.transport + .send( + WidgetApiFromWidgetAction.OpenModalWidget, + { + type, + url, + name, + buttons, + data, + }, + ) + .then(); + } + + /** + * Closes the modal widget. The widget's session will be terminated shortly after. + * @param {IModalWidgetReturnData} data Optional data to close the modal widget with. + * @returns {Promise} Resolves when complete. + */ + public closeModalWidget(data: IModalWidgetReturnData = {}): Promise { + return this.transport + .send( + WidgetApiFromWidgetAction.CloseModalWidget, + data, + ) + .then(); + } + + public sendRoomEvent( + eventType: string, + content: unknown, + roomId?: string, + delay?: number, + parentDelayId?: string, + ): Promise { + return this.sendEvent( + eventType, + undefined, + content, + roomId, + delay, + parentDelayId, + ); + } + + public sendStateEvent( + eventType: string, + stateKey: string, + content: unknown, + roomId?: string, + delay?: number, + parentDelayId?: string, + ): Promise { + return this.sendEvent( + eventType, + stateKey, + content, + roomId, + delay, + parentDelayId, + ); + } + + private sendEvent( + eventType: string, + stateKey: string | undefined, + content: unknown, + roomId?: string, + delay?: number, + parentDelayId?: string, + ): Promise { + return this.transport.send< + ISendEventFromWidgetRequestData, + ISendEventFromWidgetResponseData + >(WidgetApiFromWidgetAction.SendEvent, { + type: eventType, + content, + ...(stateKey !== undefined && { state_key: stateKey }), + ...(roomId !== undefined && { room_id: roomId }), + ...(delay !== undefined && { delay }), + ...(parentDelayId !== undefined && { parent_delay_id: parentDelayId }), + }); + } + + /** + * @deprecated This currently relies on an unstable MSC (MSC4157). + */ + public updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + ): Promise { + return this.transport.send< + IUpdateDelayedEventFromWidgetRequestData, + IUpdateDelayedEventFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, { + delay_id: delayId, + action, + }); + } + + /** + * Sends a to-device event. + * @param {string} eventType The type of events being sent. + * @param {boolean} encrypted Whether to encrypt the message contents. + * @param {Object} contentMap A map from user IDs to device IDs to message contents. + * @returns {Promise} Resolves when complete. + */ + public sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } }, + ): Promise { + return this.transport.send< + ISendToDeviceFromWidgetRequestData, + ISendToDeviceFromWidgetResponseData + >(WidgetApiFromWidgetAction.SendToDevice, { + type: eventType, + encrypted, + messages: contentMap, + }); + } + + public readRoomAccountData( + eventType: string, + roomIds?: (string | Symbols.AnyRoom)[], + ): Promise { + const data: IReadEventFromWidgetRequestData = { type: eventType }; + + if (roomIds) { + if (roomIds.includes(Symbols.AnyRoom)) { + data.room_ids = Symbols.AnyRoom; + } else { + data.room_ids = roomIds; + } } - - /** - * Asks the client for additional capabilities. Capabilities can be queued for this - * request with the requestCapability() functions. - * @returns {Promise} Resolves when complete. Note that the promise resolves when - * the capabilities request has gone through, not when the capabilities are approved/denied. - * Use the WidgetApiToWidgetAction.NotifyCapabilities action to detect changes. - */ - public updateRequestedCapabilities(): Promise { - return this.transport - .send(WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities, { - capabilities: this.requestedCapabilities, - }) - .then(); + return this.transport + .send< + IReadRoomAccountDataFromWidgetRequestData, + IReadRoomAccountDataFromWidgetResponseData + >(WidgetApiFromWidgetAction.BeeperReadRoomAccountData, data) + .then((r) => r.events); + } + + public readRoomEvents( + eventType: string, + limit?: number, + msgtype?: string, + roomIds?: (string | Symbols.AnyRoom)[], + since?: string | undefined, + ): Promise { + const data: IReadEventFromWidgetRequestData = { + type: eventType, + msgtype: msgtype, + }; + if (limit !== undefined) { + data.limit = limit; } - - /** - * Tell the client that the content has been loaded. - * @returns {Promise} Resolves when the client acknowledges the request. - */ - public sendContentLoaded(): Promise { - return this.transport.send(WidgetApiFromWidgetAction.ContentLoaded, {}).then(); + if (roomIds) { + if (roomIds.includes(Symbols.AnyRoom)) { + data.room_ids = Symbols.AnyRoom; + } else { + data.room_ids = roomIds; + } } - - /** - * Sends a sticker to the client. - * @param {IStickerActionRequestData} sticker The sticker to send. - * @returns {Promise} Resolves when the client acknowledges the request. - */ - public sendSticker(sticker: IStickerActionRequestData): Promise { - return this.transport.send(WidgetApiFromWidgetAction.SendSticker, sticker).then(); + if (since) { + data.since = since; } - - /** - * Asks the client to set the always-on-screen status for this widget. - * @param {boolean} value The new state to request. - * @returns {Promise} Resolve with true if the client was able to fulfill - * the request, resolves to false otherwise. Rejects if an error occurred. - */ - public setAlwaysOnScreen(value: boolean): Promise { - return this.transport - .send< - IStickyActionRequestData, - IStickyActionResponseData - >(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, { value }) - .then((res) => res.success); + return this.transport + .send< + IReadEventFromWidgetRequestData, + IReadEventFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC2876ReadEvents, data) + .then((r) => r.events); + } + + /** + * Reads all related events given a known eventId. + * @param eventId The id of the parent event to be read. + * @param roomId The room to look within. When undefined, the user's currently + * viewed room. + * @param relationType The relationship type of child events to search for. + * When undefined, all relations are returned. + * @param eventType The event type of child events to search for. When undefined, + * all related events are returned. + * @param limit The maximum number of events to retrieve per room. If not + * supplied, the server will apply a default limit. + * @param from The pagination token to start returning results from, as + * received from a previous call. If not supplied, results start at the most + * recent topological event known to the server. + * @param to The pagination token to stop returning results at. If not + * supplied, results continue up to limit or until there are no more events. + * @param direction The direction to search for according to MSC3715. + * @returns Resolves to the room relations. + */ + public async readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + limit?: number, + from?: string, + to?: string, + direction?: "f" | "b", + ): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC3869)) { + throw new Error( + "The read_relations action is not supported by the client.", + ); } - /** - * Opens a modal widget. - * @param {string} url The URL to the modal widget. - * @param {string} name The name of the widget. - * @param {IModalWidgetOpenRequestDataButton[]} buttons The buttons to have on the widget. - * @param {IModalWidgetCreateData} data Data to supply to the modal widget. - * @param {WidgetType} type The type of modal widget. - * @returns {Promise} Resolves when the modal widget has been opened. - */ - public openModalWidget( - url: string, - name: string, - buttons: IModalWidgetOpenRequestDataButton[] = [], - data: IModalWidgetCreateData = {}, - type: WidgetType = MatrixWidgetType.Custom, - ): Promise { - return this.transport - .send(WidgetApiFromWidgetAction.OpenModalWidget, { - type, - url, - name, - buttons, - data, - }) - .then(); + const data: IReadRelationsFromWidgetRequestData = { + event_id: eventId, + rel_type: relationType, + event_type: eventType, + room_id: roomId, + to, + from, + limit, + direction, + }; + + return this.transport.send< + IReadRelationsFromWidgetRequestData, + IReadRelationsFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC3869ReadRelations, data); + } + + public readStateEvents( + eventType: string, + limit?: number, + stateKey?: string, + roomIds?: (string | Symbols.AnyRoom)[], + ): Promise { + const data: IReadEventFromWidgetRequestData = { + type: eventType, + state_key: stateKey === undefined ? true : stateKey, + }; + if (limit !== undefined) { + data.limit = limit; } - - /** - * Closes the modal widget. The widget's session will be terminated shortly after. - * @param {IModalWidgetReturnData} data Optional data to close the modal widget with. - * @returns {Promise} Resolves when complete. - */ - public closeModalWidget(data: IModalWidgetReturnData = {}): Promise { - return this.transport.send(WidgetApiFromWidgetAction.CloseModalWidget, data).then(); + if (roomIds) { + if (roomIds.includes(Symbols.AnyRoom)) { + data.room_ids = Symbols.AnyRoom; + } else { + data.room_ids = roomIds; + } } - - public sendRoomEvent( - eventType: string, - content: unknown, - roomId?: string, - delay?: number, - parentDelayId?: string, - ): Promise { - return this.sendEvent(eventType, undefined, content, roomId, delay, parentDelayId); + return this.transport + .send< + IReadEventFromWidgetRequestData, + IReadEventFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC2876ReadEvents, data) + .then((r) => r.events); + } + + /** + * Sets a button as disabled or enabled on the modal widget. Buttons are enabled by default. + * @param {ModalButtonID} buttonId The button ID to enable/disable. + * @param {boolean} isEnabled Whether or not the button is enabled. + * @returns {Promise} Resolves when complete. + * @throws Throws if the button cannot be disabled, or the client refuses to disable the button. + */ + public setModalButtonEnabled( + buttonId: ModalButtonID, + isEnabled: boolean, + ): Promise { + if (buttonId === BuiltInModalButtonID.Close) { + throw new Error("The close button cannot be disabled"); } - - public sendStateEvent( - eventType: string, - stateKey: string, - content: unknown, - roomId?: string, - delay?: number, - parentDelayId?: string, - ): Promise { - return this.sendEvent(eventType, stateKey, content, roomId, delay, parentDelayId); + return this.transport + .send( + WidgetApiFromWidgetAction.SetModalButtonEnabled, + { + button: buttonId, + enabled: isEnabled, + }, + ) + .then(); + } + + /** + * Attempts to navigate the client to the given URI. This can only be called with Matrix URIs + * (currently only matrix.to, but in future a Matrix URI scheme will be defined). + * @param {string} uri The URI to navigate to. + * @returns {Promise} Resolves when complete. + * @throws Throws if the URI is invalid or cannot be processed. + * @deprecated This currently relies on an unstable MSC (MSC2931). + */ + public navigateTo(uri: string): Promise { + if (!uri || !uri.startsWith("https://matrix.to/#")) { + throw new Error("Invalid matrix.to URI"); } - private sendEvent( - eventType: string, - stateKey: string | undefined, - content: unknown, - roomId?: string, - delay?: number, - parentDelayId?: string, - ): Promise { - return this.transport.send( - WidgetApiFromWidgetAction.SendEvent, - { - type: eventType, - content, - ...(stateKey !== undefined && { state_key: stateKey }), - ...(roomId !== undefined && { room_id: roomId }), - ...(delay !== undefined && { delay }), - ...(parentDelayId !== undefined && { parent_delay_id: parentDelayId }), - }, + return this.transport + .send( + WidgetApiFromWidgetAction.MSC2931Navigate, + { uri }, + ) + .then(); + } + + /** + * Starts watching for TURN servers, yielding an initial set of credentials as soon as possible, + * and thereafter yielding new credentials whenever the previous ones expire. + * @yields {ITurnServer} The TURN server URIs and credentials currently available to the widget. + */ + public async *getTurnServers(): AsyncGenerator { + let setTurnServer: (server: ITurnServer) => void; + + const onUpdateTurnServers = async ( + ev: CustomEvent, + ): Promise => { + ev.preventDefault(); + setTurnServer(ev.detail.data); + await this.transport.reply( + ev.detail, + {}, + ); + }; + + // Start listening for updates before we even start watching, to catch + // TURN data that is sent immediately + this.on( + `action:${WidgetApiToWidgetAction.UpdateTurnServers}`, + onUpdateTurnServers, + ); + + // Only send the 'watch' action if we aren't already watching + if (this.turnServerWatchers === 0) { + try { + await this.transport.send( + WidgetApiFromWidgetAction.WatchTurnServers, + {}, ); - } - - /** - * @deprecated This currently relies on an unstable MSC (MSC4157). - */ - public updateDelayedEvent( - delayId: string, - action: UpdateDelayedEventAction, - ): Promise { - return this.transport.send( - WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - { - delay_id: delayId, - action, - }, + } catch (e) { + this.off( + `action:${WidgetApiToWidgetAction.UpdateTurnServers}`, + onUpdateTurnServers, ); + throw e; + } } + this.turnServerWatchers++; - /** - * Sends a to-device event. - * @param {string} eventType The type of events being sent. - * @param {boolean} encrypted Whether to encrypt the message contents. - * @param {Object} contentMap A map from user IDs to device IDs to message contents. - * @returns {Promise} Resolves when complete. - */ - public sendToDevice( - eventType: string, - encrypted: boolean, - contentMap: { [userId: string]: { [deviceId: string]: object } }, - ): Promise { - return this.transport.send( - WidgetApiFromWidgetAction.SendToDevice, - { type: eventType, encrypted, messages: contentMap }, + try { + // Watch for new data indefinitely (until this generator's return method is called) + while (true) { + yield await new Promise( + (resolve) => (setTurnServer = resolve), ); - } - - public readRoomAccountData(eventType: string, roomIds?: (string | Symbols.AnyRoom)[]): Promise { - const data: IReadEventFromWidgetRequestData = { type: eventType }; - - if (roomIds) { - if (roomIds.includes(Symbols.AnyRoom)) { - data.room_ids = Symbols.AnyRoom; - } else { - data.room_ids = roomIds; - } - } - return this.transport - .send< - IReadRoomAccountDataFromWidgetRequestData, - IReadRoomAccountDataFromWidgetResponseData - >(WidgetApiFromWidgetAction.BeeperReadRoomAccountData, data) - .then((r) => r.events); - } - - public readRoomEvents( - eventType: string, - limit?: number, - msgtype?: string, - roomIds?: (string | Symbols.AnyRoom)[], - since?: string | undefined, - ): Promise { - const data: IReadEventFromWidgetRequestData = { type: eventType, msgtype: msgtype }; - if (limit !== undefined) { - data.limit = limit; - } - if (roomIds) { - if (roomIds.includes(Symbols.AnyRoom)) { - data.room_ids = Symbols.AnyRoom; - } else { - data.room_ids = roomIds; - } - } - if (since) { - data.since = since; - } - return this.transport - .send< - IReadEventFromWidgetRequestData, - IReadEventFromWidgetResponseData - >(WidgetApiFromWidgetAction.MSC2876ReadEvents, data) - .then((r) => r.events); - } - - /** - * Reads all related events given a known eventId. - * @param eventId The id of the parent event to be read. - * @param roomId The room to look within. When undefined, the user's currently - * viewed room. - * @param relationType The relationship type of child events to search for. - * When undefined, all relations are returned. - * @param eventType The event type of child events to search for. When undefined, - * all related events are returned. - * @param limit The maximum number of events to retrieve per room. If not - * supplied, the server will apply a default limit. - * @param from The pagination token to start returning results from, as - * received from a previous call. If not supplied, results start at the most - * recent topological event known to the server. - * @param to The pagination token to stop returning results at. If not - * supplied, results continue up to limit or until there are no more events. - * @param direction The direction to search for according to MSC3715. - * @returns Resolves to the room relations. - */ - public async readEventRelations( - eventId: string, - roomId?: string, - relationType?: string, - eventType?: string, - limit?: number, - from?: string, - to?: string, - direction?: "f" | "b", - ): Promise { - const versions = await this.getClientVersions(); - if (!versions.includes(UnstableApiVersion.MSC3869)) { - throw new Error("The read_relations action is not supported by the client."); - } - - const data: IReadRelationsFromWidgetRequestData = { - event_id: eventId, - rel_type: relationType, - event_type: eventType, - room_id: roomId, - to, - from, - limit, - direction, - }; - - return this.transport.send( - WidgetApiFromWidgetAction.MSC3869ReadRelations, - data, + } + } finally { + // The loop was broken by the caller - clean up + this.off( + `action:${WidgetApiToWidgetAction.UpdateTurnServers}`, + onUpdateTurnServers, + ); + + // Since sending the 'unwatch' action will end updates for all other + // consumers, only send it if we're the only consumer remaining + this.turnServerWatchers--; + if (this.turnServerWatchers === 0) { + await this.transport.send( + WidgetApiFromWidgetAction.UnwatchTurnServers, + {}, ); + } } - - public readStateEvents( - eventType: string, - limit?: number, - stateKey?: string, - roomIds?: (string | Symbols.AnyRoom)[], - ): Promise { - const data: IReadEventFromWidgetRequestData = { - type: eventType, - state_key: stateKey === undefined ? true : stateKey, - }; - if (limit !== undefined) { - data.limit = limit; - } - if (roomIds) { - if (roomIds.includes(Symbols.AnyRoom)) { - data.room_ids = Symbols.AnyRoom; - } else { - data.room_ids = roomIds; - } - } - return this.transport - .send< - IReadEventFromWidgetRequestData, - IReadEventFromWidgetResponseData - >(WidgetApiFromWidgetAction.MSC2876ReadEvents, data) - .then((r) => r.events); - } - - /** - * Sets a button as disabled or enabled on the modal widget. Buttons are enabled by default. - * @param {ModalButtonID} buttonId The button ID to enable/disable. - * @param {boolean} isEnabled Whether or not the button is enabled. - * @returns {Promise} Resolves when complete. - * @throws Throws if the button cannot be disabled, or the client refuses to disable the button. - */ - public setModalButtonEnabled(buttonId: ModalButtonID, isEnabled: boolean): Promise { - if (buttonId === BuiltInModalButtonID.Close) { - throw new Error("The close button cannot be disabled"); - } - return this.transport - .send(WidgetApiFromWidgetAction.SetModalButtonEnabled, { - button: buttonId, - enabled: isEnabled, - }) - .then(); + } + + /** + * Search for users in the user directory. + * @param searchTerm The term to search for. + * @param limit The maximum number of results to return. If not supplied, the + * @returns Resolves to the search results. + */ + public async searchUserDirectory( + searchTerm: string, + limit?: number, + ): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC3973)) { + throw new Error( + "The user_directory_search action is not supported by the client.", + ); } - /** - * Attempts to navigate the client to the given URI. This can only be called with Matrix URIs - * (currently only matrix.to, but in future a Matrix URI scheme will be defined). - * @param {string} uri The URI to navigate to. - * @returns {Promise} Resolves when complete. - * @throws Throws if the URI is invalid or cannot be processed. - * @deprecated This currently relies on an unstable MSC (MSC2931). - */ - public navigateTo(uri: string): Promise { - if (!uri || !uri.startsWith("https://matrix.to/#")) { - throw new Error("Invalid matrix.to URI"); - } - - return this.transport - .send(WidgetApiFromWidgetAction.MSC2931Navigate, { uri }) - .then(); + const data: IUserDirectorySearchFromWidgetRequestData = { + search_term: searchTerm, + limit, + }; + + return this.transport.send< + IUserDirectorySearchFromWidgetRequestData, + IUserDirectorySearchFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, data); + } + + /** + * Get the config for the media repository. + * @returns Promise which resolves with an object containing the config. + */ + public async getMediaConfig(): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC4039)) { + throw new Error( + "The get_media_config action is not supported by the client.", + ); } - /** - * Starts watching for TURN servers, yielding an initial set of credentials as soon as possible, - * and thereafter yielding new credentials whenever the previous ones expire. - * @yields {ITurnServer} The TURN server URIs and credentials currently available to the widget. - */ - public async *getTurnServers(): AsyncGenerator { - let setTurnServer: (server: ITurnServer) => void; - - const onUpdateTurnServers = async (ev: CustomEvent): Promise => { - ev.preventDefault(); - setTurnServer(ev.detail.data); - await this.transport.reply(ev.detail, {}); - }; - - // Start listening for updates before we even start watching, to catch - // TURN data that is sent immediately - this.on(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); - - // Only send the 'watch' action if we aren't already watching - if (this.turnServerWatchers === 0) { - try { - await this.transport.send(WidgetApiFromWidgetAction.WatchTurnServers, {}); - } catch (e) { - this.off(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); - throw e; - } - } - this.turnServerWatchers++; - - try { - // Watch for new data indefinitely (until this generator's return method is called) - while (true) { - yield await new Promise((resolve) => (setTurnServer = resolve)); - } - } finally { - // The loop was broken by the caller - clean up - this.off(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); - - // Since sending the 'unwatch' action will end updates for all other - // consumers, only send it if we're the only consumer remaining - this.turnServerWatchers--; - if (this.turnServerWatchers === 0) { - await this.transport.send(WidgetApiFromWidgetAction.UnwatchTurnServers, {}); - } - } + const data: IGetMediaConfigActionFromWidgetRequestData = {}; + + return this.transport.send< + IGetMediaConfigActionFromWidgetRequestData, + IGetMediaConfigActionFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, data); + } + + /** + * Upload a file to the media repository on the homeserver. + * @param file - The object to upload. Something that can be sent to + * XMLHttpRequest.send (typically a File). + * @returns Resolves to the location of the uploaded file. + */ + public async uploadFile( + file: XMLHttpRequestBodyInit, + ): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC4039)) { + throw new Error("The upload_file action is not supported by the client."); } - /** - * Search for users in the user directory. - * @param searchTerm The term to search for. - * @param limit The maximum number of results to return. If not supplied, the - * @returns Resolves to the search results. - */ - public async searchUserDirectory( - searchTerm: string, - limit?: number, - ): Promise { - const versions = await this.getClientVersions(); - if (!versions.includes(UnstableApiVersion.MSC3973)) { - throw new Error("The user_directory_search action is not supported by the client."); - } - - const data: IUserDirectorySearchFromWidgetRequestData = { - search_term: searchTerm, - limit, - }; - - return this.transport.send< - IUserDirectorySearchFromWidgetRequestData, - IUserDirectorySearchFromWidgetResponseData - >(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, data); + const data: IUploadFileActionFromWidgetRequestData = { + file, + }; + + return this.transport.send< + IUploadFileActionFromWidgetRequestData, + IUploadFileActionFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC4039UploadFileAction, data); + } + + /** + * Download a file from the media repository on the homeserver. + * @param contentUri - MXC URI of the file to download. + * @returns Resolves to the contents of the file. + */ + public async downloadFile( + contentUri: string, + ): Promise { + const versions = await this.getClientVersions(); + if (!versions.includes(UnstableApiVersion.MSC4039)) { + throw new Error( + "The download_file action is not supported by the client.", + ); } - /** - * Get the config for the media repository. - * @returns Promise which resolves with an object containing the config. - */ - public async getMediaConfig(): Promise { - const versions = await this.getClientVersions(); - if (!versions.includes(UnstableApiVersion.MSC4039)) { - throw new Error("The get_media_config action is not supported by the client."); - } - - const data: IGetMediaConfigActionFromWidgetRequestData = {}; - - return this.transport.send< - IGetMediaConfigActionFromWidgetRequestData, - IGetMediaConfigActionFromWidgetResponseData - >(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, data); - } - - /** - * Upload a file to the media repository on the homeserver. - * @param file - The object to upload. Something that can be sent to - * XMLHttpRequest.send (typically a File). - * @returns Resolves to the location of the uploaded file. - */ - public async uploadFile(file: XMLHttpRequestBodyInit): Promise { - const versions = await this.getClientVersions(); - if (!versions.includes(UnstableApiVersion.MSC4039)) { - throw new Error("The upload_file action is not supported by the client."); - } - - const data: IUploadFileActionFromWidgetRequestData = { - file, - }; - - return this.transport.send( - WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data, - ); - } - - /** - * Download a file from the media repository on the homeserver. - * @param contentUri - MXC URI of the file to download. - * @returns Resolves to the contents of the file. - */ - public async downloadFile(contentUri: string): Promise { - const versions = await this.getClientVersions(); - if (!versions.includes(UnstableApiVersion.MSC4039)) { - throw new Error("The download_file action is not supported by the client."); - } - - const data: IDownloadFileActionFromWidgetRequestData = { - content_uri: contentUri, - }; - - return this.transport.send( - WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data, - ); - } - - /** - * Starts the communication channel. This should be done early to ensure - * that messages are not missed. Communication can only be stopped by the client. - */ - public start(): void { - this.transport.start(); - this.getClientVersions().then((v) => { - if (v.includes(UnstableApiVersion.MSC2974)) { - this.supportsMSC2974Renegotiate = true; - } - }); + const data: IDownloadFileActionFromWidgetRequestData = { + content_uri: contentUri, + }; + + return this.transport.send< + IDownloadFileActionFromWidgetRequestData, + IDownloadFileActionFromWidgetResponseData + >(WidgetApiFromWidgetAction.MSC4039DownloadFileAction, data); + } + + /** + * Starts the communication channel. This should be done early to ensure + * that messages are not missed. Communication can only be stopped by the client. + */ + public start(): void { + this.transport.start(); + this.getClientVersions().then((v) => { + if (v.includes(UnstableApiVersion.MSC2974)) { + this.supportsMSC2974Renegotiate = true; + } + }); + } + + private handleMessage( + ev: CustomEvent, + ): void | Promise { + const actionEv = new CustomEvent(`action:${ev.detail.action}`, { + detail: ev.detail, + cancelable: true, + }); + this.emit(`action:${ev.detail.action}`, actionEv); + if (!actionEv.defaultPrevented) { + switch (ev.detail.action) { + case WidgetApiToWidgetAction.SupportedApiVersions: + return this.replyVersions(ev.detail); + case WidgetApiToWidgetAction.Capabilities: + return this.handleCapabilities(ev.detail); + case WidgetApiToWidgetAction.UpdateVisibility: + return this.transport.reply( + ev.detail, + {}, + ); // ack to avoid error spam + case WidgetApiToWidgetAction.NotifyCapabilities: + return this.transport.reply( + ev.detail, + {}, + ); // ack to avoid error spam + default: + return this.transport.reply(ev.detail, { + error: { + message: "Unknown or unsupported action: " + ev.detail.action, + }, + }); + } } + } - private handleMessage(ev: CustomEvent): void | Promise { - const actionEv = new CustomEvent(`action:${ev.detail.action}`, { - detail: ev.detail, - cancelable: true, - }); - this.emit(`action:${ev.detail.action}`, actionEv); - if (!actionEv.defaultPrevented) { - switch (ev.detail.action) { - case WidgetApiToWidgetAction.SupportedApiVersions: - return this.replyVersions(ev.detail); - case WidgetApiToWidgetAction.Capabilities: - return this.handleCapabilities(ev.detail); - case WidgetApiToWidgetAction.UpdateVisibility: - return this.transport.reply(ev.detail, {}); // ack to avoid error spam - case WidgetApiToWidgetAction.NotifyCapabilities: - return this.transport.reply(ev.detail, {}); // ack to avoid error spam - default: - return this.transport.reply(ev.detail, { - error: { - message: "Unknown or unsupported action: " + ev.detail.action, - }, - }); - } - } - } + private replyVersions(request: ISupportedVersionsActionRequest): void { + this.transport.reply(request, { + supported_versions: CurrentApiVersions, + }); + } - private replyVersions(request: ISupportedVersionsActionRequest): void { - this.transport.reply(request, { - supported_versions: CurrentApiVersions, - }); + public getClientVersions(): Promise { + if (Array.isArray(this.cachedClientVersions)) { + return Promise.resolve(this.cachedClientVersions); } - public getClientVersions(): Promise { - if (Array.isArray(this.cachedClientVersions)) { - return Promise.resolve(this.cachedClientVersions); - } - - return this.transport - .send( - WidgetApiFromWidgetAction.SupportedApiVersions, - {}, - ) - .then((r) => { - this.cachedClientVersions = r.supported_versions; - return r.supported_versions; - }) - .catch((e) => { - console.warn("non-fatal error getting supported client versions: ", e); - return []; - }); + return this.transport + .send( + WidgetApiFromWidgetAction.SupportedApiVersions, + {}, + ) + .then((r) => { + this.cachedClientVersions = r.supported_versions; + return r.supported_versions; + }) + .catch((e) => { + console.warn("non-fatal error getting supported client versions: ", e); + return []; + }); + } + + private handleCapabilities( + request: ICapabilitiesActionRequest, + ): void | Promise { + if (this.capabilitiesFinished) { + return this.transport.reply(request, { + error: { + message: "Capability negotiation already completed", + }, + }); } - private handleCapabilities(request: ICapabilitiesActionRequest): void | Promise { - if (this.capabilitiesFinished) { - return this.transport.reply(request, { - error: { - message: "Capability negotiation already completed", - }, - }); - } - - // See if we can expect a capabilities notification or not - return this.getClientVersions().then((v) => { - if (v.includes(UnstableApiVersion.MSC2871)) { - this.once( - `action:${WidgetApiToWidgetAction.NotifyCapabilities}`, - (ev: CustomEvent) => { - this.approvedCapabilities = ev.detail.data.approved; - this.emit("ready"); - }, - ); - } else { - // if we can't expect notification, we're as done as we can be - this.emit("ready"); - } - - // in either case, reply to that capabilities request - this.capabilitiesFinished = true; - return this.transport.reply(request, { - capabilities: this.requestedCapabilities, - }); - }); - } + // See if we can expect a capabilities notification or not + return this.getClientVersions().then((v) => { + if (v.includes(UnstableApiVersion.MSC2871)) { + this.once( + `action:${WidgetApiToWidgetAction.NotifyCapabilities}`, + (ev: CustomEvent) => { + this.approvedCapabilities = ev.detail.data.approved; + this.emit("ready"); + }, + ); + } else { + // if we can't expect notification, we're as done as we can be + this.emit("ready"); + } + + // in either case, reply to that capabilities request + this.capabilitiesFinished = true; + return this.transport.reply(request, { + capabilities: this.requestedCapabilities, + }); + }); + } } diff --git a/src/interfaces/SetModalButtonEnabledAction.ts b/src/interfaces/SetModalButtonEnabledAction.ts index 5702e8c..ada05c6 100644 --- a/src/interfaces/SetModalButtonEnabledAction.ts +++ b/src/interfaces/SetModalButtonEnabledAction.ts @@ -19,16 +19,18 @@ import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; import { IWidgetApiAcknowledgeResponseData } from "./IWidgetApiResponse"; import { ModalButtonID } from "./ModalWidgetActions"; -export interface ISetModalButtonEnabledActionRequestData extends IWidgetApiRequestData { - enabled: boolean; - button: ModalButtonID; +export interface ISetModalButtonEnabledActionRequestData + extends IWidgetApiRequestData { + enabled: boolean; + button: ModalButtonID; } export interface ISetModalButtonEnabledActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.SetModalButtonEnabled; - data: ISetModalButtonEnabledActionRequestData; + action: WidgetApiFromWidgetAction.SetModalButtonEnabled; + data: ISetModalButtonEnabledActionRequestData; } -export interface ISetModalButtonEnabledActionResponse extends ISetModalButtonEnabledActionRequest { - response: IWidgetApiAcknowledgeResponseData; +export interface ISetModalButtonEnabledActionResponse + extends ISetModalButtonEnabledActionRequest { + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/StickerAction.ts b/src/interfaces/StickerAction.ts index c7293e3..cd401c2 100644 --- a/src/interfaces/StickerAction.ts +++ b/src/interfaces/StickerAction.ts @@ -19,31 +19,31 @@ import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; import { IWidgetApiAcknowledgeResponseData } from "./IWidgetApiResponse"; export interface IStickerActionRequestData extends IWidgetApiRequestData { - name: string; - description?: string; - content: { - url: string; - info?: { - h?: number; - w?: number; - mimetype?: string; - size?: number; - thumbnail_info?: { - // eslint-disable-line camelcase - h?: number; - w?: number; - mimetype?: string; - size?: number; - }; - }; + name: string; + description?: string; + content: { + url: string; + info?: { + h?: number; + w?: number; + mimetype?: string; + size?: number; + thumbnail_info?: { + // eslint-disable-line camelcase + h?: number; + w?: number; + mimetype?: string; + size?: number; + }; }; + }; } export interface IStickerActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.SendSticker; - data: IStickerActionRequestData; + action: WidgetApiFromWidgetAction.SendSticker; + data: IStickerActionRequestData; } export interface IStickerActionResponse extends IStickerActionRequest { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/StickyAction.ts b/src/interfaces/StickyAction.ts index 7d49f02..a9726b8 100644 --- a/src/interfaces/StickyAction.ts +++ b/src/interfaces/StickyAction.ts @@ -19,18 +19,18 @@ import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; export interface IStickyActionRequestData extends IWidgetApiRequestData { - value: boolean; + value: boolean; } export interface IStickyActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.UpdateAlwaysOnScreen; - data: IStickyActionRequestData; + action: WidgetApiFromWidgetAction.UpdateAlwaysOnScreen; + data: IStickyActionRequestData; } export interface IStickyActionResponseData extends IWidgetApiResponseData { - success: boolean; + success: boolean; } export interface IStickyActionResponse extends IStickyActionRequest { - response: IStickyActionResponseData; + response: IStickyActionResponseData; } diff --git a/src/interfaces/SupportedVersionsAction.ts b/src/interfaces/SupportedVersionsAction.ts index 8486ebc..ea630e1 100644 --- a/src/interfaces/SupportedVersionsAction.ts +++ b/src/interfaces/SupportedVersionsAction.ts @@ -14,20 +14,30 @@ * limitations under the License. */ -import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./IWidgetApiRequest"; -import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; +import { + IWidgetApiRequest, + IWidgetApiRequestEmptyData, +} from "./IWidgetApiRequest"; +import { + WidgetApiFromWidgetAction, + WidgetApiToWidgetAction, +} from "./WidgetApiAction"; import { ApiVersion } from "./ApiVersion"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; export interface ISupportedVersionsActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.SupportedApiVersions | WidgetApiToWidgetAction.SupportedApiVersions; - data: IWidgetApiRequestEmptyData; + action: + | WidgetApiFromWidgetAction.SupportedApiVersions + | WidgetApiToWidgetAction.SupportedApiVersions; + data: IWidgetApiRequestEmptyData; } -export interface ISupportedVersionsActionResponseData extends IWidgetApiResponseData { - supported_versions: ApiVersion[]; // eslint-disable-line camelcase +export interface ISupportedVersionsActionResponseData + extends IWidgetApiResponseData { + supported_versions: ApiVersion[]; // eslint-disable-line camelcase } -export interface ISupportedVersionsActionResponse extends ISupportedVersionsActionRequest { - response: ISupportedVersionsActionResponseData; +export interface ISupportedVersionsActionResponse + extends ISupportedVersionsActionRequest { + response: ISupportedVersionsActionResponseData; } diff --git a/src/interfaces/ThemeChangeAction.ts b/src/interfaces/ThemeChangeAction.ts index 292f58e..9766e20 100644 --- a/src/interfaces/ThemeChangeAction.ts +++ b/src/interfaces/ThemeChangeAction.ts @@ -19,14 +19,14 @@ import { WidgetApiToWidgetAction } from "./WidgetApiAction"; import { IWidgetApiAcknowledgeResponseData } from "./IWidgetApiResponse"; export interface IThemeChangeActionRequestData extends IWidgetApiRequestData { - // The format of a theme is deliberately unstandardized + // The format of a theme is deliberately unstandardized } export interface IThemeChangeActionRequest extends IWidgetApiRequest { - action: WidgetApiToWidgetAction.ThemeChange; - data: IThemeChangeActionRequestData; + action: WidgetApiToWidgetAction.ThemeChange; + data: IThemeChangeActionRequestData; } export interface IThemeChangeActionResponse extends IThemeChangeActionRequest { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/TurnServerActions.ts b/src/interfaces/TurnServerActions.ts index 36f664a..2bed7f1 100644 --- a/src/interfaces/TurnServerActions.ts +++ b/src/interfaces/TurnServerActions.ts @@ -14,41 +14,53 @@ * limitations under the License. */ -import { IWidgetApiRequest, IWidgetApiRequestData, IWidgetApiRequestEmptyData } from "./IWidgetApiRequest"; -import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; -import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponse } from "./IWidgetApiResponse"; +import { + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiRequestEmptyData, +} from "./IWidgetApiRequest"; +import { + WidgetApiFromWidgetAction, + WidgetApiToWidgetAction, +} from "./WidgetApiAction"; +import { + IWidgetApiAcknowledgeResponseData, + IWidgetApiResponse, +} from "./IWidgetApiResponse"; export interface ITurnServer { - uris: string[]; - username: string; - password: string; + uris: string[]; + username: string; + password: string; } export interface IWatchTurnServersRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.WatchTurnServers; - data: IWidgetApiRequestEmptyData; + action: WidgetApiFromWidgetAction.WatchTurnServers; + data: IWidgetApiRequestEmptyData; } export interface IWatchTurnServersResponse extends IWidgetApiResponse { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } export interface IUnwatchTurnServersRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.UnwatchTurnServers; - data: IWidgetApiRequestEmptyData; + action: WidgetApiFromWidgetAction.UnwatchTurnServers; + data: IWidgetApiRequestEmptyData; } export interface IUnwatchTurnServersResponse extends IWidgetApiResponse { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } -export interface IUpdateTurnServersRequestData extends IWidgetApiRequestData, ITurnServer {} +export interface IUpdateTurnServersRequestData + extends IWidgetApiRequestData, + ITurnServer {} export interface IUpdateTurnServersRequest extends IWidgetApiRequest { - action: WidgetApiToWidgetAction.UpdateTurnServers; - data: IUpdateTurnServersRequestData; + action: WidgetApiToWidgetAction.UpdateTurnServers; + data: IUpdateTurnServersRequestData; } export interface IUpdateTurnServersResponse extends IWidgetApiResponse { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/UpdateDelayedEventAction.ts b/src/interfaces/UpdateDelayedEventAction.ts index 9ba0179..92ba659 100644 --- a/src/interfaces/UpdateDelayedEventAction.ts +++ b/src/interfaces/UpdateDelayedEventAction.ts @@ -19,25 +19,29 @@ import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; export enum UpdateDelayedEventAction { - Cancel = "cancel", - Restart = "restart", - Send = "send", + Cancel = "cancel", + Restart = "restart", + Send = "send", } -export interface IUpdateDelayedEventFromWidgetRequestData extends IWidgetApiRequestData { - delay_id: string; // eslint-disable-line camelcase - action: UpdateDelayedEventAction; +export interface IUpdateDelayedEventFromWidgetRequestData + extends IWidgetApiRequestData { + delay_id: string; // eslint-disable-line camelcase + action: UpdateDelayedEventAction; } -export interface IUpdateDelayedEventFromWidgetActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent; - data: IUpdateDelayedEventFromWidgetRequestData; +export interface IUpdateDelayedEventFromWidgetActionRequest + extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent; + data: IUpdateDelayedEventFromWidgetRequestData; } -export interface IUpdateDelayedEventFromWidgetResponseData extends IWidgetApiResponseData { - // nothing +export interface IUpdateDelayedEventFromWidgetResponseData + extends IWidgetApiResponseData { + // nothing } -export interface IUpdateDelayedEventFromWidgetActionResponse extends IUpdateDelayedEventFromWidgetActionRequest { - response: IUpdateDelayedEventFromWidgetResponseData; +export interface IUpdateDelayedEventFromWidgetActionResponse + extends IUpdateDelayedEventFromWidgetActionRequest { + response: IUpdateDelayedEventFromWidgetResponseData; } diff --git a/src/interfaces/UpdateStateAction.ts b/src/interfaces/UpdateStateAction.ts index c497caf..1bbdac9 100644 --- a/src/interfaces/UpdateStateAction.ts +++ b/src/interfaces/UpdateStateAction.ts @@ -20,18 +20,20 @@ import { IWidgetApiResponseData } from "./IWidgetApiResponse"; import { IRoomEvent } from "./IRoomEvent"; export interface IUpdateStateToWidgetRequestData extends IWidgetApiRequestData { - state: IRoomEvent[]; + state: IRoomEvent[]; } export interface IUpdateStateToWidgetActionRequest extends IWidgetApiRequest { - action: WidgetApiToWidgetAction.UpdateState; - data: IUpdateStateToWidgetRequestData; + action: WidgetApiToWidgetAction.UpdateState; + data: IUpdateStateToWidgetRequestData; } -export interface IUpdateStateToWidgetResponseData extends IWidgetApiResponseData { - // nothing +export interface IUpdateStateToWidgetResponseData + extends IWidgetApiResponseData { + // nothing } -export interface IUpdateStateToWidgetActionResponse extends IUpdateStateToWidgetActionRequest { - response: IUpdateStateToWidgetResponseData; +export interface IUpdateStateToWidgetActionResponse + extends IUpdateStateToWidgetActionRequest { + response: IUpdateStateToWidgetResponseData; } diff --git a/src/interfaces/UploadFileAction.ts b/src/interfaces/UploadFileAction.ts index 9d120b6..86d529f 100644 --- a/src/interfaces/UploadFileAction.ts +++ b/src/interfaces/UploadFileAction.ts @@ -18,19 +18,23 @@ import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; -export interface IUploadFileActionFromWidgetRequestData extends IWidgetApiRequestData { - file: XMLHttpRequestBodyInit; +export interface IUploadFileActionFromWidgetRequestData + extends IWidgetApiRequestData { + file: XMLHttpRequestBodyInit; } -export interface IUploadFileActionFromWidgetActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction; - data: IUploadFileActionFromWidgetRequestData; +export interface IUploadFileActionFromWidgetActionRequest + extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction; + data: IUploadFileActionFromWidgetRequestData; } -export interface IUploadFileActionFromWidgetResponseData extends IWidgetApiResponseData { - content_uri: string; // eslint-disable-line camelcase +export interface IUploadFileActionFromWidgetResponseData + extends IWidgetApiResponseData { + content_uri: string; // eslint-disable-line camelcase } -export interface IUploadFileActionFromWidgetActionResponse extends IUploadFileActionFromWidgetActionRequest { - response: IUploadFileActionFromWidgetResponseData; +export interface IUploadFileActionFromWidgetActionResponse + extends IUploadFileActionFromWidgetActionRequest { + response: IUploadFileActionFromWidgetResponseData; } diff --git a/src/interfaces/UserDirectorySearchAction.ts b/src/interfaces/UserDirectorySearchAction.ts index fb900cc..9747818 100644 --- a/src/interfaces/UserDirectorySearchAction.ts +++ b/src/interfaces/UserDirectorySearchAction.ts @@ -18,25 +18,29 @@ import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; -export interface IUserDirectorySearchFromWidgetRequestData extends IWidgetApiRequestData { - search_term: string; // eslint-disable-line camelcase - limit?: number; +export interface IUserDirectorySearchFromWidgetRequestData + extends IWidgetApiRequestData { + search_term: string; // eslint-disable-line camelcase + limit?: number; } -export interface IUserDirectorySearchFromWidgetActionRequest extends IWidgetApiRequest { - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch; - data: IUserDirectorySearchFromWidgetRequestData; +export interface IUserDirectorySearchFromWidgetActionRequest + extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch; + data: IUserDirectorySearchFromWidgetRequestData; } -export interface IUserDirectorySearchFromWidgetResponseData extends IWidgetApiResponseData { - limited: boolean; - results: Array<{ - user_id: string; // eslint-disable-line camelcase - display_name?: string; // eslint-disable-line camelcase - avatar_url?: string; // eslint-disable-line camelcase - }>; +export interface IUserDirectorySearchFromWidgetResponseData + extends IWidgetApiResponseData { + limited: boolean; + results: Array<{ + user_id: string; // eslint-disable-line camelcase + display_name?: string; // eslint-disable-line camelcase + avatar_url?: string; // eslint-disable-line camelcase + }>; } -export interface IUserDirectorySearchFromWidgetActionResponse extends IUserDirectorySearchFromWidgetActionRequest { - response: IUserDirectorySearchFromWidgetResponseData; +export interface IUserDirectorySearchFromWidgetActionResponse + extends IUserDirectorySearchFromWidgetActionRequest { + response: IUserDirectorySearchFromWidgetResponseData; } diff --git a/src/interfaces/VisibilityAction.ts b/src/interfaces/VisibilityAction.ts index 55aa53f..fdb6454 100644 --- a/src/interfaces/VisibilityAction.ts +++ b/src/interfaces/VisibilityAction.ts @@ -19,14 +19,14 @@ import { WidgetApiToWidgetAction } from "./WidgetApiAction"; import { IWidgetApiAcknowledgeResponseData } from "./IWidgetApiResponse"; export interface IVisibilityActionRequestData extends IWidgetApiRequestData { - visible: boolean; + visible: boolean; } export interface IVisibilityActionRequest extends IWidgetApiRequest { - action: WidgetApiToWidgetAction.UpdateVisibility; - data: IVisibilityActionRequestData; + action: WidgetApiToWidgetAction.UpdateVisibility; + data: IVisibilityActionRequestData; } export interface IVisibilityActionResponse extends IVisibilityActionRequest { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index 2f0bcf5..71e12f8 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -15,83 +15,86 @@ */ export enum WidgetApiToWidgetAction { - SupportedApiVersions = "supported_api_versions", - Capabilities = "capabilities", - NotifyCapabilities = "notify_capabilities", - ThemeChange = "theme_change", - LanguageChange = "language_change", - TakeScreenshot = "screenshot", - UpdateVisibility = "visibility", - OpenIDCredentials = "openid_credentials", - WidgetConfig = "widget_config", - CloseModalWidget = "close_modal", - ButtonClicked = "button_clicked", - SendEvent = "send_event", - SendToDevice = "send_to_device", - UpdateState = "update_state", - UpdateTurnServers = "update_turn_servers", + SupportedApiVersions = "supported_api_versions", + Capabilities = "capabilities", + NotifyCapabilities = "notify_capabilities", + ThemeChange = "theme_change", + LanguageChange = "language_change", + TakeScreenshot = "screenshot", + UpdateVisibility = "visibility", + OpenIDCredentials = "openid_credentials", + WidgetConfig = "widget_config", + CloseModalWidget = "close_modal", + ButtonClicked = "button_clicked", + SendEvent = "send_event", + SendToDevice = "send_to_device", + UpdateState = "update_state", + UpdateTurnServers = "update_turn_servers", } export enum WidgetApiFromWidgetAction { - SupportedApiVersions = "supported_api_versions", - ContentLoaded = "content_loaded", - SendSticker = "m.sticker", - UpdateAlwaysOnScreen = "set_always_on_screen", - GetOpenIDCredentials = "get_openid", - CloseModalWidget = "close_modal", - OpenModalWidget = "open_modal", - SetModalButtonEnabled = "set_button_enabled", - SendEvent = "send_event", - SendToDevice = "send_to_device", - WatchTurnServers = "watch_turn_servers", - UnwatchTurnServers = "unwatch_turn_servers", + SupportedApiVersions = "supported_api_versions", + ContentLoaded = "content_loaded", + SendSticker = "m.sticker", + UpdateAlwaysOnScreen = "set_always_on_screen", + GetOpenIDCredentials = "get_openid", + CloseModalWidget = "close_modal", + OpenModalWidget = "open_modal", + SetModalButtonEnabled = "set_button_enabled", + SendEvent = "send_event", + SendToDevice = "send_to_device", + WatchTurnServers = "watch_turn_servers", + UnwatchTurnServers = "unwatch_turn_servers", - BeeperReadRoomAccountData = "com.beeper.read_room_account_data", + BeeperReadRoomAccountData = "com.beeper.read_room_account_data", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC2876ReadEvents = "org.matrix.msc2876.read_events", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC2876ReadEvents = "org.matrix.msc2876.read_events", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC2931Navigate = "org.matrix.msc2931.navigate", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC2931Navigate = "org.matrix.msc2931.navigate", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC2974RenegotiateCapabilities = "org.matrix.msc2974.request_capabilities", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC2974RenegotiateCapabilities = "org.matrix.msc2974.request_capabilities", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC3869ReadRelations = "org.matrix.msc3869.read_relations", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC3869ReadRelations = "org.matrix.msc3869.read_relations", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC4039GetMediaConfigAction = "org.matrix.msc4039.get_media_config", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4039GetMediaConfigAction = "org.matrix.msc4039.get_media_config", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC4039UploadFileAction = "org.matrix.msc4039.upload_file", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4039UploadFileAction = "org.matrix.msc4039.upload_file", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC4039DownloadFileAction = "org.matrix.msc4039.download_file", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4039DownloadFileAction = "org.matrix.msc4039.download_file", - /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. - */ - MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", + /** + * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", } -export type WidgetApiAction = WidgetApiToWidgetAction | WidgetApiFromWidgetAction | string; +export type WidgetApiAction = + | WidgetApiToWidgetAction + | WidgetApiFromWidgetAction + | string; diff --git a/src/interfaces/WidgetApiDirection.ts b/src/interfaces/WidgetApiDirection.ts index e11e144..6f9b875 100644 --- a/src/interfaces/WidgetApiDirection.ts +++ b/src/interfaces/WidgetApiDirection.ts @@ -15,16 +15,16 @@ */ export enum WidgetApiDirection { - ToWidget = "toWidget", - FromWidget = "fromWidget", + ToWidget = "toWidget", + FromWidget = "fromWidget", } export function invertedDirection(dir: WidgetApiDirection): WidgetApiDirection { - if (dir === WidgetApiDirection.ToWidget) { - return WidgetApiDirection.FromWidget; - } else if (dir === WidgetApiDirection.FromWidget) { - return WidgetApiDirection.ToWidget; - } else { - throw new Error("Invalid direction"); - } + if (dir === WidgetApiDirection.ToWidget) { + return WidgetApiDirection.FromWidget; + } else if (dir === WidgetApiDirection.FromWidget) { + return WidgetApiDirection.ToWidget; + } else { + throw new Error("Invalid direction"); + } } diff --git a/src/interfaces/WidgetConfigAction.ts b/src/interfaces/WidgetConfigAction.ts index b10314c..4989a7b 100644 --- a/src/interfaces/WidgetConfigAction.ts +++ b/src/interfaces/WidgetConfigAction.ts @@ -16,14 +16,17 @@ import { IWidgetApiRequest } from "./IWidgetApiRequest"; import { WidgetApiToWidgetAction } from "./WidgetApiAction"; -import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponse } from "./IWidgetApiResponse"; +import { + IWidgetApiAcknowledgeResponseData, + IWidgetApiResponse, +} from "./IWidgetApiResponse"; import { IModalWidgetOpenRequestData } from "./ModalWidgetActions"; export interface IWidgetConfigRequest extends IWidgetApiRequest { - action: WidgetApiToWidgetAction.WidgetConfig; - data: IModalWidgetOpenRequestData; + action: WidgetApiToWidgetAction.WidgetConfig; + data: IModalWidgetOpenRequestData; } export interface IWidgetConfigResponse extends IWidgetApiResponse { - response: IWidgetApiAcknowledgeResponseData; + response: IWidgetApiAcknowledgeResponseData; } diff --git a/src/interfaces/WidgetKind.ts b/src/interfaces/WidgetKind.ts index 374e198..8c79b22 100644 --- a/src/interfaces/WidgetKind.ts +++ b/src/interfaces/WidgetKind.ts @@ -15,7 +15,7 @@ */ export enum WidgetKind { - Room = "room", - Account = "account", - Modal = "modal", + Room = "room", + Account = "account", + Modal = "modal", } diff --git a/src/interfaces/WidgetType.ts b/src/interfaces/WidgetType.ts index d6b3e33..38ad5c4 100644 --- a/src/interfaces/WidgetType.ts +++ b/src/interfaces/WidgetType.ts @@ -15,9 +15,9 @@ */ export enum MatrixWidgetType { - Custom = "m.custom", - JitsiMeet = "m.jitsi", - Stickerpicker = "m.stickerpicker", + Custom = "m.custom", + JitsiMeet = "m.jitsi", + Stickerpicker = "m.stickerpicker", } export type WidgetType = MatrixWidgetType | string; diff --git a/src/models/Widget.ts b/src/models/Widget.ts index 0b66452..d1f340c 100644 --- a/src/models/Widget.ts +++ b/src/models/Widget.ts @@ -22,88 +22,88 @@ import { ITemplateParams, runTemplate } from ".."; * Represents the barest form of widget. */ export class Widget { - public constructor(private definition: IWidget) { - if (!this.definition) throw new Error("Definition is required"); + public constructor(private definition: IWidget) { + if (!this.definition) throw new Error("Definition is required"); - assertPresent(definition, "id"); - assertPresent(definition, "creatorUserId"); - assertPresent(definition, "type"); - assertPresent(definition, "url"); - } + assertPresent(definition, "id"); + assertPresent(definition, "creatorUserId"); + assertPresent(definition, "type"); + assertPresent(definition, "url"); + } - /** - * The user ID who created the widget. - */ - public get creatorUserId(): string { - return this.definition.creatorUserId; - } + /** + * The user ID who created the widget. + */ + public get creatorUserId(): string { + return this.definition.creatorUserId; + } - /** - * The type of widget. - */ - public get type(): WidgetType { - return this.definition.type; - } + /** + * The type of widget. + */ + public get type(): WidgetType { + return this.definition.type; + } - /** - * The ID of the widget. - */ - public get id(): string { - return this.definition.id; - } + /** + * The ID of the widget. + */ + public get id(): string { + return this.definition.id; + } - /** - * The name of the widget, or null if not set. - */ - public get name(): string | null { - return this.definition.name || null; - } + /** + * The name of the widget, or null if not set. + */ + public get name(): string | null { + return this.definition.name || null; + } - /** - * The title for the widget, or null if not set. - */ - public get title(): string | null { - return this.rawData.title || null; - } + /** + * The title for the widget, or null if not set. + */ + public get title(): string | null { + return this.rawData.title || null; + } - /** - * The templated URL for the widget. - */ - public get templateUrl(): string { - return this.definition.url; - } + /** + * The templated URL for the widget. + */ + public get templateUrl(): string { + return this.definition.url; + } - /** - * The origin for this widget. - */ - public get origin(): string { - return new URL(this.templateUrl).origin; - } + /** + * The origin for this widget. + */ + public get origin(): string { + return new URL(this.templateUrl).origin; + } - /** - * Whether or not the client should wait for the iframe to load. Defaults - * to true. - */ - public get waitForIframeLoad(): boolean { - if (this.definition.waitForIframeLoad === false) return false; - if (this.definition.waitForIframeLoad === true) return true; - return true; // default true - } + /** + * Whether or not the client should wait for the iframe to load. Defaults + * to true. + */ + public get waitForIframeLoad(): boolean { + if (this.definition.waitForIframeLoad === false) return false; + if (this.definition.waitForIframeLoad === true) return true; + return true; // default true + } - /** - * The raw data for the widget. This will always be defined, though - * may be empty. - */ - public get rawData(): IWidgetData { - return this.definition.data || {}; - } + /** + * The raw data for the widget. This will always be defined, though + * may be empty. + */ + public get rawData(): IWidgetData { + return this.definition.data || {}; + } - /** - * Gets a complete widget URL for the client to render. - * @param {ITemplateParams} params The template parameters. - * @returns {string} A templated URL. - */ - public getCompleteUrl(params: ITemplateParams): string { - return runTemplate(this.templateUrl, this.definition, params); - } + /** + * Gets a complete widget URL for the client to render. + * @param {ITemplateParams} params The template parameters. + * @returns {string} A templated URL. + */ + public getCompleteUrl(params: ITemplateParams): string { + return runTemplate(this.templateUrl, this.definition, params); + } } diff --git a/src/models/WidgetEventCapability.ts b/src/models/WidgetEventCapability.ts index 1190606..8655180 100644 --- a/src/models/WidgetEventCapability.ts +++ b/src/models/WidgetEventCapability.ts @@ -17,208 +17,255 @@ import { Capability } from ".."; export enum EventKind { - Event = "event", - State = "state_event", - ToDevice = "to_device", - RoomAccount = "room_account", + Event = "event", + State = "state_event", + ToDevice = "to_device", + RoomAccount = "room_account", } export enum EventDirection { - Send = "send", - Receive = "receive", + Send = "send", + Receive = "receive", } export class WidgetEventCapability { - private constructor( - public readonly direction: EventDirection, - public readonly eventType: string, - public readonly kind: EventKind, - public readonly keyStr: string | null, - public readonly raw: string, - ) {} - - public matchesAsStateEvent(direction: EventDirection, eventType: string, stateKey: string | null): boolean { - if (this.kind !== EventKind.State) return false; // not a state event - if (this.direction !== direction) return false; // direction mismatch - if (this.eventType !== eventType) return false; // event type mismatch - if (this.keyStr === null) return true; // all state keys are allowed - if (this.keyStr === stateKey) return true; // this state key is allowed - - // Default not allowed - return false; + private constructor( + public readonly direction: EventDirection, + public readonly eventType: string, + public readonly kind: EventKind, + public readonly keyStr: string | null, + public readonly raw: string, + ) {} + + public matchesAsStateEvent( + direction: EventDirection, + eventType: string, + stateKey: string | null, + ): boolean { + if (this.kind !== EventKind.State) return false; // not a state event + if (this.direction !== direction) return false; // direction mismatch + if (this.eventType !== eventType) return false; // event type mismatch + if (this.keyStr === null) return true; // all state keys are allowed + if (this.keyStr === stateKey) return true; // this state key is allowed + + // Default not allowed + return false; + } + + public matchesAsToDeviceEvent( + direction: EventDirection, + eventType: string, + ): boolean { + if (this.kind !== EventKind.ToDevice) return false; // not a to-device event + if (this.direction !== direction) return false; // direction mismatch + if (this.eventType !== eventType) return false; // event type mismatch + + // Checks passed, the event is allowed + return true; + } + + public matchesAsRoomEvent( + direction: EventDirection, + eventType: string, + msgtype: string | null = null, + ): boolean { + if (this.kind !== EventKind.Event) return false; // not a room event + if (this.direction !== direction) return false; // direction mismatch + if (this.eventType !== eventType) return false; // event type mismatch + + if (this.eventType === "m.room.message") { + if (this.keyStr === null) return true; // all message types are allowed + if (this.keyStr === msgtype) return true; // this message type is allowed + } else { + return true; // already passed the check for if the event is allowed } - public matchesAsToDeviceEvent(direction: EventDirection, eventType: string): boolean { - if (this.kind !== EventKind.ToDevice) return false; // not a to-device event - if (this.direction !== direction) return false; // direction mismatch - if (this.eventType !== eventType) return false; // event type mismatch - - // Checks passed, the event is allowed - return true; - } - - public matchesAsRoomEvent(direction: EventDirection, eventType: string, msgtype: string | null = null): boolean { - if (this.kind !== EventKind.Event) return false; // not a room event - if (this.direction !== direction) return false; // direction mismatch - if (this.eventType !== eventType) return false; // event type mismatch - - if (this.eventType === "m.room.message") { - if (this.keyStr === null) return true; // all message types are allowed - if (this.keyStr === msgtype) return true; // this message type is allowed - } else { - return true; // already passed the check for if the event is allowed - } - - // Default not allowed - return false; - } - - public matchesAsRoomAccountData(direction: EventDirection, eventType: string): boolean { - if (this.kind !== EventKind.RoomAccount) return false; // not room account data - if (this.direction !== direction) return false; // direction mismatch - if (this.eventType !== eventType) return false; // event type mismatch - - // Checks passed, the event is allowed - return true; - } - - public static forStateEvent( - direction: EventDirection, - eventType: string, - stateKey?: string, - ): WidgetEventCapability { - // TODO: Enable support for m.* namespace once the MSC lands. - // https://github.com/matrix-org/matrix-widget-api/issues/22 - eventType = eventType.replace(/#/g, "\\#"); - stateKey = stateKey !== null && stateKey !== undefined ? `#${stateKey}` : ""; - const str = `org.matrix.msc2762.${direction}.state_event:${eventType}${stateKey}`; - - // cheat by sending it through the processor - return WidgetEventCapability.findEventCapabilities([str])[0]; - } - - public static forToDeviceEvent(direction: EventDirection, eventType: string): WidgetEventCapability { - // TODO: Enable support for m.* namespace once the MSC lands. - // https://github.com/matrix-org/matrix-widget-api/issues/56 - const str = `org.matrix.msc3819.${direction}.to_device:${eventType}`; - - // cheat by sending it through the processor - return WidgetEventCapability.findEventCapabilities([str])[0]; - } - - public static forRoomEvent(direction: EventDirection, eventType: string): WidgetEventCapability { - // TODO: Enable support for m.* namespace once the MSC lands. - // https://github.com/matrix-org/matrix-widget-api/issues/22 - const str = `org.matrix.msc2762.${direction}.event:${eventType}`; - - // cheat by sending it through the processor - return WidgetEventCapability.findEventCapabilities([str])[0]; - } - - public static forRoomMessageEvent(direction: EventDirection, msgtype?: string): WidgetEventCapability { - // TODO: Enable support for m.* namespace once the MSC lands. - // https://github.com/matrix-org/matrix-widget-api/issues/22 - msgtype = msgtype === null || msgtype === undefined ? "" : msgtype; - const str = `org.matrix.msc2762.${direction}.event:m.room.message#${msgtype}`; - - // cheat by sending it through the processor - return WidgetEventCapability.findEventCapabilities([str])[0]; - } - - public static forRoomAccountData(direction: EventDirection, eventType: string): WidgetEventCapability { - const str = `com.beeper.capabilities.${direction}.room_account_data:${eventType}`; - - return WidgetEventCapability.findEventCapabilities([str])[0]; - } - - /** - * Parses a capabilities request to find all the event capability requests. - * @param {Iterable} capabilities The capabilities requested/to parse. - * @returns {WidgetEventCapability[]} An array of event capability requests. May be empty, but never null. - */ - public static findEventCapabilities(capabilities: Iterable): WidgetEventCapability[] { - const parsed: WidgetEventCapability[] = []; - for (const cap of capabilities) { - let direction: EventDirection | null = null; - let eventSegment: string | undefined; - let kind: EventKind | null = null; - - // TODO: Enable support for m.* namespace once the MSCs land. - // https://github.com/matrix-org/matrix-widget-api/issues/22 - // https://github.com/matrix-org/matrix-widget-api/issues/56 - - if (cap.startsWith("org.matrix.msc2762.send.event:")) { - direction = EventDirection.Send; - kind = EventKind.Event; - eventSegment = cap.substring("org.matrix.msc2762.send.event:".length); - } else if (cap.startsWith("org.matrix.msc2762.send.state_event:")) { - direction = EventDirection.Send; - kind = EventKind.State; - eventSegment = cap.substring("org.matrix.msc2762.send.state_event:".length); - } else if (cap.startsWith("org.matrix.msc3819.send.to_device:")) { - direction = EventDirection.Send; - kind = EventKind.ToDevice; - eventSegment = cap.substring("org.matrix.msc3819.send.to_device:".length); - } else if (cap.startsWith("org.matrix.msc2762.receive.event:")) { - direction = EventDirection.Receive; - kind = EventKind.Event; - eventSegment = cap.substring("org.matrix.msc2762.receive.event:".length); - } else if (cap.startsWith("org.matrix.msc2762.receive.state_event:")) { - direction = EventDirection.Receive; - kind = EventKind.State; - eventSegment = cap.substring("org.matrix.msc2762.receive.state_event:".length); - } else if (cap.startsWith("org.matrix.msc3819.receive.to_device:")) { - direction = EventDirection.Receive; - kind = EventKind.ToDevice; - eventSegment = cap.substring("org.matrix.msc3819.receive.to_device:".length); - } else if (cap.startsWith("com.beeper.capabilities.receive.room_account_data:")) { - direction = EventDirection.Receive; - kind = EventKind.RoomAccount; - eventSegment = cap.substring("com.beeper.capabilities.receive.room_account_data:".length); - } - - if (direction === null || kind === null || eventSegment === undefined) continue; - - // The capability uses `#` as a separator between event type and state key/msgtype, - // so we split on that. However, a # is also valid in either one of those so we - // join accordingly. - // Eg: `m.room.message##m.text` is "m.room.message" event with msgtype "#m.text". - const expectingKeyStr = eventSegment.startsWith("m.room.message#") || kind === EventKind.State; - let keyStr: string | null = null; - if (eventSegment.includes("#") && expectingKeyStr) { - // Dev note: regex is difficult to write, so instead the rules are manually written - // out. This is probably just as understandable as a boring regex though, so win-win? - - // Test cases: - // str eventSegment keyStr - // ------------------------------------------------------------- - // m.room.message# m.room.message - // m.room.message#test m.room.message test - // m.room.message\# m.room.message# test - // m.room.message##test m.room.message #test - // m.room.message\##test m.room.message# test - // m.room.message\\##test m.room.message\# test - // m.room.message\\###test m.room.message\# #test - - // First step: explode the string - const parts = eventSegment.split("#"); - - // To form the eventSegment, we'll keep finding parts of the exploded string until - // there's one that doesn't end with the escape character (\). We'll then join those - // segments together with the exploding character. We have to remember to consume the - // escape character as well. - const idx = parts.findIndex((p) => !p.endsWith("\\")); - eventSegment = parts - .slice(0, idx + 1) - .map((p) => (p.endsWith("\\") ? p.substring(0, p.length - 1) : p)) - .join("#"); - - // The keyStr is whatever is left over. - keyStr = parts.slice(idx + 1).join("#"); - } - - parsed.push(new WidgetEventCapability(direction, eventSegment, kind, keyStr, cap)); - } - return parsed; + // Default not allowed + return false; + } + + public matchesAsRoomAccountData( + direction: EventDirection, + eventType: string, + ): boolean { + if (this.kind !== EventKind.RoomAccount) return false; // not room account data + if (this.direction !== direction) return false; // direction mismatch + if (this.eventType !== eventType) return false; // event type mismatch + + // Checks passed, the event is allowed + return true; + } + + public static forStateEvent( + direction: EventDirection, + eventType: string, + stateKey?: string, + ): WidgetEventCapability { + // TODO: Enable support for m.* namespace once the MSC lands. + // https://github.com/matrix-org/matrix-widget-api/issues/22 + eventType = eventType.replace(/#/g, "\\#"); + stateKey = + stateKey !== null && stateKey !== undefined ? `#${stateKey}` : ""; + const str = `org.matrix.msc2762.${direction}.state_event:${eventType}${stateKey}`; + + // cheat by sending it through the processor + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + + public static forToDeviceEvent( + direction: EventDirection, + eventType: string, + ): WidgetEventCapability { + // TODO: Enable support for m.* namespace once the MSC lands. + // https://github.com/matrix-org/matrix-widget-api/issues/56 + const str = `org.matrix.msc3819.${direction}.to_device:${eventType}`; + + // cheat by sending it through the processor + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + + public static forRoomEvent( + direction: EventDirection, + eventType: string, + ): WidgetEventCapability { + // TODO: Enable support for m.* namespace once the MSC lands. + // https://github.com/matrix-org/matrix-widget-api/issues/22 + const str = `org.matrix.msc2762.${direction}.event:${eventType}`; + + // cheat by sending it through the processor + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + + public static forRoomMessageEvent( + direction: EventDirection, + msgtype?: string, + ): WidgetEventCapability { + // TODO: Enable support for m.* namespace once the MSC lands. + // https://github.com/matrix-org/matrix-widget-api/issues/22 + msgtype = msgtype === null || msgtype === undefined ? "" : msgtype; + const str = `org.matrix.msc2762.${direction}.event:m.room.message#${msgtype}`; + + // cheat by sending it through the processor + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + + public static forRoomAccountData( + direction: EventDirection, + eventType: string, + ): WidgetEventCapability { + const str = `com.beeper.capabilities.${direction}.room_account_data:${eventType}`; + + return WidgetEventCapability.findEventCapabilities([str])[0]; + } + + /** + * Parses a capabilities request to find all the event capability requests. + * @param {Iterable} capabilities The capabilities requested/to parse. + * @returns {WidgetEventCapability[]} An array of event capability requests. May be empty, but never null. + */ + public static findEventCapabilities( + capabilities: Iterable, + ): WidgetEventCapability[] { + const parsed: WidgetEventCapability[] = []; + for (const cap of capabilities) { + let direction: EventDirection | null = null; + let eventSegment: string | undefined; + let kind: EventKind | null = null; + + // TODO: Enable support for m.* namespace once the MSCs land. + // https://github.com/matrix-org/matrix-widget-api/issues/22 + // https://github.com/matrix-org/matrix-widget-api/issues/56 + + if (cap.startsWith("org.matrix.msc2762.send.event:")) { + direction = EventDirection.Send; + kind = EventKind.Event; + eventSegment = cap.substring("org.matrix.msc2762.send.event:".length); + } else if (cap.startsWith("org.matrix.msc2762.send.state_event:")) { + direction = EventDirection.Send; + kind = EventKind.State; + eventSegment = cap.substring( + "org.matrix.msc2762.send.state_event:".length, + ); + } else if (cap.startsWith("org.matrix.msc3819.send.to_device:")) { + direction = EventDirection.Send; + kind = EventKind.ToDevice; + eventSegment = cap.substring( + "org.matrix.msc3819.send.to_device:".length, + ); + } else if (cap.startsWith("org.matrix.msc2762.receive.event:")) { + direction = EventDirection.Receive; + kind = EventKind.Event; + eventSegment = cap.substring( + "org.matrix.msc2762.receive.event:".length, + ); + } else if (cap.startsWith("org.matrix.msc2762.receive.state_event:")) { + direction = EventDirection.Receive; + kind = EventKind.State; + eventSegment = cap.substring( + "org.matrix.msc2762.receive.state_event:".length, + ); + } else if (cap.startsWith("org.matrix.msc3819.receive.to_device:")) { + direction = EventDirection.Receive; + kind = EventKind.ToDevice; + eventSegment = cap.substring( + "org.matrix.msc3819.receive.to_device:".length, + ); + } else if ( + cap.startsWith("com.beeper.capabilities.receive.room_account_data:") + ) { + direction = EventDirection.Receive; + kind = EventKind.RoomAccount; + eventSegment = cap.substring( + "com.beeper.capabilities.receive.room_account_data:".length, + ); + } + + if (direction === null || kind === null || eventSegment === undefined) + continue; + + // The capability uses `#` as a separator between event type and state key/msgtype, + // so we split on that. However, a # is also valid in either one of those so we + // join accordingly. + // Eg: `m.room.message##m.text` is "m.room.message" event with msgtype "#m.text". + const expectingKeyStr = + eventSegment.startsWith("m.room.message#") || kind === EventKind.State; + let keyStr: string | null = null; + if (eventSegment.includes("#") && expectingKeyStr) { + // Dev note: regex is difficult to write, so instead the rules are manually written + // out. This is probably just as understandable as a boring regex though, so win-win? + + // Test cases: + // str eventSegment keyStr + // ------------------------------------------------------------- + // m.room.message# m.room.message + // m.room.message#test m.room.message test + // m.room.message\# m.room.message# test + // m.room.message##test m.room.message #test + // m.room.message\##test m.room.message# test + // m.room.message\\##test m.room.message\# test + // m.room.message\\###test m.room.message\# #test + + // First step: explode the string + const parts = eventSegment.split("#"); + + // To form the eventSegment, we'll keep finding parts of the exploded string until + // there's one that doesn't end with the escape character (\). We'll then join those + // segments together with the exploding character. We have to remember to consume the + // escape character as well. + const idx = parts.findIndex((p) => !p.endsWith("\\")); + eventSegment = parts + .slice(0, idx + 1) + .map((p) => (p.endsWith("\\") ? p.substring(0, p.length - 1) : p)) + .join("#"); + + // The keyStr is whatever is left over. + keyStr = parts.slice(idx + 1).join("#"); + } + + parsed.push( + new WidgetEventCapability(direction, eventSegment, kind, keyStr, cap), + ); } + return parsed; + } } diff --git a/src/models/WidgetParser.ts b/src/models/WidgetParser.ts index 07ced72..bf82365 100644 --- a/src/models/WidgetParser.ts +++ b/src/models/WidgetParser.ts @@ -19,129 +19,138 @@ import { IWidget } from ".."; import { isValidUrl } from "./validation/url"; export interface IStateEvent { - event_id: string; // eslint-disable-line camelcase - room_id: string; // eslint-disable-line camelcase - type: string; - sender: string; - origin_server_ts: number; // eslint-disable-line camelcase - unsigned?: unknown; - content: unknown; - state_key: string; // eslint-disable-line camelcase + event_id: string; // eslint-disable-line camelcase + room_id: string; // eslint-disable-line camelcase + type: string; + sender: string; + origin_server_ts: number; // eslint-disable-line camelcase + unsigned?: unknown; + content: unknown; + state_key: string; // eslint-disable-line camelcase } export interface IAccountDataWidgets { - [widgetId: string]: { - type: "m.widget"; - // the state_key is also the widget's ID - state_key: string; // eslint-disable-line camelcase - sender: string; // current user's ID - content: IWidget; - id?: string; // off-spec, but possible - }; + [widgetId: string]: { + type: "m.widget"; + // the state_key is also the widget's ID + state_key: string; // eslint-disable-line camelcase + sender: string; // current user's ID + content: IWidget; + id?: string; // off-spec, but possible + }; } export class WidgetParser { - private constructor() { - // private constructor because this is a util class + private constructor() { + // private constructor because this is a util class + } + + /** + * Parses widgets from the "m.widgets" account data event. This will always + * return an array, though may be empty if no valid widgets were found. + * @param {IAccountDataWidgets} content The content of the "m.widgets" account data. + * @returns {Widget[]} The widgets in account data, or an empty array. + */ + public static parseAccountData(content: IAccountDataWidgets): Widget[] { + if (!content) return []; + + const result: Widget[] = []; + for (const widgetId of Object.keys(content)) { + const roughWidget = content[widgetId]; + if (!roughWidget) continue; + if ( + roughWidget.type !== "m.widget" && + roughWidget.type !== "im.vector.modular.widgets" + ) + continue; + if (!roughWidget.sender) continue; + + const probableWidgetId = roughWidget.state_key || roughWidget.id; + if (probableWidgetId !== widgetId) continue; + + const asStateEvent: IStateEvent = { + content: roughWidget.content, + sender: roughWidget.sender, + type: "m.widget", + state_key: widgetId, + event_id: "$example", + room_id: "!example", + origin_server_ts: 1, + }; + + const widget = WidgetParser.parseRoomWidget(asStateEvent); + if (widget) result.push(widget); } - /** - * Parses widgets from the "m.widgets" account data event. This will always - * return an array, though may be empty if no valid widgets were found. - * @param {IAccountDataWidgets} content The content of the "m.widgets" account data. - * @returns {Widget[]} The widgets in account data, or an empty array. - */ - public static parseAccountData(content: IAccountDataWidgets): Widget[] { - if (!content) return []; - - const result: Widget[] = []; - for (const widgetId of Object.keys(content)) { - const roughWidget = content[widgetId]; - if (!roughWidget) continue; - if (roughWidget.type !== "m.widget" && roughWidget.type !== "im.vector.modular.widgets") continue; - if (!roughWidget.sender) continue; - - const probableWidgetId = roughWidget.state_key || roughWidget.id; - if (probableWidgetId !== widgetId) continue; - - const asStateEvent: IStateEvent = { - content: roughWidget.content, - sender: roughWidget.sender, - type: "m.widget", - state_key: widgetId, - event_id: "$example", - room_id: "!example", - origin_server_ts: 1, - }; - - const widget = WidgetParser.parseRoomWidget(asStateEvent); - if (widget) result.push(widget); - } - - return result; + return result; + } + + /** + * Parses all the widgets possible in the given array. This will always return + * an array, though may be empty if no widgets could be parsed. + * @param {IStateEvent[]} currentState The room state to parse. + * @returns {Widget[]} The widgets in the state, or an empty array. + */ + public static parseWidgetsFromRoomState( + currentState: IStateEvent[], + ): Widget[] { + if (!currentState) return []; + const result: Widget[] = []; + for (const state of currentState) { + const widget = WidgetParser.parseRoomWidget(state); + if (widget) result.push(widget); } - - /** - * Parses all the widgets possible in the given array. This will always return - * an array, though may be empty if no widgets could be parsed. - * @param {IStateEvent[]} currentState The room state to parse. - * @returns {Widget[]} The widgets in the state, or an empty array. - */ - public static parseWidgetsFromRoomState(currentState: IStateEvent[]): Widget[] { - if (!currentState) return []; - const result: Widget[] = []; - for (const state of currentState) { - const widget = WidgetParser.parseRoomWidget(state); - if (widget) result.push(widget); - } - return result; + return result; + } + + /** + * Parses a state event into a widget. If the state event does not represent + * a widget (wrong event type, invalid widget, etc) then null is returned. + * @param {IStateEvent} stateEvent The state event. + * @returns {Widget|null} The widget, or null if invalid + */ + public static parseRoomWidget(stateEvent: IStateEvent): Widget | null { + if (!stateEvent) return null; + + // TODO: [Legacy] Remove legacy support + if ( + stateEvent.type !== "m.widget" && + stateEvent.type !== "im.vector.modular.widgets" + ) { + return null; } - /** - * Parses a state event into a widget. If the state event does not represent - * a widget (wrong event type, invalid widget, etc) then null is returned. - * @param {IStateEvent} stateEvent The state event. - * @returns {Widget|null} The widget, or null if invalid - */ - public static parseRoomWidget(stateEvent: IStateEvent): Widget | null { - if (!stateEvent) return null; - - // TODO: [Legacy] Remove legacy support - if (stateEvent.type !== "m.widget" && stateEvent.type !== "im.vector.modular.widgets") { - return null; - } - - // Dev note: Throughout this function we have null safety to ensure that - // if the caller did not supply something useful that we don't error. This - // is done against the requirements of the interface because not everyone - // will have an interface to validate against. - - const content = (stateEvent.content as IWidget) || {}; - - // Form our best approximation of a widget with the information we have - const estimatedWidget: IWidget = { - id: stateEvent.state_key, - creatorUserId: content["creatorUserId"] || stateEvent.sender, - name: content["name"], - type: content["type"], - url: content["url"], - waitForIframeLoad: content["waitForIframeLoad"], - data: content["data"], - }; - - // Finally, process that widget - return WidgetParser.processEstimatedWidget(estimatedWidget); - } + // Dev note: Throughout this function we have null safety to ensure that + // if the caller did not supply something useful that we don't error. This + // is done against the requirements of the interface because not everyone + // will have an interface to validate against. + + const content = (stateEvent.content as IWidget) || {}; + + // Form our best approximation of a widget with the information we have + const estimatedWidget: IWidget = { + id: stateEvent.state_key, + creatorUserId: content["creatorUserId"] || stateEvent.sender, + name: content["name"], + type: content["type"], + url: content["url"], + waitForIframeLoad: content["waitForIframeLoad"], + data: content["data"], + }; - private static processEstimatedWidget(widget: IWidget): Widget | null { - // Validate that the widget has the best chance of passing as a widget - if (!widget.id || !widget.creatorUserId || !widget.type) { - return null; - } - if (!isValidUrl(widget.url)) { - return null; - } - // TODO: Validate data for known widget types - return new Widget(widget); + // Finally, process that widget + return WidgetParser.processEstimatedWidget(estimatedWidget); + } + + private static processEstimatedWidget(widget: IWidget): Widget | null { + // Validate that the widget has the best chance of passing as a widget + if (!widget.id || !widget.creatorUserId || !widget.type) { + return null; + } + if (!isValidUrl(widget.url)) { + return null; } + // TODO: Validate data for known widget types + return new Widget(widget); + } } diff --git a/src/models/validation/url.ts b/src/models/validation/url.ts index c56a9c6..4f0480a 100644 --- a/src/models/validation/url.ts +++ b/src/models/validation/url.ts @@ -15,18 +15,18 @@ */ export function isValidUrl(val: string): boolean { - if (!val) return false; // easy: not valid if not present + if (!val) return false; // easy: not valid if not present - try { - const parsed = new URL(val); - if (parsed.protocol !== "http" && parsed.protocol !== "https") { - return false; - } - return true; - } catch (e) { - if (e instanceof TypeError) { - return false; - } - throw e; + try { + const parsed = new URL(val); + if (parsed.protocol !== "http" && parsed.protocol !== "https") { + return false; } + return true; + } catch (e) { + if (e instanceof TypeError) { + return false; + } + throw e; + } } diff --git a/src/models/validation/utils.ts b/src/models/validation/utils.ts index 5572c0f..52efb16 100644 --- a/src/models/validation/utils.ts +++ b/src/models/validation/utils.ts @@ -15,8 +15,11 @@ */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function assertPresent>(obj: O, key: keyof O): void { - if (!obj[key]) { - throw new Error(`${String(key)} is required`); - } +export function assertPresent>( + obj: O, + key: keyof O, +): void { + if (!obj[key]) { + throw new Error(`${String(key)} is required`); + } } diff --git a/src/templating/url-template.ts b/src/templating/url-template.ts index b700a9b..2861149 100644 --- a/src/templating/url-template.ts +++ b/src/templating/url-template.ts @@ -17,55 +17,59 @@ import { IWidget } from ".."; export interface ITemplateParams { - widgetRoomId?: string; - currentUserId: string; - userDisplayName?: string; - userHttpAvatarUrl?: string; - clientId?: string; - clientTheme?: string; - clientLanguage?: string; - deviceId?: string; - baseUrl?: string; + widgetRoomId?: string; + currentUserId: string; + userDisplayName?: string; + userHttpAvatarUrl?: string; + clientId?: string; + clientTheme?: string; + clientLanguage?: string; + deviceId?: string; + baseUrl?: string; } -export function runTemplate(url: string, widget: IWidget, params: ITemplateParams): string { - // Always apply the supplied params over top of data to ensure the data can't lie about them. - const variables = Object.assign({}, widget.data, { - "matrix_room_id": params.widgetRoomId || "", - "matrix_user_id": params.currentUserId, - "matrix_display_name": params.userDisplayName || params.currentUserId, - "matrix_avatar_url": params.userHttpAvatarUrl || "", - "matrix_widget_id": widget.id, +export function runTemplate( + url: string, + widget: IWidget, + params: ITemplateParams, +): string { + // Always apply the supplied params over top of data to ensure the data can't lie about them. + const variables = Object.assign({}, widget.data, { + matrix_room_id: params.widgetRoomId || "", + matrix_user_id: params.currentUserId, + matrix_display_name: params.userDisplayName || params.currentUserId, + matrix_avatar_url: params.userHttpAvatarUrl || "", + matrix_widget_id: widget.id, - // TODO: Convert to stable (https://github.com/matrix-org/matrix-doc/pull/2873) - "org.matrix.msc2873.client_id": params.clientId || "", - "org.matrix.msc2873.client_theme": params.clientTheme || "", - "org.matrix.msc2873.client_language": params.clientLanguage || "", + // TODO: Convert to stable (https://github.com/matrix-org/matrix-doc/pull/2873) + "org.matrix.msc2873.client_id": params.clientId || "", + "org.matrix.msc2873.client_theme": params.clientTheme || "", + "org.matrix.msc2873.client_language": params.clientLanguage || "", - // TODO: Convert to stable (https://github.com/matrix-org/matrix-spec-proposals/pull/3819) - "org.matrix.msc3819.matrix_device_id": params.deviceId || "", + // TODO: Convert to stable (https://github.com/matrix-org/matrix-spec-proposals/pull/3819) + "org.matrix.msc3819.matrix_device_id": params.deviceId || "", - // TODO: Convert to stable (https://github.com/matrix-org/matrix-spec-proposals/pull/4039) - "org.matrix.msc4039.matrix_base_url": params.baseUrl || "", - }); - let result = url; - for (const key of Object.keys(variables)) { - // Regex escape from https://stackoverflow.com/a/6969486/7037379 - const pattern = `$${key}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string - const rexp = new RegExp(pattern, "g"); + // TODO: Convert to stable (https://github.com/matrix-org/matrix-spec-proposals/pull/4039) + "org.matrix.msc4039.matrix_base_url": params.baseUrl || "", + }); + let result = url; + for (const key of Object.keys(variables)) { + // Regex escape from https://stackoverflow.com/a/6969486/7037379 + const pattern = `$${key}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string + const rexp = new RegExp(pattern, "g"); - // This is technically not what we're supposed to do for a couple of reasons: - // 1. We are assuming that there won't later be a $key match after we replace a variable. - // 2. We are assuming that the variable is in a place where it can be escaped (eg: path or query string). - result = result.replace(rexp, encodeURIComponent(toString(variables[key]))); - } - return result; + // This is technically not what we're supposed to do for a couple of reasons: + // 1. We are assuming that there won't later be a $key match after we replace a variable. + // 2. We are assuming that the variable is in a place where it can be escaped (eg: path or query string). + result = result.replace(rexp, encodeURIComponent(toString(variables[key]))); + } + return result; } export function toString(a: unknown): string { - if (a === null || a === undefined) { - return `${a}`; - } - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return String(a); + if (a === null || a === undefined) { + return `${a}`; + } + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return String(a); } diff --git a/src/transport/ITransport.ts b/src/transport/ITransport.ts index 3446e6a..b6dda14 100644 --- a/src/transport/ITransport.ts +++ b/src/transport/ITransport.ts @@ -17,12 +17,12 @@ import { EventEmitter } from "events"; import { - IWidgetApiAcknowledgeResponseData, - IWidgetApiRequest, - IWidgetApiRequestData, - IWidgetApiResponse, - IWidgetApiResponseData, - WidgetApiAction, + IWidgetApiAcknowledgeResponseData, + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiResponse, + IWidgetApiResponseData, + WidgetApiAction, } from ".."; /** @@ -31,79 +31,85 @@ import { * of the IWidgetApiRequest. */ export interface ITransport extends EventEmitter { - /** - * True if the transport is ready to start sending, false otherwise. - */ - readonly ready: boolean; + /** + * True if the transport is ready to start sending, false otherwise. + */ + readonly ready: boolean; - /** - * The widget ID, if known. If not known, null. - */ - readonly widgetId: string | null; + /** + * The widget ID, if known. If not known, null. + */ + readonly widgetId: string | null; - /** - * If true, the transport will refuse requests from origins other than the - * widget's current origin. This is intended to be used only by widgets which - * need excess security. - */ - strictOriginCheck: boolean; + /** + * If true, the transport will refuse requests from origins other than the + * widget's current origin. This is intended to be used only by widgets which + * need excess security. + */ + strictOriginCheck: boolean; - /** - * The origin the transport should be replying/sending to. If not known, leave - * null. - */ - targetOrigin: string | null; + /** + * The origin the transport should be replying/sending to. If not known, leave + * null. + */ + targetOrigin: string | null; - /** - * The number of seconds an outbound request is allowed to take before it - * times out. - */ - timeoutSeconds: number; + /** + * The number of seconds an outbound request is allowed to take before it + * times out. + */ + timeoutSeconds: number; - /** - * Starts the transport for listening - */ - start(): void; + /** + * Starts the transport for listening + */ + start(): void; - /** - * Stops the transport. It cannot be re-started. - */ - stop(): void; + /** + * Stops the transport. It cannot be re-started. + */ + stop(): void; - /** - * Sends a request to the remote end. - * @param action The action to send. - * @param data The request data. - * @returns A promise which resolves to the remote end's response. - * @throws {Error} if the request failed with a generic error. - * @throws {WidgetApiResponseError} if the request failed with error details - * that can be communicated to the Widget API. - */ - send( - action: WidgetApiAction, - data: T, - ): Promise; + /** + * Sends a request to the remote end. + * @param action The action to send. + * @param data The request data. + * @returns A promise which resolves to the remote end's response. + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. + */ + send< + T extends IWidgetApiRequestData, + R extends IWidgetApiResponseData = IWidgetApiAcknowledgeResponseData, + >( + action: WidgetApiAction, + data: T, + ): Promise; - /** - * Sends a request to the remote end. This is similar to the send() function - * however this version returns the full response rather than just the response - * data. - * @param {WidgetApiAction} action The action to send. - * @param {IWidgetApiRequestData} data The request data. - * @returns {Promise} A promise which resolves to the remote end's response - * @throws {Error} if the request failed with a generic error. - * @throws {WidgetApiResponseError} if the request failed with error details - * that can be communicated to the Widget API. - */ - sendComplete( - action: WidgetApiAction, - data: T, - ): Promise; + /** + * Sends a request to the remote end. This is similar to the send() function + * however this version returns the full response rather than just the response + * data. + * @param {WidgetApiAction} action The action to send. + * @param {IWidgetApiRequestData} data The request data. + * @returns {Promise} A promise which resolves to the remote end's response + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. + */ + sendComplete( + action: WidgetApiAction, + data: T, + ): Promise; - /** - * Replies to a request. - * @param {IWidgetApiRequest} request The request to reply to. - * @param {IWidgetApiResponseData} responseData The response data to reply with. - */ - reply(request: IWidgetApiRequest, responseData: T): void; + /** + * Replies to a request. + * @param {IWidgetApiRequest} request The request to reply to. + * @param {IWidgetApiResponseData} responseData The response data to reply with. + */ + reply( + request: IWidgetApiRequest, + responseData: T, + ): void; } diff --git a/src/transport/PostmessageTransport.ts b/src/transport/PostmessageTransport.ts index 4589735..733825d 100644 --- a/src/transport/PostmessageTransport.ts +++ b/src/transport/PostmessageTransport.ts @@ -18,186 +18,199 @@ import { EventEmitter } from "events"; import { ITransport } from "./ITransport"; import { - invertedDirection, - isErrorResponse, - IWidgetApiRequest, - IWidgetApiRequestData, - IWidgetApiResponse, - IWidgetApiResponseData, - WidgetApiResponseError, - WidgetApiAction, - WidgetApiDirection, - WidgetApiToWidgetAction, + invertedDirection, + isErrorResponse, + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiResponse, + IWidgetApiResponseData, + WidgetApiResponseError, + WidgetApiAction, + WidgetApiDirection, + WidgetApiToWidgetAction, } from ".."; interface IOutboundRequest { - request: IWidgetApiRequest; - resolve: (response: IWidgetApiResponse) => void; - reject: (err: Error) => void; + request: IWidgetApiRequest; + resolve: (response: IWidgetApiResponse) => void; + reject: (err: Error) => void; } /** * Transport for the Widget API over postMessage. */ export class PostmessageTransport extends EventEmitter implements ITransport { - public strictOriginCheck = false; - public targetOrigin = "*"; - public timeoutSeconds = 10; - - private _ready = false; - private _widgetId: string | null = null; - private outboundRequests = new Map(); - private stopController = new AbortController(); - - public get ready(): boolean { - return this._ready; - } - - public get widgetId(): string | null { - return this._widgetId || null; - } - - public constructor( - private sendDirection: WidgetApiDirection, - private initialWidgetId: string | null, - private transportWindow: Window, - private inboundWindow: Window, - ) { - super(); - this._widgetId = initialWidgetId; - } - - private get nextRequestId(): string { - const idBase = `widgetapi-${Date.now()}`; - let index = 0; - let id = idBase; - while (this.outboundRequests.has(id)) { - id = `${idBase}-${index++}`; - } - - // reserve the ID - this.outboundRequests.set(id, null); - - return id; + public strictOriginCheck = false; + public targetOrigin = "*"; + public timeoutSeconds = 10; + + private _ready = false; + private _widgetId: string | null = null; + private outboundRequests = new Map(); + private stopController = new AbortController(); + + public get ready(): boolean { + return this._ready; + } + + public get widgetId(): string | null { + return this._widgetId || null; + } + + public constructor( + private sendDirection: WidgetApiDirection, + private initialWidgetId: string | null, + private transportWindow: Window, + private inboundWindow: Window, + ) { + super(); + this._widgetId = initialWidgetId; + } + + private get nextRequestId(): string { + const idBase = `widgetapi-${Date.now()}`; + let index = 0; + let id = idBase; + while (this.outboundRequests.has(id)) { + id = `${idBase}-${index++}`; } - private sendInternal(message: IWidgetApiRequest | IWidgetApiResponse): void { - console.log(`[PostmessageTransport] Sending object to ${this.targetOrigin}: `, message); - this.transportWindow.postMessage(message, this.targetOrigin); + // reserve the ID + this.outboundRequests.set(id, null); + + return id; + } + + private sendInternal(message: IWidgetApiRequest | IWidgetApiResponse): void { + console.log( + `[PostmessageTransport] Sending object to ${this.targetOrigin}: `, + message, + ); + this.transportWindow.postMessage(message, this.targetOrigin); + } + + public reply( + request: IWidgetApiRequest, + responseData: T, + ): void { + return this.sendInternal({ + ...request, + response: responseData, + }); + } + + public send< + T extends IWidgetApiRequestData, + R extends IWidgetApiResponseData, + >(action: WidgetApiAction, data: T): Promise { + return this.sendComplete(action, data).then((r) => r.response); + } + + public sendComplete< + T extends IWidgetApiRequestData, + R extends IWidgetApiResponse, + >(action: WidgetApiAction, data: T): Promise { + if (!this.ready || !this.widgetId) { + return Promise.reject(new Error("Not ready or unknown widget ID")); } - - public reply(request: IWidgetApiRequest, responseData: T): void { - return this.sendInternal({ - ...request, - response: responseData, - }); - } - - public send( - action: WidgetApiAction, - data: T, - ): Promise { - return this.sendComplete(action, data).then((r) => r.response); - } - - public sendComplete( - action: WidgetApiAction, - data: T, - ): Promise { - if (!this.ready || !this.widgetId) { - return Promise.reject(new Error("Not ready or unknown widget ID")); - } - const request: IWidgetApiRequest = { - api: this.sendDirection, - widgetId: this.widgetId, - requestId: this.nextRequestId, - action: action, - data: data, - }; - if (action === WidgetApiToWidgetAction.UpdateVisibility) { - request["visible"] = data["visible"]; - } - return new Promise((prResolve, prReject) => { - const resolve = (response: IWidgetApiResponse): void => { - cleanUp(); - prResolve(response); - }; - const reject = (err: Error): void => { - cleanUp(); - prReject(err); - }; - - const timerId = setTimeout(() => reject(new Error("Request timed out")), (this.timeoutSeconds || 1) * 1000); - - const onStop = (): void => reject(new Error("Transport stopped")); - this.stopController.signal.addEventListener("abort", onStop); - - const cleanUp = (): void => { - this.outboundRequests.delete(request.requestId); - clearTimeout(timerId); - this.stopController.signal.removeEventListener("abort", onStop); - }; - - this.outboundRequests.set(request.requestId, { request, resolve, reject }); - this.sendInternal(request); - }); + const request: IWidgetApiRequest = { + api: this.sendDirection, + widgetId: this.widgetId, + requestId: this.nextRequestId, + action: action, + data: data, + }; + if (action === WidgetApiToWidgetAction.UpdateVisibility) { + request["visible"] = data["visible"]; } - - public start(): void { - this.inboundWindow.addEventListener("message", (ev: MessageEvent) => { - this.handleMessage(ev); - }); - this._ready = true; - } - - public stop(): void { - this._ready = false; - this.stopController.abort(); + return new Promise((prResolve, prReject) => { + const resolve = (response: IWidgetApiResponse): void => { + cleanUp(); + prResolve(response); + }; + const reject = (err: Error): void => { + cleanUp(); + prReject(err); + }; + + const timerId = setTimeout( + () => reject(new Error("Request timed out")), + (this.timeoutSeconds || 1) * 1000, + ); + + const onStop = (): void => reject(new Error("Transport stopped")); + this.stopController.signal.addEventListener("abort", onStop); + + const cleanUp = (): void => { + this.outboundRequests.delete(request.requestId); + clearTimeout(timerId); + this.stopController.signal.removeEventListener("abort", onStop); + }; + + this.outboundRequests.set(request.requestId, { + request, + resolve, + reject, + }); + this.sendInternal(request); + }); + } + + public start(): void { + this.inboundWindow.addEventListener("message", (ev: MessageEvent) => { + this.handleMessage(ev); + }); + this._ready = true; + } + + public stop(): void { + this._ready = false; + this.stopController.abort(); + } + + private handleMessage(ev: MessageEvent): void { + if (this.stopController.signal.aborted) return; + if (!ev.data) return; // invalid event + + if (this.strictOriginCheck && ev.origin !== window.origin) return; // bad origin + + // treat the message as a response first, then downgrade to a request + const response = ev.data; + if (!response.action || !response.requestId || !response.widgetId) return; // invalid request/response + + if (!response.response) { + // it's a request + const request = response; + if (request.api !== invertedDirection(this.sendDirection)) return; // wrong direction + this.handleRequest(request); + } else { + // it's a response + if (response.api !== this.sendDirection) return; // wrong direction + this.handleResponse(response); } + } - private handleMessage(ev: MessageEvent): void { - if (this.stopController.signal.aborted) return; - if (!ev.data) return; // invalid event - - if (this.strictOriginCheck && ev.origin !== window.origin) return; // bad origin - - // treat the message as a response first, then downgrade to a request - const response = ev.data; - if (!response.action || !response.requestId || !response.widgetId) return; // invalid request/response - - if (!response.response) { - // it's a request - const request = response; - if (request.api !== invertedDirection(this.sendDirection)) return; // wrong direction - this.handleRequest(request); - } else { - // it's a response - if (response.api !== this.sendDirection) return; // wrong direction - this.handleResponse(response); - } + private handleRequest(request: IWidgetApiRequest): void { + if (this.widgetId) { + if (this.widgetId !== request.widgetId) return; // wrong widget + } else { + this._widgetId = request.widgetId; } - private handleRequest(request: IWidgetApiRequest): void { - if (this.widgetId) { - if (this.widgetId !== request.widgetId) return; // wrong widget - } else { - this._widgetId = request.widgetId; - } - - this.emit("message", new CustomEvent("message", { detail: request })); - } + this.emit("message", new CustomEvent("message", { detail: request })); + } - private handleResponse(response: IWidgetApiResponse): void { - if (response.widgetId !== this.widgetId) return; // wrong widget + private handleResponse(response: IWidgetApiResponse): void { + if (response.widgetId !== this.widgetId) return; // wrong widget - const req = this.outboundRequests.get(response.requestId); - if (!req) return; // response to an unknown request + const req = this.outboundRequests.get(response.requestId); + if (!req) return; // response to an unknown request - if (isErrorResponse(response.response)) { - const { message, ...data } = response.response.error; - req.reject(new WidgetApiResponseError(message, data)); - } else { - req.resolve(response); - } + if (isErrorResponse(response.response)) { + const { message, ...data } = response.response.error; + req.reject(new WidgetApiResponseError(message, data)); + } else { + req.resolve(response); } + } } diff --git a/src/util/SimpleObservable.ts b/src/util/SimpleObservable.ts index 5108247..cffa861 100644 --- a/src/util/SimpleObservable.ts +++ b/src/util/SimpleObservable.ts @@ -17,23 +17,23 @@ export type ObservableFunction = (val: T) => void; export class SimpleObservable { - private listeners: ObservableFunction[] = []; + private listeners: ObservableFunction[] = []; - public constructor(initialFn?: ObservableFunction) { - if (initialFn) this.listeners.push(initialFn); - } + public constructor(initialFn?: ObservableFunction) { + if (initialFn) this.listeners.push(initialFn); + } - public onUpdate(fn: ObservableFunction): void { - this.listeners.push(fn); - } + public onUpdate(fn: ObservableFunction): void { + this.listeners.push(fn); + } - public update(val: T): void { - for (const listener of this.listeners) { - listener(val); - } + public update(val: T): void { + for (const listener of this.listeners) { + listener(val); } + } - public close(): void { - this.listeners = []; // reset - } + public close(): void { + this.listeners = []; // reset + } } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 0a261f2..dff644a 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -26,25 +26,28 @@ import { IWidgetApiRequest } from "../src/interfaces/IWidgetApiRequest"; import { IReadRelationsFromWidgetActionRequest } from "../src/interfaces/ReadRelationsAction"; import { ISupportedVersionsActionRequest } from "../src/interfaces/SupportedVersionsAction"; import { IUserDirectorySearchFromWidgetActionRequest } from "../src/interfaces/UserDirectorySearchAction"; -import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "../src/interfaces/WidgetApiAction"; +import { + WidgetApiFromWidgetAction, + WidgetApiToWidgetAction, +} from "../src/interfaces/WidgetApiAction"; import { WidgetApiDirection } from "../src/interfaces/WidgetApiDirection"; import { Widget } from "../src/models/Widget"; import { PostmessageTransport } from "../src/transport/PostmessageTransport"; import { - IDownloadFileActionFromWidgetActionRequest, - IGetOpenIDActionRequest, - IMatrixApiError, - INavigateActionRequest, - IReadEventFromWidgetActionRequest, - ISendEventFromWidgetActionRequest, - ISendToDeviceFromWidgetActionRequest, - IUpdateDelayedEventFromWidgetActionRequest, - IUploadFileActionFromWidgetActionRequest, - IWidgetApiErrorResponseDataDetails, - OpenIDRequestState, - SimpleObservable, - Symbols, - UpdateDelayedEventAction, + IDownloadFileActionFromWidgetActionRequest, + IGetOpenIDActionRequest, + IMatrixApiError, + INavigateActionRequest, + IReadEventFromWidgetActionRequest, + ISendEventFromWidgetActionRequest, + ISendToDeviceFromWidgetActionRequest, + IUpdateDelayedEventFromWidgetActionRequest, + IUploadFileActionFromWidgetActionRequest, + IWidgetApiErrorResponseDataDetails, + OpenIDRequestState, + SimpleObservable, + Symbols, + UpdateDelayedEventAction, } from "../src"; import { IGetMediaConfigActionFromWidgetActionRequest } from "../src/interfaces/GetMediaConfigAction"; import { IReadRoomAccountDataFromWidgetActionRequest } from "../src/interfaces/ReadRoomAccountDataAction"; @@ -52,2484 +55,2659 @@ import { IReadRoomAccountDataFromWidgetActionRequest } from "../src/interfaces/R jest.mock("../src/transport/PostmessageTransport"); afterEach(() => { - jest.resetAllMocks(); + jest.resetAllMocks(); }); function createRoomEvent(event: Partial = {}): IRoomEvent { - return { - type: "m.room.message", - sender: "user-id", - content: {}, - origin_server_ts: 0, - event_id: "id-0", - room_id: "!room-id", - unsigned: {}, - ...event, - }; + return { + type: "m.room.message", + sender: "user-id", + content: {}, + origin_server_ts: 0, + event_id: "id-0", + room_id: "!room-id", + unsigned: {}, + ...event, + }; } class CustomMatrixError extends Error { - public constructor( - message: string, - public readonly httpStatus: number, - public readonly name: string, - public readonly data: Record, - ) { - super(message); - } + public constructor( + message: string, + public readonly httpStatus: number, + public readonly name: string, + public readonly data: Record, + ) { + super(message); + } } -function processCustomMatrixError(e: unknown): IWidgetApiErrorResponseDataDetails | undefined { - return e instanceof CustomMatrixError - ? { - matrix_api_error: { - http_status: e.httpStatus, - http_headers: {}, - url: "", - response: { - errcode: e.name, - error: e.message, - ...e.data, - }, - }, - } - : undefined; +function processCustomMatrixError( + e: unknown, +): IWidgetApiErrorResponseDataDetails | undefined { + return e instanceof CustomMatrixError + ? { + matrix_api_error: { + http_status: e.httpStatus, + http_headers: {}, + url: "", + response: { + errcode: e.name, + error: e.message, + ...e.data, + }, + }, + } + : undefined; } describe("ClientWidgetApi", () => { - let capabilities: Capability[]; - let iframe: HTMLIFrameElement; - let driver: jest.Mocked; - let clientWidgetApi: ClientWidgetApi; - let transport: PostmessageTransport; - let emitEvent: Parameters["1"]; - - async function loadIframe(caps: Capability[] = []): Promise { - capabilities = caps; - - const ready = new Promise((resolve) => { - clientWidgetApi.once("ready", resolve); - }); - - iframe.dispatchEvent(new Event("load")); - - await ready; - } - - beforeEach(() => { - capabilities = []; - iframe = document.createElement("iframe"); - document.body.appendChild(iframe); - - driver = { - navigate: jest.fn(), - readRoomTimeline: jest.fn(), - readRoomState: jest.fn(() => Promise.resolve([])), - readEventRelations: jest.fn(), - sendEvent: jest.fn(), - sendDelayedEvent: jest.fn(), - updateDelayedEvent: jest.fn(), - sendToDevice: jest.fn(), - askOpenID: jest.fn(), - readRoomAccountData: jest.fn(), - validateCapabilities: jest.fn(), - searchUserDirectory: jest.fn(), - getMediaConfig: jest.fn(), - uploadFile: jest.fn(), - downloadFile: jest.fn(), - getKnownRooms: jest.fn(() => []), - processError: jest.fn(), - } as Partial as jest.Mocked; - - clientWidgetApi = new ClientWidgetApi( - new Widget({ - id: "test", - creatorUserId: "@alice:example.org", - type: "example", - url: "https://example.org", - }), - iframe, - driver, - ); - - [transport] = jest.mocked(PostmessageTransport).mock.instances; - emitEvent = jest.mocked(transport.on).mock.calls[0][1]; - - jest.mocked(transport.send).mockResolvedValue({}); - jest.mocked(driver.validateCapabilities).mockImplementation(async () => new Set(capabilities)); + let capabilities: Capability[]; + let iframe: HTMLIFrameElement; + let driver: jest.Mocked; + let clientWidgetApi: ClientWidgetApi; + let transport: PostmessageTransport; + let emitEvent: Parameters["1"]; + + async function loadIframe(caps: Capability[] = []): Promise { + capabilities = caps; + + const ready = new Promise((resolve) => { + clientWidgetApi.once("ready", resolve); }); - afterEach(() => { - clientWidgetApi.stop(); - iframe.remove(); + iframe.dispatchEvent(new Event("load")); + + await ready; + } + + beforeEach(() => { + capabilities = []; + iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + + driver = { + navigate: jest.fn(), + readRoomTimeline: jest.fn(), + readRoomState: jest.fn(() => Promise.resolve([])), + readEventRelations: jest.fn(), + sendEvent: jest.fn(), + sendDelayedEvent: jest.fn(), + updateDelayedEvent: jest.fn(), + sendToDevice: jest.fn(), + askOpenID: jest.fn(), + readRoomAccountData: jest.fn(), + validateCapabilities: jest.fn(), + searchUserDirectory: jest.fn(), + getMediaConfig: jest.fn(), + uploadFile: jest.fn(), + downloadFile: jest.fn(), + getKnownRooms: jest.fn(() => []), + processError: jest.fn(), + } as Partial as jest.Mocked; + + clientWidgetApi = new ClientWidgetApi( + new Widget({ + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org", + }), + iframe, + driver, + ); + + [transport] = jest.mocked(PostmessageTransport).mock.instances; + emitEvent = jest.mocked(transport.on).mock.calls[0][1]; + + jest.mocked(transport.send).mockResolvedValue({}); + jest + .mocked(driver.validateCapabilities) + .mockImplementation(async () => new Set(capabilities)); + }); + + afterEach(() => { + clientWidgetApi.stop(); + iframe.remove(); + }); + + it("should initiate capabilities", async () => { + await loadIframe(["m.always_on_screen"]); + + expect(clientWidgetApi.hasCapability("m.always_on_screen")).toBe(true); + expect(clientWidgetApi.hasCapability("m.sticker")).toBe(false); + }); + + describe("navigate action", () => { + it("navigates", async () => { + driver.navigate.mockResolvedValue(Promise.resolve()); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: "https://matrix.to/#/#room:example.net", + }, + }; + + await loadIframe(["org.matrix.msc2931.navigate"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.navigate).toHaveBeenCalledWith(event.data.uri); }); - it("should initiate capabilities", async () => { - await loadIframe(["m.always_on_screen"]); + it("fails to navigate", async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: "https://matrix.to/#/#room:example.net", + }, + }; - expect(clientWidgetApi.hasCapability("m.always_on_screen")).toBe(true); - expect(clientWidgetApi.hasCapability("m.sticker")).toBe(false); - }); + await loadIframe([]); // Without the required capability - describe("navigate action", () => { - it("navigates", async () => { - driver.navigate.mockResolvedValue(Promise.resolve()); + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Missing capability" }, + }); + }); - const event: INavigateActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2931Navigate, - data: { - uri: "https://matrix.to/#/#room:example.net", - }, - }; + expect(driver.navigate).not.toBeCalled(); + }); - await loadIframe(["org.matrix.msc2931.navigate"]); + it("fails to navigate to an unsupported URI", async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: "https://example.net", + }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(["org.matrix.msc2931.navigate"]); - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, {}); - }); + emitEvent(new CustomEvent("", { detail: event })); - expect(driver.navigate).toHaveBeenCalledWith(event.data.uri); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid matrix.to URI" }, }); + }); - it("fails to navigate", async () => { - const event: INavigateActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2931Navigate, - data: { - uri: "https://matrix.to/#/#room:example.net", - }, - }; + expect(driver.navigate).not.toBeCalled(); + }); - await loadIframe([]); // Without the required capability + it("should reject requests when the driver throws an exception", async () => { + driver.navigate.mockRejectedValue(new Error("M_UNKNOWN: Unknown error")); - emitEvent(new CustomEvent("", { detail: event })); + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: "https://matrix.to/#/#room:example.net", + }, + }; - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Missing capability" }, - }); - }); + await loadIframe(["org.matrix.msc2931.navigate"]); - expect(driver.navigate).not.toBeCalled(); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("fails to navigate to an unsupported URI", async () => { - const event: INavigateActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2931Navigate, - data: { - uri: "https://example.net", - }, - }; + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Error handling navigation" }, + }); + }); + }); - await loadIframe(["org.matrix.msc2931.navigate"]); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.navigate.mockRejectedValue( + new CustomMatrixError("failed to navigate", 400, "M_UNKNOWN", { + reason: "Unknown error", + }), + ); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: "https://matrix.to/#/#room:example.net", + }, + }; + + await loadIframe(["org.matrix.msc2931.navigate"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Error handling navigation", + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "failed to navigate", + reason: "Unknown error", + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + + describe("send_event action", () => { + it("sends message events", async () => { + const roomId = "!room:example.org"; + const eventId = "$event:example.org"; + + driver.sendEvent.mockResolvedValue({ + roomId, + eventId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: {}, + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + event_id: eventId, + }); + }); + + expect(driver.sendEvent).toHaveBeenCalledWith( + event.data.type, + event.data.content, + null, + roomId, + ); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("sends state events", async () => { + const roomId = "!room:example.org"; + const eventId = "$event:example.org"; + + driver.sendEvent.mockResolvedValue({ + roomId, + eventId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.topic", + content: {}, + state_key: "", + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.state_event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + event_id: eventId, + }); + }); + + expect(driver.sendEvent).toHaveBeenCalledWith( + event.data.type, + event.data.content, + "", + roomId, + ); + }); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid matrix.to URI" }, - }); - }); + it("should reject requests when the driver throws an exception", async () => { + const roomId = "!room:example.org"; + + driver.sendEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: "hello", + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Error sending event" }, + }); + }); + }); - expect(driver.navigate).not.toBeCalled(); + it("should reject with Matrix API error response thrown by driver", async () => { + const roomId = "!room:example.org"; + + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendEvent.mockRejectedValue( + new CustomMatrixError("failed to send event", 400, "M_NOT_JSON", { + reason: "Content must be a JSON object.", + }), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: "hello", + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Error sending event", + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_NOT_JSON", + error: "failed to send event", + reason: "Content must be a JSON object.", + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); + + describe("send_event action for delayed events", () => { + it("fails to send delayed events", async () => { + const roomId = "!room:example.org"; + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: {}, + delay: 5000, + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + // Without the required capability + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, + }); + }); + + expect(driver.sendDelayedEvent).not.toBeCalled(); + }); - it("should reject requests when the driver throws an exception", async () => { - driver.navigate.mockRejectedValue(new Error("M_UNKNOWN: Unknown error")); - - const event: INavigateActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2931Navigate, - data: { - uri: "https://matrix.to/#/#room:example.net", - }, - }; + it("sends delayed message events", async () => { + const roomId = "!room:example.org"; + const parentDelayId = "fp"; + const timeoutDelayId = "ft"; + + driver.sendDelayedEvent.mockResolvedValue({ + roomId, + delayId: timeoutDelayId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: {}, + room_id: roomId, + delay: 5000, + parent_delay_id: parentDelayId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + delay_id: timeoutDelayId, + }); + }); + + expect(driver.sendDelayedEvent).toHaveBeenCalledWith( + event.data.delay, + event.data.parent_delay_id, + event.data.type, + event.data.content, + null, + roomId, + ); + }); - await loadIframe(["org.matrix.msc2931.navigate"]); + it("sends delayed state events", async () => { + const roomId = "!room:example.org"; + const parentDelayId = "fp"; + const timeoutDelayId = "ft"; + + driver.sendDelayedEvent.mockResolvedValue({ + roomId, + delayId: timeoutDelayId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.topic", + content: {}, + state_key: "", + room_id: roomId, + delay: 5000, + parent_delay_id: parentDelayId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.state_event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + delay_id: timeoutDelayId, + }); + }); + + expect(driver.sendDelayedEvent).toHaveBeenCalledWith( + event.data.delay, + event.data.parent_delay_id, + event.data.type, + event.data.content, + "", + roomId, + ); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("should reject requests when the driver throws an exception", async () => { + const roomId = "!room:example.org"; + + driver.sendDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: "hello", + room_id: roomId, + delay: 5000, + parent_delay_id: "fp", + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Error sending event" }, + }); + }); + }); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Error handling navigation" }, - }); - }); + it("should reject with Matrix API error response thrown by driver", async () => { + const roomId = "!room:example.org"; + + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendDelayedEvent.mockRejectedValue( + new CustomMatrixError("failed to send event", 400, "M_NOT_JSON", { + reason: "Content must be a JSON object.", + }), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: "hello", + room_id: roomId, + delay: 5000, + parent_delay_id: "fp", + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Error sending event", + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_NOT_JSON", + error: "failed to send event", + reason: "Content must be a JSON object.", + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); + + describe("receiving events", () => { + const roomId = "!room:example.org"; + const otherRoomId = "!other-room:example.org"; + const event = createRoomEvent({ + room_id: roomId, + type: "m.room.message", + content: "hello", + }); + const eventFromOtherRoom = createRoomEvent({ + room_id: otherRoomId, + type: "m.room.message", + content: "test", + }); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.navigate.mockRejectedValue( - new CustomMatrixError("failed to navigate", 400, "M_UNKNOWN", { - reason: "Unknown error", - }), - ); - - const event: INavigateActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2931Navigate, - data: { - uri: "https://matrix.to/#/#room:example.net", - }, - }; - - await loadIframe(["org.matrix.msc2931.navigate"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Error handling navigation", - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "failed to navigate", - reason: "Unknown error", - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); + it("forwards events to the widget from one room only", async () => { + // Give the widget capabilities to receive from just one room + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + "org.matrix.msc2762.receive.event:m.room.message", + ]); + + // Event from the matching room should be forwarded + clientWidgetApi.feedEvent(event); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + event, + ); + + // Event from the other room should not be forwarded + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + eventFromOtherRoom, + ); }); - describe("send_event action", () => { - it("sends message events", async () => { - const roomId = "!room:example.org"; - const eventId = "$event:example.org"; - - driver.sendEvent.mockResolvedValue({ - roomId, - eventId, - }); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: {}, - room_id: roomId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - event_id: eventId, - }); - }); - - expect(driver.sendEvent).toHaveBeenCalledWith(event.data.type, event.data.content, null, roomId); - }); + it("forwards events to the widget from the currently viewed room", async () => { + clientWidgetApi.setViewedRoomId(roomId); + // Give the widget capabilities to receive events without specifying + // any rooms that it can read + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + "org.matrix.msc2762.receive.event:m.room.message", + ]); + + // Event from the viewed room should be forwarded + clientWidgetApi.feedEvent(event); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + event, + ); + + // Event from the other room should not be forwarded + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + eventFromOtherRoom, + ); + + // View the other room; now the event can be forwarded + clientWidgetApi.setViewedRoomId(otherRoomId); + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + eventFromOtherRoom, + ); + }); - it("sends state events", async () => { - const roomId = "!room:example.org"; - const eventId = "$event:example.org"; - - driver.sendEvent.mockResolvedValue({ - roomId, - eventId, - }); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.topic", - content: {}, - state_key: "", - room_id: roomId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.state_event:${event.data.type}`, - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - event_id: eventId, - }); - }); - - expect(driver.sendEvent).toHaveBeenCalledWith(event.data.type, event.data.content, "", roomId); - }); + it("forwards events to the widget from all rooms", async () => { + // Give the widget capabilities to receive from any known room + await loadIframe([ + `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, + "org.matrix.msc2762.receive.event:m.room.message", + ]); + + // Events from both rooms should be forwarded + clientWidgetApi.feedEvent(event); + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + event, + ); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.SendEvent, + eventFromOtherRoom, + ); + }); + }); + + describe("receiving room state", () => { + it("syncs initial state and feeds updates", async () => { + const roomId = "!room:example.org"; + const otherRoomId = "!other-room:example.org"; + clientWidgetApi.setViewedRoomId(roomId); + const topicEvent = createRoomEvent({ + room_id: roomId, + type: "m.room.topic", + state_key: "", + content: { topic: "Hello world!" }, + }); + const nameEvent = createRoomEvent({ + room_id: roomId, + type: "m.room.name", + state_key: "", + content: { name: "Test room" }, + }); + const joinRulesEvent = createRoomEvent({ + room_id: roomId, + type: "m.room.join_rules", + state_key: "", + content: { join_rule: "public" }, + }); + const otherRoomNameEvent = createRoomEvent({ + room_id: otherRoomId, + type: "m.room.name", + state_key: "", + content: { name: "Other room" }, + }); + + // Artificially delay the delivery of the join rules event + let resolveJoinRules: () => void; + const joinRules = new Promise( + (resolve) => (resolveJoinRules = resolve), + ); + + driver.readRoomState.mockImplementation( + async (rId, eventType, stateKey) => { + if (rId === roomId) { + if (eventType === "m.room.topic" && stateKey === "") + return [topicEvent]; + if (eventType === "m.room.name" && stateKey === "") + return [nameEvent]; + if (eventType === "m.room.join_rules" && stateKey === "") { + await joinRules; + return [joinRulesEvent]; + } + } else if (rId === otherRoomId) { + if (eventType === "m.room.name" && stateKey === "") + return [otherRoomNameEvent]; + } + return []; + }, + ); + + await loadIframe([ + "org.matrix.msc2762.receive.state_event:m.room.topic#", + "org.matrix.msc2762.receive.state_event:m.room.name#", + "org.matrix.msc2762.receive.state_event:m.room.join_rules#", + ]); + + // Simulate a race between reading the original join rules event and + // the join rules being updated at the same time + const newJoinRulesEvent = createRoomEvent({ + room_id: roomId, + type: "m.room.join_rules", + state_key: "", + content: { join_rule: "invite" }, + }); + clientWidgetApi.feedStateUpdate(newJoinRulesEvent); + // What happens if the original join rules are delivered after the + // updated ones? + resolveJoinRules!(); + + await waitFor(() => { + // The initial topic and name should have been pushed + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { + state: [topicEvent, nameEvent, newJoinRulesEvent], + }, + ); + // Only the updated join rules should have been delivered + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { + state: expect.arrayContaining([joinRules]), + }, + ); + }); + + // Check that further updates to room state are pushed to the widget + // as expected + const newTopicEvent = createRoomEvent({ + room_id: roomId, + type: "m.room.topic", + state_key: "", + content: { topic: "Our new topic" }, + }); + clientWidgetApi.feedStateUpdate(newTopicEvent); + + await waitFor(() => { + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { + state: [newTopicEvent], + }, + ); + }); + + // Up to this point we should not have received any state for the + // other (unviewed) room + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { + state: expect.arrayContaining([otherRoomNameEvent]), + }, + ); + // Now view the other room + clientWidgetApi.setViewedRoomId(otherRoomId); + (transport.send as unknown as jest.SpyInstance).mockClear(); + + await waitFor(() => { + // The state of the other room should now be pushed + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { + state: expect.arrayContaining([otherRoomNameEvent]), + }, + ); + }); + }); + }); - it("should reject requests when the driver throws an exception", async () => { - const roomId = "!room:example.org"; - - driver.sendEvent.mockRejectedValue(new Error("M_BAD_JSON: Content must be a JSON object")); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: "hello", - room_id: roomId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Error sending event" }, - }); - }); - }); + describe("update_delayed_event action", () => { + it("fails to update delayed events", async () => { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: "f", + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe([]); // Without the required capability - it("should reject with Matrix API error response thrown by driver", async () => { - const roomId = "!room:example.org"; - - driver.processError.mockImplementation(processCustomMatrixError); - - driver.sendEvent.mockRejectedValue( - new CustomMatrixError("failed to send event", 400, "M_NOT_JSON", { - reason: "Content must be a JSON object.", - }), - ); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: "hello", - room_id: roomId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Error sending event", - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_NOT_JSON", - error: "failed to send event", - reason: "Content must be a JSON object.", - }, - } satisfies IMatrixApiError, - }, - }); - }); + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, }); + }); + + expect(driver.updateDelayedEvent).not.toBeCalled(); }); - describe("send_event action for delayed events", () => { - it("fails to send delayed events", async () => { - const roomId = "!room:example.org"; - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: {}, - delay: 5000, - room_id: roomId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - // Without the required capability - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, - }); - }); - - expect(driver.sendDelayedEvent).not.toBeCalled(); - }); + it("fails to update delayed events with unsupported action", async () => { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: "f", + action: "unknown" as UpdateDelayedEventAction, + }, + }; - it("sends delayed message events", async () => { - const roomId = "!room:example.org"; - const parentDelayId = "fp"; - const timeoutDelayId = "ft"; - - driver.sendDelayedEvent.mockResolvedValue({ - roomId, - delayId: timeoutDelayId, - }); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: {}, - room_id: roomId, - delay: 5000, - parent_delay_id: parentDelayId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, - }); - }); - - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - null, - roomId, - ); - }); + await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - it("sends delayed state events", async () => { - const roomId = "!room:example.org"; - const parentDelayId = "fp"; - const timeoutDelayId = "ft"; - - driver.sendDelayedEvent.mockResolvedValue({ - roomId, - delayId: timeoutDelayId, - }); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.topic", - content: {}, - state_key: "", - room_id: roomId, - delay: 5000, - parent_delay_id: parentDelayId, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.state_event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, - }); - }); - - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - "", - roomId, - ); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("should reject requests when the driver throws an exception", async () => { - const roomId = "!room:example.org"; - - driver.sendDelayedEvent.mockRejectedValue(new Error("M_BAD_JSON: Content must be a JSON object")); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: "hello", - room_id: roomId, - delay: 5000, - parent_delay_id: "fp", - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Error sending event" }, - }); - }); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, }); + }); - it("should reject with Matrix API error response thrown by driver", async () => { - const roomId = "!room:example.org"; - - driver.processError.mockImplementation(processCustomMatrixError); - - driver.sendDelayedEvent.mockRejectedValue( - new CustomMatrixError("failed to send event", 400, "M_NOT_JSON", { - reason: "Content must be a JSON object.", - }), - ); - - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: "hello", - room_id: roomId, - delay: 5000, - parent_delay_id: "fp", - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Error sending event", - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_NOT_JSON", - error: "failed to send event", - reason: "Content must be a JSON object.", - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); + expect(driver.updateDelayedEvent).not.toBeCalled(); }); - describe("receiving events", () => { - const roomId = "!room:example.org"; - const otherRoomId = "!other-room:example.org"; - const event = createRoomEvent({ room_id: roomId, type: "m.room.message", content: "hello" }); - const eventFromOtherRoom = createRoomEvent({ - room_id: otherRoomId, - type: "m.room.message", - content: "test", - }); - - it("forwards events to the widget from one room only", async () => { - // Give the widget capabilities to receive from just one room - await loadIframe([ - `org.matrix.msc2762.timeline:${roomId}`, - "org.matrix.msc2762.receive.event:m.room.message", - ]); + it("updates delayed events", async () => { + driver.updateDelayedEvent.mockResolvedValue(undefined); - // Event from the matching room should be forwarded - clientWidgetApi.feedEvent(event); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); + for (const action of [ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ]) { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: "f", + action, + }, + }; - // Event from the other room should not be forwarded - clientWidgetApi.feedEvent(eventFromOtherRoom); - expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); - }); + await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - it("forwards events to the widget from the currently viewed room", async () => { - clientWidgetApi.setViewedRoomId(roomId); - // Give the widget capabilities to receive events without specifying - // any rooms that it can read - await loadIframe([ - `org.matrix.msc2762.timeline:${roomId}`, - "org.matrix.msc2762.receive.event:m.room.message", - ]); - - // Event from the viewed room should be forwarded - clientWidgetApi.feedEvent(event); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); - - // Event from the other room should not be forwarded - clientWidgetApi.feedEvent(eventFromOtherRoom); - expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); - - // View the other room; now the event can be forwarded - clientWidgetApi.setViewedRoomId(otherRoomId); - clientWidgetApi.feedEvent(eventFromOtherRoom); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("forwards events to the widget from all rooms", async () => { - // Give the widget capabilities to receive from any known room - await loadIframe([ - `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, - "org.matrix.msc2762.receive.event:m.room.message", - ]); - - // Events from both rooms should be forwarded - clientWidgetApi.feedEvent(event); - clientWidgetApi.feedEvent(eventFromOtherRoom); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); }); - }); - describe("receiving room state", () => { - it("syncs initial state and feeds updates", async () => { - const roomId = "!room:example.org"; - const otherRoomId = "!other-room:example.org"; - clientWidgetApi.setViewedRoomId(roomId); - const topicEvent = createRoomEvent({ - room_id: roomId, - type: "m.room.topic", - state_key: "", - content: { topic: "Hello world!" }, - }); - const nameEvent = createRoomEvent({ - room_id: roomId, - type: "m.room.name", - state_key: "", - content: { name: "Test room" }, - }); - const joinRulesEvent = createRoomEvent({ - room_id: roomId, - type: "m.room.join_rules", - state_key: "", - content: { join_rule: "public" }, - }); - const otherRoomNameEvent = createRoomEvent({ - room_id: otherRoomId, - type: "m.room.name", - state_key: "", - content: { name: "Other room" }, - }); - - // Artificially delay the delivery of the join rules event - let resolveJoinRules: () => void; - const joinRules = new Promise((resolve) => (resolveJoinRules = resolve)); - - driver.readRoomState.mockImplementation(async (rId, eventType, stateKey) => { - if (rId === roomId) { - if (eventType === "m.room.topic" && stateKey === "") return [topicEvent]; - if (eventType === "m.room.name" && stateKey === "") return [nameEvent]; - if (eventType === "m.room.join_rules" && stateKey === "") { - await joinRules; - return [joinRulesEvent]; - } - } else if (rId === otherRoomId) { - if (eventType === "m.room.name" && stateKey === "") return [otherRoomNameEvent]; - } - return []; - }); - - await loadIframe([ - "org.matrix.msc2762.receive.state_event:m.room.topic#", - "org.matrix.msc2762.receive.state_event:m.room.name#", - "org.matrix.msc2762.receive.state_event:m.room.join_rules#", - ]); - - // Simulate a race between reading the original join rules event and - // the join rules being updated at the same time - const newJoinRulesEvent = createRoomEvent({ - room_id: roomId, - type: "m.room.join_rules", - state_key: "", - content: { join_rule: "invite" }, - }); - clientWidgetApi.feedStateUpdate(newJoinRulesEvent); - // What happens if the original join rules are delivered after the - // updated ones? - resolveJoinRules!(); - - await waitFor(() => { - // The initial topic and name should have been pushed - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.UpdateState, { - state: [topicEvent, nameEvent, newJoinRulesEvent], - }); - // Only the updated join rules should have been delivered - expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.UpdateState, { - state: expect.arrayContaining([joinRules]), - }); - }); - - // Check that further updates to room state are pushed to the widget - // as expected - const newTopicEvent = createRoomEvent({ - room_id: roomId, - type: "m.room.topic", - state_key: "", - content: { topic: "Our new topic" }, - }); - clientWidgetApi.feedStateUpdate(newTopicEvent); - - await waitFor(() => { - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.UpdateState, { - state: [newTopicEvent], - }); - }); - - // Up to this point we should not have received any state for the - // other (unviewed) room - expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.UpdateState, { - state: expect.arrayContaining([otherRoomNameEvent]), - }); - // Now view the other room - clientWidgetApi.setViewedRoomId(otherRoomId); - (transport.send as unknown as jest.SpyInstance).mockClear(); - - await waitFor(() => { - // The state of the other room should now be pushed - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.UpdateState, { - state: expect.arrayContaining([otherRoomNameEvent]), - }); - }); - }); + expect(driver.updateDelayedEvent).toHaveBeenCalledWith( + event.data.delay_id, + event.data.action, + ); + } }); - describe("update_delayed_event action", () => { - it("fails to update delayed events", async () => { - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: "f", - action: UpdateDelayedEventAction.Send, - }, - }; + it("should reject requests when the driver throws an exception", async () => { + driver.updateDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); - await loadIframe([]); // Without the required capability + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: "f", + action: UpdateDelayedEventAction.Send, + }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - expect(driver.updateDelayedEvent).not.toBeCalled(); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Error updating delayed event" }, }); + }); + }); - it("fails to update delayed events with unsupported action", async () => { - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: "f", - action: "unknown" as UpdateDelayedEventAction, - }, - }; - - await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, - }); - }); - - expect(driver.updateDelayedEvent).not.toBeCalled(); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.updateDelayedEvent.mockRejectedValue( + new CustomMatrixError( + "failed to update delayed event", + 400, + "M_NOT_JSON", + { + reason: "Content must be a JSON object.", + }, + ), + ); + + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: "f", + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe(["org.matrix.msc4157.update_delayed_event"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Error updating delayed event", + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_NOT_JSON", + error: "failed to update delayed event", + reason: "Content must be a JSON object.", + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); + + describe("send_to_device action", () => { + it("sends unencrypted to-device events", async () => { + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + encrypted: false, + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; + + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.sendToDevice).toHaveBeenCalledWith( + event.data.type, + event.data.encrypted, + event.data.messages, + ); + }); - it("updates delayed events", async () => { - driver.updateDelayedEvent.mockResolvedValue(undefined); - - for (const action of [ - UpdateDelayedEventAction.Cancel, - UpdateDelayedEventAction.Restart, - UpdateDelayedEventAction.Send, - ]) { - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: "f", - action, - }, - }; - - await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, {}); - }); - - expect(driver.updateDelayedEvent).toHaveBeenCalledWith(event.data.delay_id, event.data.action); - } - }); + it("fails to send to-device events without event type", async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + encrypted: false, + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; - it("should reject requests when the driver throws an exception", async () => { - driver.updateDelayedEvent.mockRejectedValue(new Error("M_BAD_JSON: Content must be a JSON object")); - - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: "f", - action: UpdateDelayedEventAction.Send, - }, - }; - - await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Error updating delayed event" }, - }); - }); - }); + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.updateDelayedEvent.mockRejectedValue( - new CustomMatrixError("failed to update delayed event", 400, "M_NOT_JSON", { - reason: "Content must be a JSON object.", - }), - ); - - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: "f", - action: UpdateDelayedEventAction.Send, - }, - }; - - await loadIframe(["org.matrix.msc4157.update_delayed_event"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Error updating delayed event", - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_NOT_JSON", - error: "failed to update delayed event", - reason: "Content must be a JSON object.", - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - describe("send_to_device action", () => { - it("sends unencrypted to-device events", async () => { - const event: ISendToDeviceFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - encrypted: false, - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, {}); - }); - - expect(driver.sendToDevice).toHaveBeenCalledWith( - event.data.type, - event.data.encrypted, - event.data.messages, - ); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - missing event type" }, }); + }); - it("fails to send to-device events without event type", async () => { - const event: IWidgetApiRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - encrypted: false, - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - missing event type" }, - }); - }); - - expect(driver.sendToDevice).not.toBeCalled(); - }); + expect(driver.sendToDevice).not.toBeCalled(); + }); - it("fails to send to-device events without event contents", async () => { - const event: IWidgetApiRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - encrypted: false, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - missing event contents" }, - }); - }); - - expect(driver.sendToDevice).not.toBeCalled(); - }); + it("fails to send to-device events without event contents", async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + encrypted: false, + }, + }; - it("fails to send to-device events without encryption flag", async () => { - const event: IWidgetApiRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - missing encryption flag" }, - }); - }); - - expect(driver.sendToDevice).not.toBeCalled(); - }); + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); - it("fails to send to-device events with any event type", async () => { - const event: ISendToDeviceFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - encrypted: false, - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}_different`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Cannot send to-device events of this type" }, - }); - }); - - expect(driver.sendToDevice).not.toBeCalled(); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("should reject requests when the driver throws an exception", async () => { - driver.sendToDevice.mockRejectedValue( - new Error("M_FORBIDDEN: You don't have permission to send to-device events"), - ); - - const event: ISendToDeviceFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - encrypted: false, - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Error sending event" }, - }); - }); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - missing event contents" }, }); + }); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.sendToDevice.mockRejectedValue( - new CustomMatrixError("failed to send event", 400, "M_FORBIDDEN", { - reason: "You don't have permission to send to-device events", - }), - ); - - const event: ISendToDeviceFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendToDevice, - data: { - type: "net.example.test", - encrypted: false, - messages: { - "@foo:bar.com": { - DEVICEID: { - example_content_key: "value", - }, - }, - }, - }, - }; - - await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Error sending event", - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_FORBIDDEN", - error: "failed to send event", - reason: "You don't have permission to send to-device events", - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); + expect(driver.sendToDevice).not.toBeCalled(); }); - describe("get_openid action", () => { - it("gets info", async () => { - driver.askOpenID.mockImplementation((observable) => { - observable.update({ - state: OpenIDRequestState.Allowed, - token: { - access_token: "access_token", - }, - }); - }); - - const event: IGetOpenIDActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.GetOpenIDCredentials, - data: {}, - }; - - await loadIframe([]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - state: OpenIDRequestState.Allowed, - access_token: "access_token", - }); - }); - - expect(driver.askOpenID).toHaveBeenCalledWith(expect.any(SimpleObservable)); - }); + it("fails to send to-device events without encryption flag", async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; - it("fails when client provided invalid token", async () => { - driver.askOpenID.mockImplementation((observable) => { - observable.update({ - state: OpenIDRequestState.Allowed, - }); - }); + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); - const event: IGetOpenIDActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.GetOpenIDCredentials, - data: {}, - }; + emitEvent(new CustomEvent("", { detail: event })); - await loadIframe([]); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - missing encryption flag" }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("fails to send to-device events with any event type", async () => { + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + encrypted: false, + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - error: { message: "client provided invalid OIDC token for an allowed request" }, - }); - }); + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}_different`, + ]); - expect(driver.askOpenID).toHaveBeenCalledWith(expect.any(SimpleObservable)); - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - describe("com.beeper.read_room_account_data action", () => { - it("reads room account data", async () => { - const type = "net.example.test"; - const roomId = "!room:example.org"; - - driver.readRoomAccountData.mockResolvedValue([ - { - type, - room_id: roomId, - content: {}, - }, - ]); - - const event: IReadRoomAccountDataFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.BeeperReadRoomAccountData, - data: { - room_ids: [roomId], - type, - }, - }; - - await loadIframe([`com.beeper.capabilities.receive.room_account_data:${type}`]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - events: [ - { - type, - room_id: roomId, - content: {}, - }, - ], - }); - }); - - expect(driver.readRoomAccountData).toHaveBeenCalledWith(event.data.type); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Cannot send to-device events of this type" }, }); + }); - it("does not read room account data", async () => { - const type = "net.example.test"; - const roomId = "!room:example.org"; - - driver.readRoomAccountData.mockResolvedValue([ - { - type, - room_id: roomId, - content: {}, - }, - ]); - - const event: IReadRoomAccountDataFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.BeeperReadRoomAccountData, - data: { - room_ids: [roomId], - type, - }, - }; - - await loadIframe([]); // Without the required capability - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - error: { message: "Cannot read room account data of this type" }, - }); - }); - - expect(driver.readRoomAccountData).toHaveBeenCalledWith(event.data.type); - }); + expect(driver.sendToDevice).not.toBeCalled(); }); - describe("org.matrix.msc2876.read_events action", () => { - it("reads events from a specific room", async () => { - const roomId = "!room:example.org"; - const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: "test" }); - driver.readRoomTimeline.mockImplementation(async (rId) => { - if (rId === roomId) return [event]; - return []; - }); - - const request: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - room_ids: [roomId], - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${roomId}`, - "org.matrix.msc2762.receive.event:net.example.test", - ]); - clientWidgetApi.setViewedRoomId(roomId); - - emitEvent(new CustomEvent("", { detail: request })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(request, { - events: [event], - }); - }); - - expect(driver.readRoomTimeline).toHaveBeenCalledWith( - roomId, - "net.example.test", - undefined, - undefined, - 0, - undefined, - ); - }); + it("should reject requests when the driver throws an exception", async () => { + driver.sendToDevice.mockRejectedValue( + new Error( + "M_FORBIDDEN: You don't have permission to send to-device events", + ), + ); + + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + encrypted: false, + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; - it("reads events from all rooms", async () => { - const roomId = "!room:example.org"; - const otherRoomId = "!other-room:example.org"; - const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: "test" }); - const otherRoomEvent = createRoomEvent({ room_id: otherRoomId, type: "net.example.test", content: "hi" }); - driver.getKnownRooms.mockReturnValue([roomId, otherRoomId]); - driver.readRoomTimeline.mockImplementation(async (rId) => { - if (rId === roomId) return [event]; - if (rId === otherRoomId) return [otherRoomEvent]; - return []; - }); - - const request: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - room_ids: Symbols.AnyRoom, - }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, - "org.matrix.msc2762.receive.event:net.example.test", - ]); - clientWidgetApi.setViewedRoomId(roomId); - - emitEvent(new CustomEvent("", { detail: request })); - - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(request, { - events: [event, otherRoomEvent], - }); - }); - - expect(driver.readRoomTimeline).toHaveBeenCalledWith( - roomId, - "net.example.test", - undefined, - undefined, - 0, - undefined, - ); - expect(driver.readRoomTimeline).toHaveBeenCalledWith( - otherRoomId, - "net.example.test", - undefined, - undefined, - 0, - undefined, - ); - }); + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); - it("reads state events with any state key", async () => { - driver.readRoomTimeline.mockResolvedValue([ - createRoomEvent({ type: "net.example.test", state_key: "A" }), - createRoomEvent({ type: "net.example.test", state_key: "B" }), - ]); - - const event: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - state_key: true, - }, - }; - - await loadIframe(["org.matrix.msc2762.receive.state_event:net.example.test"]); - clientWidgetApi.setViewedRoomId("!room-id"); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - events: [ - createRoomEvent({ type: "net.example.test", state_key: "A" }), - createRoomEvent({ type: "net.example.test", state_key: "B" }), - ], - }); - }); - - expect(driver.readRoomTimeline).toBeCalledWith( - "!room-id", - "net.example.test", - undefined, - undefined, - 0, - undefined, - ); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("fails to read state events with any state key", async () => { - const event: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - state_key: true, - }, - }; - - await loadIframe([]); // Without the required capability - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, - }); - }); - - expect(driver.readRoomTimeline).not.toBeCalled(); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Error sending event" }, }); + }); + }); - it("reads state events with a specific state key", async () => { - driver.readRoomTimeline.mockResolvedValue([createRoomEvent({ type: "net.example.test", state_key: "B" })]); - - const event: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - state_key: "B", - }, - }; - - await loadIframe(["org.matrix.msc2762.receive.state_event:net.example.test#B"]); - clientWidgetApi.setViewedRoomId("!room-id"); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - events: [createRoomEvent({ type: "net.example.test", state_key: "B" })], - }); - }); - - expect(driver.readRoomTimeline).toBeCalledWith( - "!room-id", - "net.example.test", - undefined, - "B", - 0, - undefined, - ); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendToDevice.mockRejectedValue( + new CustomMatrixError("failed to send event", 400, "M_FORBIDDEN", { + reason: "You don't have permission to send to-device events", + }), + ); + + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: "net.example.test", + encrypted: false, + messages: { + "@foo:bar.com": { + DEVICEID: { + example_content_key: "value", + }, + }, + }, + }, + }; + + await loadIframe([ + `org.matrix.msc3819.send.to_device:${event.data.type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Error sending event", + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_FORBIDDEN", + error: "failed to send event", + reason: "You don't have permission to send to-device events", + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); - it("fails to read state events with a specific state key", async () => { - const event: IReadEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC2876ReadEvents, - data: { - type: "net.example.test", - state_key: "B", - }, - }; - - // Request the capability for the wrong state key - await loadIframe(["org.matrix.msc2762.receive.state_event:net.example.test#A"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, - }); - }); - - expect(driver.readRoomTimeline).not.toBeCalled(); + describe("get_openid action", () => { + it("gets info", async () => { + driver.askOpenID.mockImplementation((observable) => { + observable.update({ + state: OpenIDRequestState.Allowed, + token: { + access_token: "access_token", + }, }); - }); + }); - describe("org.matrix.msc3869.read_relations action", () => { - it("should present as supported api version", () => { - const event: ISupportedVersionsActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SupportedApiVersions, - data: {}, - }; + const event: IGetOpenIDActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.GetOpenIDCredentials, + data: {}, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe([]); - expect(transport.reply).toBeCalledWith(event, { - supported_versions: expect.arrayContaining([UnstableApiVersion.MSC3869]), - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("should handle and process the request", async () => { - driver.readEventRelations.mockResolvedValue({ - chunk: [createRoomEvent()], - }); - - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { event_id: "$event" }, - }; - - await loadIframe(["org.matrix.msc2762.receive.event:m.room.message"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - chunk: [createRoomEvent()], - }); - }); - - expect(driver.readEventRelations).toBeCalledWith( - "$event", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ); + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + state: OpenIDRequestState.Allowed, + access_token: "access_token", }); + }); - it("should only return events that match requested capabilities", async () => { - driver.readEventRelations.mockResolvedValue({ - chunk: [ - createRoomEvent(), - createRoomEvent({ type: "m.reaction" }), - createRoomEvent({ type: "net.example.test", state_key: "A" }), - createRoomEvent({ type: "net.example.test", state_key: "B" }), - ], - }); - - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { event_id: "$event" }, - }; - - await loadIframe([ - "org.matrix.msc2762.receive.event:m.room.message", - "org.matrix.msc2762.receive.state_event:net.example.test#A", - ]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - chunk: [createRoomEvent(), createRoomEvent({ type: "net.example.test", state_key: "A" })], - }); - }); - - expect(driver.readEventRelations).toBeCalledWith( - "$event", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ); - }); + expect(driver.askOpenID).toHaveBeenCalledWith( + expect.any(SimpleObservable), + ); + }); - it("should accept all options and pass it to the driver", async () => { - driver.readEventRelations.mockResolvedValue({ - chunk: [], - }); - - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { - event_id: "$event", - room_id: "!room-id", - event_type: "m.room.message", - rel_type: "m.reference", - limit: 25, - from: "from-token", - to: "to-token", - direction: "f", - }, - }; - - await loadIframe(["org.matrix.msc2762.timeline:!room-id"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - chunk: [], - }); - }); - - expect(driver.readEventRelations).toBeCalledWith( - "$event", - "!room-id", - "m.reference", - "m.room.message", - "from-token", - "to-token", - 25, - "f", - ); + it("fails when client provided invalid token", async () => { + driver.askOpenID.mockImplementation((observable) => { + observable.update({ + state: OpenIDRequestState.Allowed, }); + }); - it("should reject requests without event_id", async () => { - const event: IWidgetApiRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: {}, - }; + const event: IGetOpenIDActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.GetOpenIDCredentials, + data: {}, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe([]); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - missing event ID" }, - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("should reject requests with a negative limit", async () => { - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { - event_id: "$event", - limit: -1, - }, - }; - - emitEvent(new CustomEvent("", { detail: event })); - - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - limit out of range" }, - }); + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + error: { + message: + "client provided invalid OIDC token for an allowed request", + }, }); + }); - it("should reject requests when the room timeline was not requested", async () => { - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { - event_id: "$event", - room_id: "!another-room-id", - }, - }; - - emitEvent(new CustomEvent("", { detail: event })); - - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unable to access room timeline: !another-room-id" }, - }); - }); + expect(driver.askOpenID).toHaveBeenCalledWith( + expect.any(SimpleObservable), + ); + }); + }); + + describe("com.beeper.read_room_account_data action", () => { + it("reads room account data", async () => { + const type = "net.example.test"; + const roomId = "!room:example.org"; + + driver.readRoomAccountData.mockResolvedValue([ + { + type, + room_id: roomId, + content: {}, + }, + ]); + + const event: IReadRoomAccountDataFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.BeeperReadRoomAccountData, + data: { + room_ids: [roomId], + type, + }, + }; + + await loadIframe([ + `com.beeper.capabilities.receive.room_account_data:${type}`, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + events: [ + { + type, + room_id: roomId, + content: {}, + }, + ], + }); + }); + + expect(driver.readRoomAccountData).toHaveBeenCalledWith(event.data.type); + }); - it("should reject requests when the driver throws an exception", async () => { - driver.readEventRelations.mockRejectedValue( - new Error("M_FORBIDDEN: You don't have permission to access that event"), - ); + it("does not read room account data", async () => { + const type = "net.example.test"; + const roomId = "!room:example.org"; - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { event_id: "$event" }, - }; + driver.readRoomAccountData.mockResolvedValue([ + { + type, + room_id: roomId, + content: {}, + }, + ]); - await loadIframe(); + const event: IReadRoomAccountDataFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.BeeperReadRoomAccountData, + data: { + room_ids: [roomId], + type, + }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe([]); // Without the required capability - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unexpected error while reading relations" }, - }); - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.readEventRelations.mockRejectedValue( - new CustomMatrixError("failed to read relations", 403, "M_FORBIDDEN", { - reason: "You don't have permission to access that event", - }), - ); - - const event: IReadRelationsFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { event_id: "$event" }, - }; - - await loadIframe(); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Unexpected error while reading relations", - matrix_api_error: { - http_status: 403, - http_headers: {}, - url: "", - response: { - errcode: "M_FORBIDDEN", - error: "failed to read relations", - reason: "You don't have permission to access that event", - }, - } satisfies IMatrixApiError, - }, - }); - }); + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + error: { message: "Cannot read room account data of this type" }, }); + }); + + expect(driver.readRoomAccountData).toHaveBeenCalledWith(event.data.type); + }); + }); + + describe("org.matrix.msc2876.read_events action", () => { + it("reads events from a specific room", async () => { + const roomId = "!room:example.org"; + const event = createRoomEvent({ + room_id: roomId, + type: "net.example.test", + content: "test", + }); + driver.readRoomTimeline.mockImplementation(async (rId) => { + if (rId === roomId) return [event]; + return []; + }); + + const request: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + room_ids: [roomId], + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + "org.matrix.msc2762.receive.event:net.example.test", + ]); + clientWidgetApi.setViewedRoomId(roomId); + + emitEvent(new CustomEvent("", { detail: request })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(request, { + events: [event], + }); + }); + + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + roomId, + "net.example.test", + undefined, + undefined, + 0, + undefined, + ); }); - describe("org.matrix.msc3973.user_directory_search action", () => { - it("should present as supported api version", () => { - const event: ISupportedVersionsActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SupportedApiVersions, - data: {}, - }; + it("reads events from all rooms", async () => { + const roomId = "!room:example.org"; + const otherRoomId = "!other-room:example.org"; + const event = createRoomEvent({ + room_id: roomId, + type: "net.example.test", + content: "test", + }); + const otherRoomEvent = createRoomEvent({ + room_id: otherRoomId, + type: "net.example.test", + content: "hi", + }); + driver.getKnownRooms.mockReturnValue([roomId, otherRoomId]); + driver.readRoomTimeline.mockImplementation(async (rId) => { + if (rId === roomId) return [event]; + if (rId === otherRoomId) return [otherRoomEvent]; + return []; + }); + + const request: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + room_ids: Symbols.AnyRoom, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, + "org.matrix.msc2762.receive.event:net.example.test", + ]); + clientWidgetApi.setViewedRoomId(roomId); + + emitEvent(new CustomEvent("", { detail: request })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(request, { + events: [event, otherRoomEvent], + }); + }); + + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + roomId, + "net.example.test", + undefined, + undefined, + 0, + undefined, + ); + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + otherRoomId, + "net.example.test", + undefined, + undefined, + 0, + undefined, + ); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("reads state events with any state key", async () => { + driver.readRoomTimeline.mockResolvedValue([ + createRoomEvent({ type: "net.example.test", state_key: "A" }), + createRoomEvent({ type: "net.example.test", state_key: "B" }), + ]); + + const event: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + state_key: true, + }, + }; + + await loadIframe([ + "org.matrix.msc2762.receive.state_event:net.example.test", + ]); + clientWidgetApi.setViewedRoomId("!room-id"); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + events: [ + createRoomEvent({ type: "net.example.test", state_key: "A" }), + createRoomEvent({ type: "net.example.test", state_key: "B" }), + ], + }); + }); + + expect(driver.readRoomTimeline).toBeCalledWith( + "!room-id", + "net.example.test", + undefined, + undefined, + 0, + undefined, + ); + }); - expect(transport.reply).toBeCalledWith(event, { - supported_versions: expect.arrayContaining([UnstableApiVersion.MSC3973]), - }); - }); + it("fails to read state events with any state key", async () => { + const event: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + state_key: true, + }, + }; - it("should handle and process the request", async () => { - driver.searchUserDirectory.mockResolvedValue({ - limited: true, - results: [ - { - userId: "@foo:bar.com", - }, - ], - }); - - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { search_term: "foo" }, - }; - - await loadIframe(["org.matrix.msc3973.user_directory_search"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - limited: true, - results: [ - { - user_id: "@foo:bar.com", - display_name: undefined, - avatar_url: undefined, - }, - ], - }); - }); - - expect(driver.searchUserDirectory).toBeCalledWith("foo", undefined); - }); + await loadIframe([]); // Without the required capability - it("should accept all options and pass it to the driver", async () => { - driver.searchUserDirectory.mockResolvedValue({ - limited: false, - results: [ - { - userId: "@foo:bar.com", - }, - { - userId: "@bar:foo.com", - displayName: "Bar", - avatarUrl: "mxc://...", - }, - ], - }); - - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { - search_term: "foo", - limit: 5, - }, - }; - - await loadIframe(["org.matrix.msc3973.user_directory_search"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - limited: false, - results: [ - { - user_id: "@foo:bar.com", - display_name: undefined, - avatar_url: undefined, - }, - { - user_id: "@bar:foo.com", - display_name: "Bar", - avatar_url: "mxc://...", - }, - ], - }); - }); - - expect(driver.searchUserDirectory).toBeCalledWith("foo", 5); + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, }); + }); - it("should accept empty search_term", async () => { - driver.searchUserDirectory.mockResolvedValue({ - limited: false, - results: [], - }); + expect(driver.readRoomTimeline).not.toBeCalled(); + }); - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { search_term: "" }, - }; + it("reads state events with a specific state key", async () => { + driver.readRoomTimeline.mockResolvedValue([ + createRoomEvent({ type: "net.example.test", state_key: "B" }), + ]); + + const event: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + state_key: "B", + }, + }; + + await loadIframe([ + "org.matrix.msc2762.receive.state_event:net.example.test#B", + ]); + clientWidgetApi.setViewedRoomId("!room-id"); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + events: [ + createRoomEvent({ type: "net.example.test", state_key: "B" }), + ], + }); + }); + + expect(driver.readRoomTimeline).toBeCalledWith( + "!room-id", + "net.example.test", + undefined, + "B", + 0, + undefined, + ); + }); - await loadIframe(["org.matrix.msc3973.user_directory_search"]); + it("fails to read state events with a specific state key", async () => { + const event: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: "net.example.test", + state_key: "B", + }, + }; - emitEvent(new CustomEvent("", { detail: event })); + // Request the capability for the wrong state key + await loadIframe([ + "org.matrix.msc2762.receive.state_event:net.example.test#A", + ]); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - limited: false, - results: [], - }); - }); + emitEvent(new CustomEvent("", { detail: event })); - expect(driver.searchUserDirectory).toBeCalledWith("", undefined); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, }); + }); - it("should reject requests when the capability was not requested", async () => { - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { search_term: "foo" }, - }; + expect(driver.readRoomTimeline).not.toBeCalled(); + }); + }); + + describe("org.matrix.msc3869.read_relations action", () => { + it("should present as supported api version", () => { + const event: ISupportedVersionsActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SupportedApiVersions, + data: {}, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + supported_versions: expect.arrayContaining([ + UnstableApiVersion.MSC3869, + ]), + }); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("should handle and process the request", async () => { + driver.readEventRelations.mockResolvedValue({ + chunk: [createRoomEvent()], + }); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: "$event" }, + }; + + await loadIframe(["org.matrix.msc2762.receive.event:m.room.message"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + chunk: [createRoomEvent()], + }); + }); + + expect(driver.readEventRelations).toBeCalledWith( + "$event", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + }); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Missing capability" }, - }); + it("should only return events that match requested capabilities", async () => { + driver.readEventRelations.mockResolvedValue({ + chunk: [ + createRoomEvent(), + createRoomEvent({ type: "m.reaction" }), + createRoomEvent({ type: "net.example.test", state_key: "A" }), + createRoomEvent({ type: "net.example.test", state_key: "B" }), + ], + }); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: "$event" }, + }; + + await loadIframe([ + "org.matrix.msc2762.receive.event:m.room.message", + "org.matrix.msc2762.receive.state_event:net.example.test#A", + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + chunk: [ + createRoomEvent(), + createRoomEvent({ type: "net.example.test", state_key: "A" }), + ], + }); + }); + + expect(driver.readEventRelations).toBeCalledWith( + "$event", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + }); - expect(driver.searchUserDirectory).not.toBeCalled(); - }); + it("should accept all options and pass it to the driver", async () => { + driver.readEventRelations.mockResolvedValue({ + chunk: [], + }); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: "$event", + room_id: "!room-id", + event_type: "m.room.message", + rel_type: "m.reference", + limit: 25, + from: "from-token", + to: "to-token", + direction: "f", + }, + }; + + await loadIframe(["org.matrix.msc2762.timeline:!room-id"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + chunk: [], + }); + }); + + expect(driver.readEventRelations).toBeCalledWith( + "$event", + "!room-id", + "m.reference", + "m.room.message", + "from-token", + "to-token", + 25, + "f", + ); + }); - it("should reject requests without search_term", async () => { - const event: IWidgetApiRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: {}, - }; + it("should reject requests without event_id", async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: {}, + }; - await loadIframe(["org.matrix.msc3973.user_directory_search"]); + emitEvent(new CustomEvent("", { detail: event })); - emitEvent(new CustomEvent("", { detail: event })); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - missing event ID" }, + }); + }); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - missing search term" }, - }); + it("should reject requests with a negative limit", async () => { + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: "$event", + limit: -1, + }, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - limit out of range" }, + }); + }); - expect(driver.searchUserDirectory).not.toBeCalled(); - }); + it("should reject requests when the room timeline was not requested", async () => { + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: "$event", + room_id: "!another-room-id", + }, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Unable to access room timeline: !another-room-id" }, + }); + }); - it("should reject requests with a negative limit", async () => { - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { - search_term: "foo", - limit: -1, - }, - }; + it("should reject requests when the driver throws an exception", async () => { + driver.readEventRelations.mockRejectedValue( + new Error( + "M_FORBIDDEN: You don't have permission to access that event", + ), + ); - await loadIframe(["org.matrix.msc3973.user_directory_search"]); + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: "$event" }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Invalid request - limit out of range" }, - }); + emitEvent(new CustomEvent("", { detail: event })); - expect(driver.searchUserDirectory).not.toBeCalled(); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Unexpected error while reading relations" }, }); + }); + }); - it("should reject requests when the driver throws an exception", async () => { - driver.searchUserDirectory.mockRejectedValue(new Error("M_LIMIT_EXCEEDED: Too many requests")); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.readEventRelations.mockRejectedValue( + new CustomMatrixError("failed to read relations", 403, "M_FORBIDDEN", { + reason: "You don't have permission to access that event", + }), + ); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: "$event" }, + }; + + await loadIframe(); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while reading relations", + matrix_api_error: { + http_status: 403, + http_headers: {}, + url: "", + response: { + errcode: "M_FORBIDDEN", + error: "failed to read relations", + reason: "You don't have permission to access that event", + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + + describe("org.matrix.msc3973.user_directory_search action", () => { + it("should present as supported api version", () => { + const event: ISupportedVersionsActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SupportedApiVersions, + data: {}, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + supported_versions: expect.arrayContaining([ + UnstableApiVersion.MSC3973, + ]), + }); + }); - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { search_term: "foo" }, - }; + it("should handle and process the request", async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: true, + results: [ + { + userId: "@foo:bar.com", + }, + ], + }); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: "foo" }, + }; + + await loadIframe(["org.matrix.msc3973.user_directory_search"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: true, + results: [ + { + user_id: "@foo:bar.com", + display_name: undefined, + avatar_url: undefined, + }, + ], + }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith("foo", undefined); + }); - await loadIframe(["org.matrix.msc3973.user_directory_search"]); + it("should accept all options and pass it to the driver", async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: false, + results: [ + { + userId: "@foo:bar.com", + }, + { + userId: "@bar:foo.com", + displayName: "Bar", + avatarUrl: "mxc://...", + }, + ], + }); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { + search_term: "foo", + limit: 5, + }, + }; + + await loadIframe(["org.matrix.msc3973.user_directory_search"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: false, + results: [ + { + user_id: "@foo:bar.com", + display_name: undefined, + avatar_url: undefined, + }, + { + user_id: "@bar:foo.com", + display_name: "Bar", + avatar_url: "mxc://...", + }, + ], + }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith("foo", 5); + }); - emitEvent(new CustomEvent("", { detail: event })); + it("should accept empty search_term", async () => { + driver.searchUserDirectory.mockResolvedValue({ + limited: false, + results: [], + }); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unexpected error while searching in the user directory" }, - }); - }); - }); + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: "" }, + }; - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.searchUserDirectory.mockRejectedValue( - new CustomMatrixError("failed to search the user directory", 429, "M_LIMIT_EXCEEDED", { - reason: "Too many requests", - retry_after_ms: 2000, - }), - ); - - const event: IUserDirectorySearchFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { search_term: "foo" }, - }; - - await loadIframe(["org.matrix.msc3973.user_directory_search"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Unexpected error while searching in the user directory", - matrix_api_error: { - http_status: 429, - http_headers: {}, - url: "", - response: { - errcode: "M_LIMIT_EXCEEDED", - error: "failed to search the user directory", - reason: "Too many requests", - retry_after_ms: 2000, - }, - } satisfies IMatrixApiError, - }, - }); - }); + await loadIframe(["org.matrix.msc3973.user_directory_search"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + limited: false, + results: [], }); + }); + + expect(driver.searchUserDirectory).toBeCalledWith("", undefined); }); - describe("org.matrix.msc4039.get_media_config action", () => { - it("should present as supported api version", () => { - const event: ISupportedVersionsActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SupportedApiVersions, - data: {}, - }; + it("should reject requests when the capability was not requested", async () => { + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: "foo" }, + }; - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { - supported_versions: expect.arrayContaining([UnstableApiVersion.MSC4039]), - }); - }); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Missing capability" }, + }); - it("should handle and process the request", async () => { - driver.getMediaConfig.mockResolvedValue({ - "m.upload.size": 1000, - }); + expect(driver.searchUserDirectory).not.toBeCalled(); + }); - const event: IGetMediaConfigActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: {}, - }; + it("should reject requests without search_term", async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: {}, + }; - await loadIframe(["org.matrix.msc4039.upload_file"]); + await loadIframe(["org.matrix.msc3973.user_directory_search"]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - "m.upload.size": 1000, - }); - }); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - missing search term" }, + }); - expect(driver.getMediaConfig).toBeCalled(); - }); + expect(driver.searchUserDirectory).not.toBeCalled(); + }); - it("should reject requests when the capability was not requested", async () => { - const event: IGetMediaConfigActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: {}, - }; + it("should reject requests with a negative limit", async () => { + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { + search_term: "foo", + limit: -1, + }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(["org.matrix.msc3973.user_directory_search"]); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Missing capability" }, - }); + emitEvent(new CustomEvent("", { detail: event })); - expect(driver.getMediaConfig).not.toBeCalled(); - }); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Invalid request - limit out of range" }, + }); - it("should reject requests when the driver throws an exception", async () => { - driver.getMediaConfig.mockRejectedValue(new Error("M_LIMIT_EXCEEDED: Too many requests")); + expect(driver.searchUserDirectory).not.toBeCalled(); + }); - const event: IGetMediaConfigActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: {}, - }; + it("should reject requests when the driver throws an exception", async () => { + driver.searchUserDirectory.mockRejectedValue( + new Error("M_LIMIT_EXCEEDED: Too many requests"), + ); - await loadIframe(["org.matrix.msc4039.upload_file"]); + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: "foo" }, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(["org.matrix.msc3973.user_directory_search"]); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unexpected error while getting the media configuration" }, - }); - }); + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while searching in the user directory", + }, }); + }); + }); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.getMediaConfig.mockRejectedValue( - new CustomMatrixError("failed to get the media configuration", 429, "M_LIMIT_EXCEEDED", { - reason: "Too many requests", - retry_after_ms: 2000, - }), - ); - - const event: IGetMediaConfigActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: {}, - }; - - await loadIframe(["org.matrix.msc4039.upload_file"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Unexpected error while getting the media configuration", - matrix_api_error: { - http_status: 429, - http_headers: {}, - url: "", - response: { - errcode: "M_LIMIT_EXCEEDED", - error: "failed to get the media configuration", - reason: "Too many requests", - retry_after_ms: 2000, - }, - } satisfies IMatrixApiError, - }, - }); - }); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.searchUserDirectory.mockRejectedValue( + new CustomMatrixError( + "failed to search the user directory", + 429, + "M_LIMIT_EXCEEDED", + { + reason: "Too many requests", + retry_after_ms: 2000, + }, + ), + ); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: "foo" }, + }; + + await loadIframe(["org.matrix.msc3973.user_directory_search"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while searching in the user directory", + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: "", + response: { + errcode: "M_LIMIT_EXCEEDED", + error: "failed to search the user directory", + reason: "Too many requests", + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); + + describe("org.matrix.msc4039.get_media_config action", () => { + it("should present as supported api version", () => { + const event: ISupportedVersionsActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SupportedApiVersions, + data: {}, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + supported_versions: expect.arrayContaining([ + UnstableApiVersion.MSC4039, + ]), + }); }); - describe("MSC4039", () => { - it("should present as supported api version", () => { - const event: ISupportedVersionsActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SupportedApiVersions, - data: {}, - }; + it("should handle and process the request", async () => { + driver.getMediaConfig.mockResolvedValue({ + "m.upload.size": 1000, + }); + + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; - emitEvent(new CustomEvent("", { detail: event })); + await loadIframe(["org.matrix.msc4039.upload_file"]); - expect(transport.reply).toBeCalledWith(event, { - supported_versions: expect.arrayContaining([UnstableApiVersion.MSC4039]), - }); + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + "m.upload.size": 1000, }); + }); + + expect(driver.getMediaConfig).toBeCalled(); }); - describe("org.matrix.msc4039.upload_file action", () => { - it("should handle and process the request", async () => { - driver.uploadFile.mockResolvedValue({ - contentUri: "mxc://...", - }); + it("should reject requests when the capability was not requested", async () => { + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; - const event: IUploadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data: { - file: "data", - }, - }; + emitEvent(new CustomEvent("", { detail: event })); - await loadIframe(["org.matrix.msc4039.upload_file"]); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Missing capability" }, + }); - emitEvent(new CustomEvent("", { detail: event })); + expect(driver.getMediaConfig).not.toBeCalled(); + }); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - content_uri: "mxc://...", - }); - }); + it("should reject requests when the driver throws an exception", async () => { + driver.getMediaConfig.mockRejectedValue( + new Error("M_LIMIT_EXCEEDED: Too many requests"), + ); - expect(driver.uploadFile).toBeCalled(); - }); + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; - it("should reject requests when the capability was not requested", async () => { - const event: IUploadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data: { - file: "data", - }, - }; + await loadIframe(["org.matrix.msc4039.upload_file"]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Missing capability" }, - }); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while getting the media configuration", + }, + }); + }); + }); - expect(driver.uploadFile).not.toBeCalled(); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.getMediaConfig.mockRejectedValue( + new CustomMatrixError( + "failed to get the media configuration", + 429, + "M_LIMIT_EXCEEDED", + { + reason: "Too many requests", + retry_after_ms: 2000, + }, + ), + ); + + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; + + await loadIframe(["org.matrix.msc4039.upload_file"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while getting the media configuration", + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: "", + response: { + errcode: "M_LIMIT_EXCEEDED", + error: "failed to get the media configuration", + reason: "Too many requests", + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); + + describe("MSC4039", () => { + it("should present as supported api version", () => { + const event: ISupportedVersionsActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SupportedApiVersions, + data: {}, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + supported_versions: expect.arrayContaining([ + UnstableApiVersion.MSC4039, + ]), + }); + }); + }); - it("should reject requests when the driver throws an exception", async () => { - driver.uploadFile.mockRejectedValue(new Error("M_LIMIT_EXCEEDED: Too many requests")); + describe("org.matrix.msc4039.upload_file action", () => { + it("should handle and process the request", async () => { + driver.uploadFile.mockResolvedValue({ + contentUri: "mxc://...", + }); - const event: IUploadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data: { - file: "data", - }, - }; + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: "data", + }, + }; - await loadIframe(["org.matrix.msc4039.upload_file"]); + await loadIframe(["org.matrix.msc4039.upload_file"]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unexpected error while uploading a file" }, - }); - }); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + content_uri: "mxc://...", }); + }); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.uploadFile.mockRejectedValue( - new CustomMatrixError("failed to upload a file", 429, "M_LIMIT_EXCEEDED", { - reason: "Too many requests", - retry_after_ms: 2000, - }), - ); - - const event: IUploadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data: { - file: "data", - }, - }; - - await loadIframe(["org.matrix.msc4039.upload_file"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Unexpected error while uploading a file", - matrix_api_error: { - http_status: 429, - http_headers: {}, - url: "", - response: { - errcode: "M_LIMIT_EXCEEDED", - error: "failed to upload a file", - reason: "Too many requests", - retry_after_ms: 2000, - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); + expect(driver.uploadFile).toBeCalled(); }); - describe("org.matrix.msc4039.download_file action", () => { - it("should handle and process the request", async () => { - driver.downloadFile.mockResolvedValue({ - file: "test contents", - }); + it("should reject requests when the capability was not requested", async () => { + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: "data", + }, + }; - const event: IDownloadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data: { - content_uri: "mxc://example.com/test_file", - }, - }; + emitEvent(new CustomEvent("", { detail: event })); - await loadIframe(["org.matrix.msc4039.download_file"]); + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Missing capability" }, + }); - emitEvent(new CustomEvent("", { detail: event })); + expect(driver.uploadFile).not.toBeCalled(); + }); - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - file: "test contents", - }); - }); + it("should reject requests when the driver throws an exception", async () => { + driver.uploadFile.mockRejectedValue( + new Error("M_LIMIT_EXCEEDED: Too many requests"), + ); - expect(driver.downloadFile).toHaveBeenCalledWith("mxc://example.com/test_file"); - }); + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: "data", + }, + }; - it("should reject requests when the capability was not requested", async () => { - const event: IDownloadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data: { - content_uri: "mxc://example.com/test_file", - }, - }; + await loadIframe(["org.matrix.msc4039.upload_file"]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Missing capability" }, - }); + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Unexpected error while uploading a file" }, + }); + }); + }); - expect(driver.uploadFile).not.toBeCalled(); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.uploadFile.mockRejectedValue( + new CustomMatrixError( + "failed to upload a file", + 429, + "M_LIMIT_EXCEEDED", + { + reason: "Too many requests", + retry_after_ms: 2000, + }, + ), + ); + + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: "data", + }, + }; + + await loadIframe(["org.matrix.msc4039.upload_file"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while uploading a file", + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: "", + response: { + errcode: "M_LIMIT_EXCEEDED", + error: "failed to upload a file", + reason: "Too many requests", + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, }); + }); + }); + }); - it("should reject requests when the driver throws an exception", async () => { - driver.downloadFile.mockRejectedValue(new Error("M_LIMIT_EXCEEDED: Too many requests")); + describe("org.matrix.msc4039.download_file action", () => { + it("should handle and process the request", async () => { + driver.downloadFile.mockResolvedValue({ + file: "test contents", + }); - const event: IDownloadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data: { - content_uri: "mxc://example.com/test_file", - }, - }; + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: "mxc://example.com/test_file", + }, + }; - await loadIframe(["org.matrix.msc4039.download_file"]); + await loadIframe(["org.matrix.msc4039.download_file"]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { message: "Unexpected error while downloading a file" }, - }); - }); + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + file: "test contents", }); + }); - it("should reject with Matrix API error response thrown by driver", async () => { - driver.processError.mockImplementation(processCustomMatrixError); - - driver.downloadFile.mockRejectedValue( - new CustomMatrixError("failed to download a file", 429, "M_LIMIT_EXCEEDED", { - reason: "Too many requests", - retry_after_ms: 2000, - }), - ); - - const event: IDownloadFileActionFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data: { - content_uri: "mxc://example.com/test_file", - }, - }; - - await loadIframe(["org.matrix.msc4039.download_file"]); - - emitEvent(new CustomEvent("", { detail: event })); - - await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { - error: { - message: "Unexpected error while downloading a file", - matrix_api_error: { - http_status: 429, - http_headers: {}, - url: "", - response: { - errcode: "M_LIMIT_EXCEEDED", - error: "failed to download a file", - reason: "Too many requests", - retry_after_ms: 2000, - }, - } satisfies IMatrixApiError, - }, - }); - }); - }); + expect(driver.downloadFile).toHaveBeenCalledWith( + "mxc://example.com/test_file", + ); }); - it("updates theme", () => { - clientWidgetApi.updateTheme({ name: "dark" }); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.ThemeChange, { name: "dark" }); + it("should reject requests when the capability was not requested", async () => { + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: "mxc://example.com/test_file", + }, + }; + + emitEvent(new CustomEvent("", { detail: event })); + + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Missing capability" }, + }); + + expect(driver.uploadFile).not.toBeCalled(); + }); + + it("should reject requests when the driver throws an exception", async () => { + driver.downloadFile.mockRejectedValue( + new Error("M_LIMIT_EXCEEDED: Too many requests"), + ); + + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: "mxc://example.com/test_file", + }, + }; + + await loadIframe(["org.matrix.msc4039.download_file"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: "Unexpected error while downloading a file" }, + }); + }); }); - it("updates language", () => { - clientWidgetApi.updateLanguage("tlh"); - expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.LanguageChange, { lang: "tlh" }); + it("should reject with Matrix API error response thrown by driver", async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.downloadFile.mockRejectedValue( + new CustomMatrixError( + "failed to download a file", + 429, + "M_LIMIT_EXCEEDED", + { + reason: "Too many requests", + retry_after_ms: 2000, + }, + ), + ); + + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: "mxc://example.com/test_file", + }, + }; + + await loadIframe(["org.matrix.msc4039.download_file"]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: "Unexpected error while downloading a file", + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: "", + response: { + errcode: "M_LIMIT_EXCEEDED", + error: "failed to download a file", + reason: "Too many requests", + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); }); + }); + + it("updates theme", () => { + clientWidgetApi.updateTheme({ name: "dark" }); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.ThemeChange, + { name: "dark" }, + ); + }); + + it("updates language", () => { + clientWidgetApi.updateLanguage("tlh"); + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.LanguageChange, + { lang: "tlh" }, + ); + }); }); diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index b128e1c..c3870ab 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -26,731 +26,831 @@ import { IUserDirectorySearchFromWidgetResponseData } from "../src/interfaces/Us import { WidgetApiFromWidgetAction } from "../src/interfaces/WidgetApiAction"; import { WidgetApi, WidgetApiResponseError } from "../src/WidgetApi"; import { - IWidgetApiErrorResponseData, - IWidgetApiErrorResponseDataDetails, - IWidgetApiRequest, - IWidgetApiRequestData, - IWidgetApiResponse, - IWidgetApiResponseData, - UpdateDelayedEventAction, - WidgetApiDirection, + IWidgetApiErrorResponseData, + IWidgetApiErrorResponseDataDetails, + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiResponse, + IWidgetApiResponseData, + UpdateDelayedEventAction, + WidgetApiDirection, } from "../src"; type SendRequestArgs = { - action: WidgetApiFromWidgetAction; - data: IWidgetApiRequestData; + action: WidgetApiFromWidgetAction; + data: IWidgetApiRequestData; }; class TransportChannels { - /** Data sent by widget requests */ - public readonly requestQueue: Array = []; - /** Responses to send as if from a client. Initialized with the response to {@link WidgetApi.start}*/ - public readonly responseQueue: IWidgetApiResponseData[] = [ - { supported_versions: [] } satisfies ISupportedVersionsActionResponseData, - ]; + /** Data sent by widget requests */ + public readonly requestQueue: Array = []; + /** Responses to send as if from a client. Initialized with the response to {@link WidgetApi.start}*/ + public readonly responseQueue: IWidgetApiResponseData[] = [ + { supported_versions: [] } satisfies ISupportedVersionsActionResponseData, + ]; } class WidgetTransportHelper { - /** For ignoring the request sent by {@link WidgetApi.start} */ - private skippedFirstRequest = false; + /** For ignoring the request sent by {@link WidgetApi.start} */ + private skippedFirstRequest = false; - public constructor(private channels: TransportChannels) {} + public constructor(private channels: TransportChannels) {} - public nextTrackedRequest(): SendRequestArgs | undefined { - if (!this.skippedFirstRequest) { - this.skippedFirstRequest = true; - this.channels.requestQueue.shift(); - } - return this.channels.requestQueue.shift(); + public nextTrackedRequest(): SendRequestArgs | undefined { + if (!this.skippedFirstRequest) { + this.skippedFirstRequest = true; + this.channels.requestQueue.shift(); } + return this.channels.requestQueue.shift(); + } - public queueResponse(data: IWidgetApiResponseData): void { - this.channels.responseQueue.push(data); - } + public queueResponse(data: IWidgetApiResponseData): void { + this.channels.responseQueue.push(data); + } } class ClientTransportHelper { - public constructor(private channels: TransportChannels) {} - - public trackRequest(action: WidgetApiFromWidgetAction, data: IWidgetApiRequestData): void { - this.channels.requestQueue.push({ action, data }); - } - - public nextQueuedResponse(): IWidgetApiRequestData | undefined { - return this.channels.responseQueue.shift(); - } + public constructor(private channels: TransportChannels) {} + + public trackRequest( + action: WidgetApiFromWidgetAction, + data: IWidgetApiRequestData, + ): void { + this.channels.requestQueue.push({ action, data }); + } + + public nextQueuedResponse(): IWidgetApiRequestData | undefined { + return this.channels.responseQueue.shift(); + } } describe("WidgetApi", () => { - let widgetApi: WidgetApi; - let widgetTransportHelper: WidgetTransportHelper; - let clientListener: (e: MessageEvent) => void; + let widgetApi: WidgetApi; + let widgetTransportHelper: WidgetTransportHelper; + let clientListener: (e: MessageEvent) => void; + + beforeEach(() => { + const channels = new TransportChannels(); + widgetTransportHelper = new WidgetTransportHelper(channels); + const clientTrafficHelper = new ClientTransportHelper(channels); + + clientListener = (e: MessageEvent): void => { + if (!e.data.action || !e.data.requestId || !e.data.widgetId) return; // invalid request/response + if ("response" in e.data || e.data.api !== WidgetApiDirection.FromWidget) + return; // not a request + const request = e.data; + + clientTrafficHelper.trackRequest( + request.action as WidgetApiFromWidgetAction, + request.data, + ); + + const response = clientTrafficHelper.nextQueuedResponse(); + if (response) { + window.postMessage( + { + ...request, + response: response, + } satisfies IWidgetApiResponse, + "*", + ); + } + }; + window.addEventListener("message", clientListener); + + widgetApi = new WidgetApi("WidgetApi-test", "*"); + widgetApi.start(); + }); + + afterEach(() => { + window.removeEventListener("message", clientListener); + }); + + describe("readEventRelations", () => { + it("should forward the request to the ClientWidgetApi", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3869], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + chunk: [], + } as IReadRelationsFromWidgetResponseData); + + await expect( + widgetApi.readEventRelations( + "$event", + "!room-id", + "m.reference", + "m.room.message", + 25, + "from-token", + "to-token", + "f", + ), + ).resolves.toEqual({ + chunk: [], + }); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: "$event", + room_id: "!room-id", + rel_type: "m.reference", + event_type: "m.room.message", + limit: 25, + from: "from-token", + to: "to-token", + direction: "f", + }, + } satisfies SendRequestArgs); + }); + + it("should reject the request if the api is not supported", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [], + } as ISupportedVersionsActionResponseData); + + await expect( + widgetApi.readEventRelations( + "$event", + "!room-id", + "m.reference", + "m.room.message", + 25, + "from-token", + "to-token", + "f", + ), + ).rejects.toThrow( + "The read_relations action is not supported by the client.", + ); + + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: expect.anything(), + } satisfies SendRequestArgs); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3869], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.readEventRelations( + "$event", + "!room-id", + "m.reference", + "m.room.message", + 25, + "from-token", + "to-token", + "f", + ), + ).rejects.toThrow("An error occurred"); + }); + + it("should handle an error with details", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3869], + } as ISupportedVersionsActionResponseData); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.readEventRelations( + "$event", + "!room-id", + "m.reference", + "m.room.message", + 25, + "from-token", + "to-token", + "f", + ), + ).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("sendEvent", () => { + it("sends message events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + event_id: "$event", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id"), + ).resolves.toEqual({ + room_id: "!room-id", + event_id: "$event", + }); + }); + + it("sends state events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + event_id: "$event", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id"), + ).resolves.toEqual({ + room_id: "!room-id", + event_id: "$event", + }); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id"), + ).rejects.toThrow("An error occurred"); + }); + it("should handle an error with details", async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id"), + ).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("delayed sendEvent", () => { + it("sends delayed message events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + delay_id: "id", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 2000), + ).resolves.toEqual({ + room_id: "!room-id", + delay_id: "id", + }); + }); + + it("sends delayed state events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + delay_id: "id", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id", 2000), + ).resolves.toEqual({ + room_id: "!room-id", + delay_id: "id", + }); + }); + + it("sends delayed child action message events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + delay_id: "id", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendRoomEvent( + "m.room.message", + {}, + "!room-id", + 1000, + undefined, + ), + ).resolves.toEqual({ + room_id: "!room-id", + delay_id: "id", + }); + }); + + it("sends delayed child action state events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + delay_id: "id", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendStateEvent( + "m.room.topic", + "", + {}, + "!room-id", + 1000, + undefined, + ), + ).resolves.toEqual({ + room_id: "!room-id", + delay_id: "id", + }); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.sendRoomEvent( + "m.room.message", + {}, + "!room-id", + 1000, + undefined, + ), + ).rejects.toThrow("An error occurred"); + }); + + it("should handle an error with details", async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.sendRoomEvent( + "m.room.message", + {}, + "!room-id", + 1000, + undefined, + ), + ).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("updateDelayedEvent", () => { + it("updates delayed events", async () => { + widgetTransportHelper.queueResponse({}); + await expect( + widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send), + ).resolves.toEqual({}); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send), + ).rejects.toThrow("An error occurred"); + }); + + it("should handle an error with details", async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send), + ).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("getClientVersions", () => { beforeEach(() => { - const channels = new TransportChannels(); - widgetTransportHelper = new WidgetTransportHelper(channels); - const clientTrafficHelper = new ClientTransportHelper(channels); - - clientListener = (e: MessageEvent): void => { - if (!e.data.action || !e.data.requestId || !e.data.widgetId) return; // invalid request/response - if ("response" in e.data || e.data.api !== WidgetApiDirection.FromWidget) return; // not a request - const request = e.data; - - clientTrafficHelper.trackRequest(request.action as WidgetApiFromWidgetAction, request.data); - - const response = clientTrafficHelper.nextQueuedResponse(); - if (response) { - window.postMessage( - { - ...request, - response: response, - } satisfies IWidgetApiResponse, - "*", - ); - } - }; - window.addEventListener("message", clientListener); - - widgetApi = new WidgetApi("WidgetApi-test", "*"); - widgetApi.start(); - }); - - afterEach(() => { - window.removeEventListener("message", clientListener); - }); - - describe("readEventRelations", () => { - it("should forward the request to the ClientWidgetApi", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3869], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ - chunk: [], - } as IReadRelationsFromWidgetResponseData); - - await expect( - widgetApi.readEventRelations( - "$event", - "!room-id", - "m.reference", - "m.room.message", - 25, - "from-token", - "to-token", - "f", - ), - ).resolves.toEqual({ - chunk: [], - }); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: { - event_id: "$event", - room_id: "!room-id", - rel_type: "m.reference", - event_type: "m.room.message", - limit: 25, - from: "from-token", - to: "to-token", - direction: "f", - }, - } satisfies SendRequestArgs); - }); - - it("should reject the request if the api is not supported", async () => { - widgetTransportHelper.queueResponse({ supported_versions: [] } as ISupportedVersionsActionResponseData); - - await expect( - widgetApi.readEventRelations( - "$event", - "!room-id", - "m.reference", - "m.room.message", - 25, - "from-token", - "to-token", - "f", - ), - ).rejects.toThrow("The read_relations action is not supported by the client."); - - const request = widgetTransportHelper.nextTrackedRequest(); - expect(request).not.toBeUndefined(); - expect(request).not.toEqual({ - action: WidgetApiFromWidgetAction.MSC3869ReadRelations, - data: expect.anything(), - } satisfies SendRequestArgs); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3869], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ - error: { message: "An error occurred" }, - } as IWidgetApiErrorResponseData); - - await expect( - widgetApi.readEventRelations( - "$event", - "!room-id", - "m.reference", - "m.room.message", - 25, - "from-token", - "to-token", - "f", - ), - ).rejects.toThrow("An error occurred"); - }); - - it("should handle an error with details", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3869], - } as ISupportedVersionsActionResponseData); - - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect( - widgetApi.readEventRelations( - "$event", - "!room-id", - "m.reference", - "m.room.message", - 25, - "from-token", - "to-token", - "f", - ), - ).rejects.toThrow(new WidgetApiResponseError("An error occurred", errorDetails)); - }); - }); - - describe("sendEvent", () => { - it("sends message events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - event_id: "$event", - } as ISendEventFromWidgetResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id")).resolves.toEqual({ - room_id: "!room-id", - event_id: "$event", - }); - }); - - it("sends state events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - event_id: "$event", - } as ISendEventFromWidgetResponseData); - - await expect(widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id")).resolves.toEqual({ - room_id: "!room-id", - event_id: "$event", - }); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - error: { message: "An error occurred" }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id")).rejects.toThrow( - "An error occurred", - ); - }); - - it("should handle an error with details", async () => { - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id")).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("delayed sendEvent", () => { - it("sends delayed message events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - delay_id: "id", - } as ISendEventFromWidgetResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 2000)).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); - }); - - it("sends delayed state events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - delay_id: "id", - } as ISendEventFromWidgetResponseData); - - await expect(widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id", 2000)).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); - }); - - it("sends delayed child action message events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - delay_id: "id", - } as ISendEventFromWidgetResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); - }); - - it("sends delayed child action state events", async () => { - widgetTransportHelper.queueResponse({ - room_id: "!room-id", - delay_id: "id", - } as ISendEventFromWidgetResponseData); - - await expect( - widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id", 1000, undefined), - ).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - error: { message: "An error occurred" }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).rejects.toThrow( - "An error occurred", - ); - }); - - it("should handle an error with details", async () => { - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("updateDelayedEvent", () => { - it("updates delayed events", async () => { - widgetTransportHelper.queueResponse({}); - await expect(widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send)).resolves.toEqual({}); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - error: { message: "An error occurred" }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send)).rejects.toThrow( - "An error occurred", - ); - }); - - it("should handle an error with details", async () => { - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.updateDelayedEvent("id", UpdateDelayedEventAction.Send)).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("getClientVersions", () => { - beforeEach(() => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3869, UnstableApiVersion.MSC2762], - } as ISupportedVersionsActionResponseData); - }); - - it("should request supported client versions", async () => { - await expect(widgetApi.getClientVersions()).resolves.toEqual(["org.matrix.msc3869", "org.matrix.msc2762"]); - }); - - it("should cache supported client versions on successive calls", async () => { - await expect(widgetApi.getClientVersions()).resolves.toEqual(["org.matrix.msc3869", "org.matrix.msc2762"]); - - await expect(widgetApi.getClientVersions()).resolves.toEqual(["org.matrix.msc3869", "org.matrix.msc2762"]); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toBeUndefined(); - }); - }); - - describe("searchUserDirectory", () => { - it("should forward the request to the ClientWidgetApi", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3973], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ - limited: false, - results: [], - } as IUserDirectorySearchFromWidgetResponseData); - - await expect(widgetApi.searchUserDirectory("foo", 10)).resolves.toEqual({ - limited: false, - results: [], - }); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: { - search_term: "foo", - limit: 10, - }, - } satisfies SendRequestArgs); - }); - - it("should reject the request if the api is not supported", async () => { - widgetTransportHelper.queueResponse({ supported_versions: [] } as ISupportedVersionsActionResponseData); - - await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow( - "The user_directory_search action is not supported by the client.", - ); - - const request = widgetTransportHelper.nextTrackedRequest(); - expect(request).not.toBeUndefined(); - expect(request).not.toEqual({ - action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - data: expect.anything(), - } satisfies SendRequestArgs); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3973], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ error: { message: "An error occurred" } }); - - await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow("An error occurred"); - }); - - it("should handle an error with details", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC3973], - } as ISupportedVersionsActionResponseData); - - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("getMediaConfig", () => { - it("should forward the request to the ClientWidgetApi", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ - "m.upload.size": 1000, - } as IGetMediaConfigActionFromWidgetResponseData); - - await expect(widgetApi.getMediaConfig()).resolves.toEqual({ - "m.upload.size": 1000, - }); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: {}, - } satisfies SendRequestArgs); - }); - - it("should reject the request if the api is not supported", async () => { - widgetTransportHelper.queueResponse({ supported_versions: [] } as ISupportedVersionsActionResponseData); - - await expect(widgetApi.getMediaConfig()).rejects.toThrow( - "The get_media_config action is not supported by the client.", - ); - - const request = widgetTransportHelper.nextTrackedRequest(); - expect(request).not.toBeUndefined(); - expect(request).not.toEqual({ - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: expect.anything(), - } satisfies SendRequestArgs); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ error: { message: "An error occurred" } }); - - await expect(widgetApi.getMediaConfig()).rejects.toThrow("An error occurred"); - }); - - it("should handle an error with details", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.getMediaConfig()).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("uploadFile", () => { - it("should forward the request to the ClientWidgetApi", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ - content_uri: "mxc://...", - } as IUploadFileActionFromWidgetResponseData); - - await expect(widgetApi.uploadFile("data")).resolves.toEqual({ - content_uri: "mxc://...", - }); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ - action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, - data: { file: "data" }, - } satisfies SendRequestArgs); - }); - - it("should reject the request if the api is not supported", async () => { - widgetTransportHelper.queueResponse({ supported_versions: [] } as ISupportedVersionsActionResponseData); - - await expect(widgetApi.uploadFile("data")).rejects.toThrow( - "The upload_file action is not supported by the client.", - ); - - const request = widgetTransportHelper.nextTrackedRequest(); - expect(request).not.toBeUndefined(); - expect(request).not.toEqual({ - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: expect.anything(), - } satisfies SendRequestArgs); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ error: { message: "An error occurred" } }); - - await expect(widgetApi.uploadFile("data")).rejects.toThrow("An error occurred"); - }); - - it("should handle an error with details", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.uploadFile("data")).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); - }); - - describe("downloadFile", () => { - it("should forward the request to the ClientWidgetApi", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ file: "test contents" } as IDownloadFileActionFromWidgetResponseData); - - await expect(widgetApi.downloadFile("mxc://example.com/test_file")).resolves.toEqual({ - file: "test contents", - }); - - expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); - expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ - action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - data: { content_uri: "mxc://example.com/test_file" }, - } satisfies SendRequestArgs); - }); - - it("should reject the request if the api is not supported", async () => { - widgetTransportHelper.queueResponse({ supported_versions: [] } as ISupportedVersionsActionResponseData); - - await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( - "The download_file action is not supported by the client.", - ); - - const request = widgetTransportHelper.nextTrackedRequest(); - expect(request).not.toBeUndefined(); - expect(request).not.toEqual({ - action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - data: expect.anything(), - } satisfies SendRequestArgs); - }); - - it("should handle an error", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - widgetTransportHelper.queueResponse({ error: { message: "An error occurred" } }); - - await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow("An error occurred"); - }); - - it("should handle an error with details", async () => { - widgetTransportHelper.queueResponse({ - supported_versions: [UnstableApiVersion.MSC4039], - } as ISupportedVersionsActionResponseData); - - const errorDetails: IWidgetApiErrorResponseDataDetails = { - matrix_api_error: { - http_status: 400, - http_headers: {}, - url: "", - response: { - errcode: "M_UNKNOWN", - error: "Unknown error", - }, - }, - }; - - widgetTransportHelper.queueResponse({ - error: { - message: "An error occurred", - ...errorDetails, - }, - } as IWidgetApiErrorResponseData); - - await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( - new WidgetApiResponseError("An error occurred", errorDetails), - ); - }); + widgetTransportHelper.queueResponse({ + supported_versions: [ + UnstableApiVersion.MSC3869, + UnstableApiVersion.MSC2762, + ], + } as ISupportedVersionsActionResponseData); + }); + + it("should request supported client versions", async () => { + await expect(widgetApi.getClientVersions()).resolves.toEqual([ + "org.matrix.msc3869", + "org.matrix.msc2762", + ]); + }); + + it("should cache supported client versions on successive calls", async () => { + await expect(widgetApi.getClientVersions()).resolves.toEqual([ + "org.matrix.msc3869", + "org.matrix.msc2762", + ]); + + await expect(widgetApi.getClientVersions()).resolves.toEqual([ + "org.matrix.msc3869", + "org.matrix.msc2762", + ]); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toBeUndefined(); + }); + }); + + describe("searchUserDirectory", () => { + it("should forward the request to the ClientWidgetApi", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3973], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + limited: false, + results: [], + } as IUserDirectorySearchFromWidgetResponseData); + + await expect(widgetApi.searchUserDirectory("foo", 10)).resolves.toEqual({ + limited: false, + results: [], + }); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { + search_term: "foo", + limit: 10, + }, + } satisfies SendRequestArgs); + }); + + it("should reject the request if the api is not supported", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [], + } as ISupportedVersionsActionResponseData); + + await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow( + "The user_directory_search action is not supported by the client.", + ); + + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: expect.anything(), + } satisfies SendRequestArgs); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3973], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + }); + + await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow( + "An error occurred", + ); + }); + + it("should handle an error with details", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC3973], + } as ISupportedVersionsActionResponseData); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect(widgetApi.searchUserDirectory("foo", 10)).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("getMediaConfig", () => { + it("should forward the request to the ClientWidgetApi", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + "m.upload.size": 1000, + } as IGetMediaConfigActionFromWidgetResponseData); + + await expect(widgetApi.getMediaConfig()).resolves.toEqual({ + "m.upload.size": 1000, + }); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + } satisfies SendRequestArgs); + }); + + it("should reject the request if the api is not supported", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [], + } as ISupportedVersionsActionResponseData); + + await expect(widgetApi.getMediaConfig()).rejects.toThrow( + "The get_media_config action is not supported by the client.", + ); + + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + }); + + await expect(widgetApi.getMediaConfig()).rejects.toThrow( + "An error occurred", + ); + }); + + it("should handle an error with details", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect(widgetApi.getMediaConfig()).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("uploadFile", () => { + it("should forward the request to the ClientWidgetApi", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + content_uri: "mxc://...", + } as IUploadFileActionFromWidgetResponseData); + + await expect(widgetApi.uploadFile("data")).resolves.toEqual({ + content_uri: "mxc://...", + }); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { file: "data" }, + } satisfies SendRequestArgs); + }); + + it("should reject the request if the api is not supported", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [], + } as ISupportedVersionsActionResponseData); + + await expect(widgetApi.uploadFile("data")).rejects.toThrow( + "The upload_file action is not supported by the client.", + ); + + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + }); + + await expect(widgetApi.uploadFile("data")).rejects.toThrow( + "An error occurred", + ); + }); + + it("should handle an error with details", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect(widgetApi.uploadFile("data")).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); + }); + }); + + describe("downloadFile", () => { + it("should forward the request to the ClientWidgetApi", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + file: "test contents", + } as IDownloadFileActionFromWidgetResponseData); + + await expect( + widgetApi.downloadFile("mxc://example.com/test_file"), + ).resolves.toEqual({ + file: "test contents", + }); + + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { content_uri: "mxc://example.com/test_file" }, + } satisfies SendRequestArgs); + }); + + it("should reject the request if the api is not supported", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [], + } as ISupportedVersionsActionResponseData); + + await expect( + widgetApi.downloadFile("mxc://example.com/test_file"), + ).rejects.toThrow( + "The download_file action is not supported by the client.", + ); + + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); + }); + + it("should handle an error", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + widgetTransportHelper.queueResponse({ + error: { message: "An error occurred" }, + }); + + await expect( + widgetApi.downloadFile("mxc://example.com/test_file"), + ).rejects.toThrow("An error occurred"); + }); + + it("should handle an error with details", async () => { + widgetTransportHelper.queueResponse({ + supported_versions: [UnstableApiVersion.MSC4039], + } as ISupportedVersionsActionResponseData); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: "Unknown error", + }, + }, + }; + + widgetTransportHelper.queueResponse({ + error: { + message: "An error occurred", + ...errorDetails, + }, + } as IWidgetApiErrorResponseData); + + await expect( + widgetApi.downloadFile("mxc://example.com/test_file"), + ).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); }); + }); }); diff --git a/test/url-template-test.ts b/test/url-template-test.ts index 3f28df8..ee67028 100644 --- a/test/url-template-test.ts +++ b/test/url-template-test.ts @@ -17,41 +17,47 @@ import { runTemplate } from "../src"; describe("runTemplate", () => { - it("should replace device id template in url", () => { - const url = "https://localhost/?my-query#device_id=$org.matrix.msc3819.matrix_device_id"; - const replacedUrl = runTemplate( - url, - { - id: "widget-id", - creatorUserId: "@user-id", - type: "type", - url, - }, - { - deviceId: "my-device-id", - currentUserId: "@user-id", - }, - ); + it("should replace device id template in url", () => { + const url = + "https://localhost/?my-query#device_id=$org.matrix.msc3819.matrix_device_id"; + const replacedUrl = runTemplate( + url, + { + id: "widget-id", + creatorUserId: "@user-id", + type: "type", + url, + }, + { + deviceId: "my-device-id", + currentUserId: "@user-id", + }, + ); - expect(replacedUrl).toBe("https://localhost/?my-query#device_id=my-device-id"); - }); + expect(replacedUrl).toBe( + "https://localhost/?my-query#device_id=my-device-id", + ); + }); - it("should replace base url template in url", () => { - const url = "https://localhost/?my-query#base_url=$org.matrix.msc4039.matrix_base_url"; - const replacedUrl = runTemplate( - url, - { - id: "widget-id", - creatorUserId: "@user-id", - type: "type", - url, - }, - { - currentUserId: "@user-id", - baseUrl: "https://localhost/api", - }, - ); + it("should replace base url template in url", () => { + const url = + "https://localhost/?my-query#base_url=$org.matrix.msc4039.matrix_base_url"; + const replacedUrl = runTemplate( + url, + { + id: "widget-id", + creatorUserId: "@user-id", + type: "type", + url, + }, + { + currentUserId: "@user-id", + baseUrl: "https://localhost/api", + }, + ); - expect(replacedUrl).toBe("https://localhost/?my-query#base_url=https%3A%2F%2Flocalhost%2Fapi"); - }); + expect(replacedUrl).toBe( + "https://localhost/?my-query#base_url=https%3A%2F%2Flocalhost%2Fapi", + ); + }); });