From 647a724f18fbdb2829c822426b11afddd81314c5 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 7 Nov 2025 15:24:04 +0000 Subject: [PATCH 1/6] Add initial implementation --- src/ClientWidgetApi.ts | 52 ++++++++++++++++++++------- src/WidgetApi.ts | 15 ++++++++ src/driver/WidgetDriver.ts | 60 ++++++++++++++++++++++++++++++- src/interfaces/Capabilities.ts | 4 +++ src/interfaces/IRoomEvent.ts | 4 +++ src/interfaces/SendEventAction.ts | 3 ++ src/interfaces/WidgetApiAction.ts | 5 +++ 7 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 87f4cff..a517814 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -601,10 +601,18 @@ 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}` }, }); } + let sendEventPromise: Promise; if (request.data.state_key !== undefined) { if (!this.canSendStateEvent(request.data.type, request.data.state_key)) { @@ -612,6 +620,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 +652,37 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (isDelayedEvent) { + // 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, + ...params, + ); + } else if (isDelayedEvent) { sendEventPromise = this.driver.sendDelayedEvent( request.data.delay ?? null, request.data.parent_delay_id ?? null, - request.data.type, - content, - null, // not sending a state event - request.data.room_id, + ...params, ); - } else { - sendEventPromise = this.driver.sendEvent( - request.data.type, - content, - null, // not sending a state event - request.data.room_id, + } else if (request.data.sticky_duration_ms) { + sendEventPromise = this.driver.sendStickyEvent( + request.data.sticky_duration_ms, + ...params, ); + } else { + sendEventPromise = this.driver.sendEvent(...params); } } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 73ee411..a7be278 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -463,6 +463,7 @@ export class WidgetApi extends EventEmitter { roomId?: string, delay?: number, parentDelayId?: string, + stickyDurationMs?: number, ): Promise { return this.transport.send( WidgetApiFromWidgetAction.SendEvent, @@ -473,6 +474,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 }) }, ); } @@ -516,6 +518,19 @@ export class WidgetApi extends EventEmitter { ); } + /** + * @experimental This currently relies on an unstable MSC (MSC4157). + */ + public sendStickyEvent(delayId: string): Promise { + return this.transport.send( + WidgetApiFromWidgetAction.MSC4354SendStickyEvent, + { + delay_id: delayId, + action: UpdateDelayedEventAction.Send, + }, + ); + } + /** * Sends a to-device event. * @param {string} eventType The type of events being sent. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index f14a5ed..08fc534 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -104,11 +104,37 @@ export abstract class WidgetDriver { eventType: string, content: unknown, stateKey: string | null = null, - roomId: string | null = null, + roomId: string | null = null ): Promise { 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} stateKey The state key if this is a state event, otherwise null. + * May be an empty string. + * @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, + stateKey: string | null = null, + 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 +165,38 @@ 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} stateKey The state key if this is a state event, otherwise null. + * May be an empty string. + * @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, + stateKey: string | null = null, + 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..ed837d2 100644 --- a/src/interfaces/IRoomEvent.ts +++ b/src/interfaces/IRoomEvent.ts @@ -23,4 +23,8 @@ export interface IRoomEvent { origin_server_ts: number; // eslint-disable-line camelcase content: unknown; unsigned: unknown; + //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; From e8152f8d40cc5827a267cde9ba8875d0b6719474 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 7 Nov 2025 15:30:33 +0000 Subject: [PATCH 2/6] Prettier --- src/ClientWidgetApi.ts | 6 +----- src/WidgetApi.ts | 2 +- src/driver/WidgetDriver.ts | 10 +++++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index a517814..32b4e1e 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -612,7 +612,6 @@ export class ClientWidgetApi extends EventEmitter { }); } - let sendEventPromise: Promise; if (request.data.state_key !== undefined) { if (!this.canSendStateEvent(request.data.type, request.data.state_key)) { @@ -677,10 +676,7 @@ export class ClientWidgetApi extends EventEmitter { ...params, ); } else if (request.data.sticky_duration_ms) { - sendEventPromise = this.driver.sendStickyEvent( - request.data.sticky_duration_ms, - ...params, - ); + sendEventPromise = this.driver.sendStickyEvent(request.data.sticky_duration_ms, ...params); } else { sendEventPromise = this.driver.sendEvent(...params); } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index a7be278..2ed2790 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -474,7 +474,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 }) + ...(stickyDurationMs !== undefined && { sticky_duration_ms: stickyDurationMs }), }, ); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 08fc534..e423662 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -104,7 +104,7 @@ export abstract class WidgetDriver { eventType: string, content: unknown, stateKey: string | null = null, - roomId: string | null = null + roomId: string | null = null, ): Promise { return Promise.reject(new Error("Failed to override function")); } @@ -126,11 +126,11 @@ export abstract class WidgetDriver { * @throws Rejected when the event could not be sent. */ public sendStickyEvent( - stickyDurationMs: number, + stickyDurationMs: number, eventType: string, content: unknown, stateKey: string | null = null, - roomId: string | null = null + roomId: string | null = null, ): Promise { throw new Error("Method not implemented."); } @@ -188,11 +188,11 @@ export abstract class WidgetDriver { public sendDelayedStickyEvent( delay: number | null, parentDelayId: string | null, - stickyDurationMs: number, + stickyDurationMs: number, eventType: string, content: unknown, stateKey: string | null = null, - roomId: string | null = null + roomId: string | null = null, ): Promise { throw new Error("Method not implemented."); } From 3863f5b4df2d6a7065b4630dbd0348d8ffc80f58 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 10 Nov 2025 14:17:46 +0000 Subject: [PATCH 3/6] lint --- src/WidgetApi.ts | 16 +-- src/driver/WidgetDriver.ts | 4 +- test/ClientWidgetApi-test.ts | 188 ++++++++++++++++++++++++++++++++++- test/WidgetApi-test.ts | 51 ++++++++++ 4 files changed, 242 insertions(+), 17 deletions(-) diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 2ed2790..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( @@ -518,19 +519,6 @@ export class WidgetApi extends EventEmitter { ); } - /** - * @experimental This currently relies on an unstable MSC (MSC4157). - */ - public sendStickyEvent(delayId: string): Promise { - return this.transport.send( - WidgetApiFromWidgetAction.MSC4354SendStickyEvent, - { - delay_id: delayId, - action: UpdateDelayedEventAction.Send, - }, - ); - } - /** * Sends a to-device event. * @param {string} eventType The type of events being sent. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index e423662..162bcbd 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -181,7 +181,7 @@ export abstract class WidgetDriver { * May be an empty string. * @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 + * @returns {Promise} Resolves when the event has been sent with * details of that event. * @throws Rejected when the event could not be sent. */ @@ -193,7 +193,7 @@ export abstract class WidgetDriver { content: unknown, stateKey: string | null = null, roomId: string | null = null, - ): Promise { + ): Promise { throw new Error("Method not implemented."); } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 22c879a..c108182 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,190 @@ 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, + null, + 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, + null, + 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 [ From 6d93598f3f43c355fb5bc6c5d1788dfcbff69187 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 10 Nov 2025 14:22:50 +0000 Subject: [PATCH 4/6] remove state key --- src/ClientWidgetApi.ts | 8 ++++++-- src/driver/WidgetDriver.ts | 6 ------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 32b4e1e..b5619e5 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -667,7 +667,9 @@ export class ClientWidgetApi extends EventEmitter { request.data.delay ?? null, request.data.parent_delay_id ?? null, request.data.sticky_duration_ms, - ...params, + request.data.type, + content, + request.data.room_id, ); } else if (isDelayedEvent) { sendEventPromise = this.driver.sendDelayedEvent( @@ -676,7 +678,9 @@ export class ClientWidgetApi extends EventEmitter { ...params, ); } else if (request.data.sticky_duration_ms) { - sendEventPromise = this.driver.sendStickyEvent(request.data.sticky_duration_ms, ...params); + sendEventPromise = this.driver.sendStickyEvent(request.data.sticky_duration_ms, + request.data.type, + content, request.data.room_id); } else { sendEventPromise = this.driver.sendEvent(...params); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 162bcbd..2441dc6 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -117,8 +117,6 @@ export abstract class WidgetDriver { * @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} stateKey The state key if this is a state event, otherwise null. - * May be an empty string. * @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 @@ -129,7 +127,6 @@ export abstract class WidgetDriver { stickyDurationMs: number, eventType: string, content: unknown, - stateKey: string | null = null, roomId: string | null = null, ): Promise { throw new Error("Method not implemented."); @@ -177,8 +174,6 @@ export abstract class WidgetDriver { * 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} stateKey The state key if this is a state event, otherwise null. - * May be an empty string. * @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 @@ -191,7 +186,6 @@ export abstract class WidgetDriver { stickyDurationMs: number, eventType: string, content: unknown, - stateKey: string | null = null, roomId: string | null = null, ): Promise { throw new Error("Method not implemented."); From ff470531aaeb441047c46c9e5adf52fee353aced Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 10 Nov 2025 14:41:16 +0000 Subject: [PATCH 5/6] fix tests --- src/ClientWidgetApi.ts | 9 ++++++--- test/ClientWidgetApi-test.ts | 9 +-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index b5619e5..40f3fc3 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -678,9 +678,12 @@ export class ClientWidgetApi extends EventEmitter { ...params, ); } else if (request.data.sticky_duration_ms) { - sendEventPromise = this.driver.sendStickyEvent(request.data.sticky_duration_ms, - request.data.type, - content, request.data.room_id); + sendEventPromise = this.driver.sendStickyEvent( + request.data.sticky_duration_ms, + request.data.type, + content, + request.data.room_id, + ); } else { sendEventPromise = this.driver.sendEvent(...params); } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index c108182..f576e57 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -782,13 +782,7 @@ describe("ClientWidgetApi", () => { }); }); - expect(driver.sendStickyEvent).toHaveBeenCalledWith( - 5000, - event.data.type, - event.data.content, - null, - roomId, - ); + expect(driver.sendStickyEvent).toHaveBeenCalledWith(5000, event.data.type, event.data.content, roomId); }); it.each([ @@ -845,7 +839,6 @@ describe("ClientWidgetApi", () => { 5000, event.data.type, event.data.content, - null, roomId, ); }, From 5746feb29cc3ada0a78f4cd0eb4f827d27b7b2c3 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 10 Nov 2025 16:42:34 +0000 Subject: [PATCH 6/6] Add unstable sticky --- src/interfaces/IRoomEvent.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/interfaces/IRoomEvent.ts b/src/interfaces/IRoomEvent.ts index ed837d2..c908645 100644 --- a/src/interfaces/IRoomEvent.ts +++ b/src/interfaces/IRoomEvent.ts @@ -27,4 +27,7 @@ export interface IRoomEvent { sticky?: { duration_ms: number; }; + msc4354_sticky?: { + duration_ms: number; + }; }