diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 87f4cff..40f3fc3 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -601,7 +601,14 @@ export class ClientWidgetApi extends EventEmitter { const isDelayedEvent = request.data.delay !== undefined || request.data.parent_delay_id !== undefined; if (isDelayedEvent && !this.hasCapability(MatrixCapabilities.MSC4157SendDelayedEvent)) { return this.transport.reply(request, { - error: { message: "Missing capability" }, + error: { message: `Missing capability for ${MatrixCapabilities.MSC4157SendDelayedEvent}` }, + }); + } + + const isStickyEvent = request.data.sticky_duration_ms !== undefined; + if (isStickyEvent && !this.hasCapability(MatrixCapabilities.MSC4354SendStickyEvent)) { + return this.transport.reply(request, { + error: { message: `Missing capability for ${MatrixCapabilities.MSC4354SendStickyEvent}` }, }); } @@ -612,6 +619,11 @@ export class ClientWidgetApi extends EventEmitter { error: { message: "Cannot send state events of this type" }, }); } + if (isStickyEvent) { + return this.transport.reply(request, { + error: { message: "Cannot send a state event with a sticky duration" }, + }); + } if (isDelayedEvent) { sendEventPromise = this.driver.sendDelayedEvent( @@ -639,22 +651,41 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (isDelayedEvent) { - sendEventPromise = this.driver.sendDelayedEvent( + // Events can be sticky, delayed, both, or neither. The following + // section of code takes the common parameters and uses the correct + // function depending on the request type. + + const params: Parameters = [ + request.data.type, + content, + null, // not sending a state event + request.data.room_id, + ]; + + if (isDelayedEvent && request.data.sticky_duration_ms) { + sendEventPromise = this.driver.sendDelayedStickyEvent( request.data.delay ?? null, request.data.parent_delay_id ?? null, + request.data.sticky_duration_ms, request.data.type, content, - null, // not sending a state event request.data.room_id, ); - } else { - sendEventPromise = this.driver.sendEvent( + } else if (isDelayedEvent) { + sendEventPromise = this.driver.sendDelayedEvent( + request.data.delay ?? null, + request.data.parent_delay_id ?? null, + ...params, + ); + } else if (request.data.sticky_duration_ms) { + sendEventPromise = this.driver.sendStickyEvent( + request.data.sticky_duration_ms, request.data.type, content, - null, // not sending a state event request.data.room_id, ); + } else { + sendEventPromise = this.driver.sendEvent(...params); } } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 73ee411..649ed29 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -441,8 +441,9 @@ export class WidgetApi extends EventEmitter { roomId?: string, delay?: number, parentDelayId?: string, + stickyDurationMs?: number, ): Promise { - return this.sendEvent(eventType, undefined, content, roomId, delay, parentDelayId); + return this.sendEvent(eventType, undefined, content, roomId, delay, parentDelayId, stickyDurationMs); } public sendStateEvent( @@ -463,6 +464,7 @@ export class WidgetApi extends EventEmitter { roomId?: string, delay?: number, parentDelayId?: string, + stickyDurationMs?: number, ): Promise { return this.transport.send( WidgetApiFromWidgetAction.SendEvent, @@ -473,6 +475,7 @@ export class WidgetApi extends EventEmitter { ...(roomId !== undefined && { room_id: roomId }), ...(delay !== undefined && { delay }), ...(parentDelayId !== undefined && { parent_delay_id: parentDelayId }), + ...(stickyDurationMs !== undefined && { sticky_duration_ms: stickyDurationMs }), }, ); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index f14a5ed..2441dc6 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -109,6 +109,29 @@ export abstract class WidgetDriver { return Promise.reject(new Error("Failed to override function")); } + /** + * @experimental Part of MSC4354 + * Sends a sticky event into a room. If `roomId` is falsy, the client should send the event + * into the room the user is currently looking at. The widget API will have already + * verified that the widget is capable of sending the event to that room. + * @param {number} stickyDurationMs The length of time a sticky event may remain sticky, in milliseconds. + * @param {string} eventType The event type to be sent. + * @param {*} content The content for the event. + * @param {string|null} roomId The room ID to send the event to. If falsy, the room the + * user is currently looking at. + * @returns {Promise} Resolves when the event has been sent with + * details of that event. + * @throws Rejected when the event could not be sent. + */ + public sendStickyEvent( + stickyDurationMs: number, + eventType: string, + content: unknown, + roomId: string | null = null, + ): Promise { + throw new Error("Method not implemented."); + } + /** * @experimental Part of MSC4140 & MSC4157 * Sends a delayed event into a room. If `roomId` is falsy, the client should send it @@ -139,6 +162,35 @@ export abstract class WidgetDriver { return Promise.reject(new Error("Failed to override function")); } + /** + * @experimental Part of MSC4140, MSC4157 and MSC4354 + * Sends a delayed sticky event into a room. If `roomId` is falsy, the client should send the event + * into the room the user is currently looking at. The widget API will have already + * verified that the widget is capable of sending the event to that room. + * @param {number} stickyDurationMs The length of time a sticky event may remain sticky, in milliseconds. + * @param {number|null} delay How much later to send the event, or null to not send the + * event automatically. May not be null if {@link parentDelayId} is null. + * @param {string|null} parentDelayId The ID of the delayed event this one is grouped with, + * or null if it will be put in a new group. May not be null if {@link delay} is null. + * @param {string} eventType The event type to be sent. + * @param {*} content The content for the event. + * @param {string|null} roomId The room ID to send the event to. If falsy, the room the + * user is currently looking at. + * @returns {Promise} Resolves when the event has been sent with + * details of that event. + * @throws Rejected when the event could not be sent. + */ + public sendDelayedStickyEvent( + delay: number | null, + parentDelayId: string | null, + stickyDurationMs: number, + eventType: string, + content: unknown, + roomId: string | null = null, + ): Promise { + throw new Error("Method not implemented."); + } + /** * @experimental Part of MSC4140 & MSC4157 * Cancel the scheduled delivery of the delayed event matching the provided {@link delayId}. diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index 9b1ec15..3d6f6f8 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -53,6 +53,10 @@ export enum MatrixCapabilities { * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", + /** + * @experimental It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4354SendStickyEvent = "org.matrix.msc4354.send_sticky_event", } export type Capability = MatrixCapabilities | string; diff --git a/src/interfaces/IRoomEvent.ts b/src/interfaces/IRoomEvent.ts index 5e90005..c908645 100644 --- a/src/interfaces/IRoomEvent.ts +++ b/src/interfaces/IRoomEvent.ts @@ -23,4 +23,11 @@ export interface IRoomEvent { origin_server_ts: number; // eslint-disable-line camelcase content: unknown; unsigned: unknown; + //MSC4354 + sticky?: { + duration_ms: number; + }; + msc4354_sticky?: { + duration_ms: number; + }; } diff --git a/src/interfaces/SendEventAction.ts b/src/interfaces/SendEventAction.ts index 4631dac..0f37bc3 100644 --- a/src/interfaces/SendEventAction.ts +++ b/src/interfaces/SendEventAction.ts @@ -28,6 +28,9 @@ export interface ISendEventFromWidgetRequestData extends IWidgetApiRequestData { // MSC4157 delay?: number; // eslint-disable-line camelcase parent_delay_id?: string; // eslint-disable-line camelcase + + // MSC4354SendStickyEvent + sticky_duration_ms?: number; } export interface ISendEventFromWidgetActionRequest extends IWidgetApiRequest { diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index b6ac75d..61c8b66 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -92,6 +92,11 @@ export enum WidgetApiFromWidgetAction { * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", + + /** + * @experimental It is not recommended to rely on this existing - it can be removed without notice. + */ + MSC4354SendStickyEvent = "org.matrix.msc4354.send_sticky_event", } export type WidgetApiAction = WidgetApiToWidgetAction | WidgetApiFromWidgetAction | string; diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 22c879a..f576e57 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -20,7 +20,7 @@ import { waitFor } from "@testing-library/dom"; import { ClientWidgetApi } from "../src/ClientWidgetApi"; import { WidgetDriver } from "../src/driver/WidgetDriver"; import { CurrentApiVersions, UnstableApiVersion } from "../src/interfaces/ApiVersion"; -import { Capability } from "../src/interfaces/Capabilities"; +import { Capability, MatrixCapabilities } from "../src/interfaces/Capabilities"; import { IRoomEvent } from "../src/interfaces/IRoomEvent"; import { IWidgetApiRequest } from "../src/interfaces/IWidgetApiRequest"; import { IReadRelationsFromWidgetActionRequest } from "../src/interfaces/ReadRelationsAction"; @@ -141,6 +141,8 @@ describe("ClientWidgetApi", () => { downloadFile: jest.fn(), getKnownRooms: jest.fn(() => []), processError: jest.fn(), + sendStickyEvent: jest.fn(), + sendDelayedStickyEvent: jest.fn(), } as Partial as jest.Mocked; clientWidgetApi = new ClientWidgetApi( @@ -704,6 +706,183 @@ describe("ClientWidgetApi", () => { }); }); + describe("send_event action for sticky 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: { + sticky_key: "foobar", + }, + delay: 5000, + room_id: roomId, + sticky_duration_ms: 5000, + }, + }; + + 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).toHaveBeenCalledWith(event, { + error: { message: expect.any(String) }, + }); + }); + + expect(driver.sendDelayedEvent).not.toHaveBeenCalled(); + }); + + it("can send a sticky message event", async () => { + const roomId = "!room:example.org"; + const eventId = "$evt:example.org"; + + driver.sendStickyEvent.mockResolvedValue({ + roomId, + eventId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: { + sticky_key: "12345", + }, + room_id: roomId, + sticky_duration_ms: 5000, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + MatrixCapabilities.MSC4354SendStickyEvent, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + event_id: eventId, + }); + }); + + expect(driver.sendStickyEvent).toHaveBeenCalledWith(5000, event.data.type, event.data.content, roomId); + }); + + it.each([ + { hasDelay: true, hasParent: false }, + { hasDelay: false, hasParent: true }, + { hasDelay: true, hasParent: true }, + ])( + "sends sticky message events with a delay (withDelay = $hasDelay, hasParent = $hasParent)", + async ({ hasDelay, hasParent }) => { + const roomId = "!room:example.org"; + const timeoutDelayId = "ft"; + + driver.sendDelayedStickyEvent.mockResolvedValue({ + roomId, + delayId: timeoutDelayId, + }); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: { + sticky_key: "12345", + }, + room_id: roomId, + ...(hasDelay && { delay: 5000 }), + ...(hasParent && { parent_delay_id: "fp" }), + sticky_duration_ms: 5000, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + MatrixCapabilities.MSC4157SendDelayedEvent, + MatrixCapabilities.MSC4354SendStickyEvent, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + delay_id: timeoutDelayId, + }); + }); + + expect(driver.sendDelayedStickyEvent).toHaveBeenCalledWith( + event.data.delay ?? null, + event.data.parent_delay_id ?? null, + 5000, + event.data.type, + event.data.content, + roomId, + ); + }, + ); + + it("does not allow sticky state events", async () => { + const roomId = "!room:example.org"; + 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, + sticky_duration_ms: 5000, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.state_event:${event.data.type}`, + MatrixCapabilities.MSC4354SendStickyEvent, + ]); + + emitEvent(new CustomEvent("", { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + error: { message: "Cannot send a state event with a sticky duration" }, + }); + }); + }); + }); + describe("receiving events", () => { const roomId = "!room:example.org"; const otherRoomId = "!other-room:example.org"; diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 94b6bdc..68704d2 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -393,6 +393,57 @@ describe("WidgetApi", () => { }); }); + describe("sticky sendEvent", () => { + it("sends sticky message events", async () => { + widgetTransportHelper.queueResponse({ + room_id: "!room-id", + event_id: "$event_id", + } as ISendEventFromWidgetResponseData); + + await expect( + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, undefined, 2500), + ).resolves.toEqual({ + room_id: "!room-id", + event_id: "$event_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", undefined, undefined, 2500), + ).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", undefined, undefined, 2500), + ).rejects.toThrow(new WidgetApiResponseError("An error occurred", errorDetails)); + }); + }); + describe("updateDelayedEvent", () => { it("updates delayed events", async () => { for (const updateDelayedEvent of [