diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 529a707..7415bef 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,7 @@ import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; import { Capability, MatrixCapabilities } from "./interfaces/Capabilities"; -import { IOpenIDUpdate, ISendEventDetails, WidgetDriver } from "./driver/WidgetDriver"; +import { IOpenIDUpdate, ISendEventDetails, ISendFutureDetails, WidgetDriver } from "./driver/WidgetDriver"; import { ICapabilitiesActionResponseData, INotifyCapabilitiesActionRequestData, @@ -477,21 +477,31 @@ export class ClientWidgetApi extends EventEmitter { }); } - const isState = request.data.state_key !== null && request.data.state_key !== undefined; - let sendEventPromise: Promise; - if (isState) { - if (!this.canSendStateEvent(request.data.type, request.data.state_key!)) { + let sendEventPromise: Promise; + if (request.data.state_key !== undefined) { + if (!this.canSendStateEvent(request.data.type, request.data.state_key)) { return this.transport.reply(request, { error: {message: "Cannot send state events of this type"}, }); } - sendEventPromise = this.driver.sendEvent( - request.data.type, - request.data.content || {}, - request.data.state_key, - request.data.room_id, - ); + if (request.data.future_timeout === undefined && request.data.future_group_id === undefined) { + sendEventPromise = this.driver.sendEvent( + request.data.type, + request.data.content || {}, + request.data.state_key, + request.data.room_id, + ); + } else { + sendEventPromise = this.driver.sendFuture( + request.data.future_timeout ?? null, + request.data.future_group_id ?? null, + request.data.type, + request.data.content || {}, + request.data.state_key, + request.data.room_id, + ); + } } else { const content = request.data.content as { msgtype?: string } || {}; const msgtype = content['msgtype']; @@ -501,18 +511,36 @@ export class ClientWidgetApi extends EventEmitter { }); } - sendEventPromise = this.driver.sendEvent( - request.data.type, - content, - null, // not sending a state event - request.data.room_id, - ); + if (request.data.future_timeout === undefined && request.data.future_group_id === undefined) { + sendEventPromise = this.driver.sendEvent( + request.data.type, + content, + null, // not sending a state event + request.data.room_id, + ); + } else { + sendEventPromise = this.driver.sendFuture( + request.data.future_timeout ?? null, + request.data.future_group_id ?? null, + request.data.type, + content, + null, // not sending a state event + request.data.room_id, + ); + } } sendEventPromise.then(sentEvent => { return this.transport.reply(request, { room_id: sentEvent.roomId, - event_id: sentEvent.eventId, + ...("eventId" in sentEvent ? { + event_id: sentEvent.eventId, + } : { + future_group_id: sentEvent.futureGroupId, + send_token: sentEvent.sendToken, + cancel_token: sentEvent.cancelToken, + ...("refreshToken" in sentEvent && { refresh_token: sentEvent.refreshToken }), + }), }); }).catch(e => { console.error("error sending event: ", e); diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 1dc17ee..807dc40 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -400,11 +400,10 @@ export class WidgetApi extends EventEmitter { eventType: string, content: unknown, roomId?: string, + futureTimeout?: number, + futureGroupId?: string, ): Promise { - return this.transport.send( - WidgetApiFromWidgetAction.SendEvent, - {type: eventType, content, room_id: roomId}, - ); + return this.sendEvent(eventType, undefined, content, roomId, futureTimeout, futureGroupId); } public sendStateEvent( @@ -412,10 +411,30 @@ export class WidgetApi extends EventEmitter { stateKey: string, content: unknown, roomId?: string, + futureTimeout?: number, + futureGroupId?: string, + ): Promise { + return this.sendEvent(eventType, stateKey, content, roomId, futureTimeout, futureGroupId); + } + + private sendEvent( + eventType: string, + stateKey: string | undefined, + content: unknown, + roomId?: string, + futureTimeout?: number, + futureGroupId?: string, ): Promise { return this.transport.send( WidgetApiFromWidgetAction.SendEvent, - {type: eventType, content, state_key: stateKey, room_id: roomId}, + { + type: eventType, + content, + ...(stateKey !== undefined && { state_key: stateKey }), + ...(roomId !== undefined && { room_id: roomId }), + ...(futureTimeout !== undefined && { future_timeout: futureTimeout }), + ...(futureGroupId !== undefined && { future_group_id: futureGroupId }), + }, ); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index c98722a..f847bdf 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,6 +29,14 @@ export interface ISendEventDetails { eventId: string; } +export interface ISendFutureDetails { + roomId: string; + futureGroupId: string; + sendToken: string; + cancelToken: string; + refreshToken?: string; +} + export interface IOpenIDUpdate { state: OpenIDRequestState; token?: IOpenIDCredentials; @@ -103,6 +111,36 @@ export abstract class WidgetDriver { return Promise.reject(new Error("Failed to override function")); } + /** + * @experimental Part of MSC4140 & MSC4157 + * Sends a future into a room. If `roomId` is falsy, the client should send the future + * into the room the user is currently looking at. The widget API will have already + * verified that the widget is capable of sending the future's event to that room. + * @param {number|null} futureTimeout The future's timeout, or null for an action future. + * May not be null if {@link futureGroupId} is null. + * @param {string|null} futureGroupId The ID of the group the future belongs to, + * or null if it will be put in a new group. May not be null if {@link futureTimeout} is null. + * @param {string} eventType The event type of the event to be sent by the future. + * @param {*} content The content for the event to be sent by the future. + * @param {string|null} stateKey The state key if the event to be sent by the future is + * a state event, otherwise null. May be an empty string. + * @param {string|null} roomId The room ID to send the future to. If falsy, the room the + * user is currently looking at. + * @returns {Promise} Resolves when the future has been sent with + * details of that future. + * @throws Rejected when the future could not be sent. + */ + public sendFuture( + futureTimeout: number | null, + futureGroupId: string | null, + eventType: string, + content: unknown, + stateKey: string | null = null, + roomId: string | null = null, + ): Promise { + return Promise.reject(new Error("Failed to override function")); + } + /** * Sends a to-device event. The widget API will have already verified that the widget * is capable of sending the event. diff --git a/src/interfaces/SendEventAction.ts b/src/interfaces/SendEventAction.ts index 8fe6da0..aa3d1df 100644 --- a/src/interfaces/SendEventAction.ts +++ b/src/interfaces/SendEventAction.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,10 @@ export interface ISendEventFromWidgetRequestData extends IWidgetApiRequestData { type: string; content: unknown; room_id?: string; // eslint-disable-line camelcase + + // MSC4157: Futures + future_timeout?: number; // eslint-disable-line camelcase + future_group_id?: string; // eslint-disable-line camelcase } export interface ISendEventFromWidgetActionRequest extends IWidgetApiRequest { @@ -33,7 +37,13 @@ export interface ISendEventFromWidgetActionRequest extends IWidgetApiRequest { export interface ISendEventFromWidgetResponseData extends IWidgetApiResponseData { room_id: string; // eslint-disable-line camelcase - event_id: string; // eslint-disable-line camelcase + event_id?: string; // eslint-disable-line camelcase + + // MSC4157: Futures + future_group_id?: string; // eslint-disable-line camelcase + send_token?: string; // eslint-disable-line camelcase + cancel_token?: string; // eslint-disable-line camelcase + refresh_token?: string; // eslint-disable-line camelcase } export interface ISendEventFromWidgetActionResponse extends ISendEventFromWidgetActionRequest { diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 9d7ff80..c630286 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -28,7 +28,7 @@ import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection'; import { Widget } from '../src/models/Widget'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; -import { IReadEventFromWidgetActionRequest } from '../src'; +import { IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest } from '../src'; import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction'; import { IUploadFileActionFromWidgetActionRequest } from '../src/interfaces/UploadFileAction'; @@ -79,6 +79,8 @@ describe('ClientWidgetApi', () => { driver = { readStateEvents: jest.fn(), readEventRelations: jest.fn(), + sendEvent: jest.fn(), + sendFuture: jest.fn(), validateCapabilities: jest.fn(), searchUserDirectory: jest.fn(), getMediaConfig: jest.fn(), @@ -117,6 +119,204 @@ describe('ClientWidgetApi', () => { expect(clientWidgetApi.hasCapability('m.sticker')).toBe(false); }); + 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('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, + ); + }); + }); + + describe('send_event action for futures', () => { + it('sends message futures', async () => { + const roomId = '!room:example.org'; + const futureGroupId = 'fg'; + + driver.sendFuture.mockResolvedValue({ + roomId, + futureGroupId, + sendToken: 'st', + cancelToken: 'ct', + refreshToken: 'rt', + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: {}, + room_id: roomId, + future_timeout: 5000, + future_group_id: futureGroupId, + }, + }; + + 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, + future_group_id: futureGroupId, + send_token: 'st', + cancel_token: 'ct', + refresh_token: 'rt', + }); + }); + + expect(driver.sendFuture).toHaveBeenCalledWith( + event.data.future_timeout, + event.data.future_group_id, + event.data.type, + event.data.content, + null, + roomId, + ); + }); + + it('sends state futures', async () => { + const roomId = '!room:example.org'; + const futureGroupId = 'fg'; + + driver.sendFuture.mockResolvedValue({ + roomId, + futureGroupId, + sendToken: 'st', + cancelToken: 'ct', + refreshToken: 'rt', + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.topic', + content: {}, + state_key: '', + room_id: roomId, + future_timeout: 5000, + future_group_id: futureGroupId, + }, + }; + + 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, + future_group_id: futureGroupId, + send_token: 'st', + cancel_token: 'ct', + refresh_token: 'rt', + }); + }); + + expect(driver.sendFuture).toHaveBeenCalledWith( + event.data.future_timeout, + event.data.future_group_id, + event.data.type, + event.data.content, + '', + roomId, + ); + }); + }); + describe('org.matrix.msc2876.read_events action', () => { it('reads state events with any state key', async () => { driver.readStateEvents.mockResolvedValue([