From 47681a43790fb090b0f2854783f1179c352eb651 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 20 Feb 2026 09:53:53 -0500 Subject: [PATCH 1/2] MSC4157 / MSC4140: remove parent delay IDs Drop support for sending delayed events that are sent upon the scheduled sending of another delayed event. This feature has been absent from the delayed event MSC for some time now, and is not known to have been implemented in any server. --- src/ClientWidgetApi.ts | 19 +- src/WidgetApi.ts | 8 +- src/driver/WidgetDriver.ts | 16 +- src/interfaces/SendEventAction.ts | 1 - test/ClientWidgetApi-test.ts | 307 ++++++++++++++---------------- test/WidgetApi-test.ts | 34 +--- 6 files changed, 156 insertions(+), 229 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 75ec2a6..b51a5b8 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -626,8 +626,8 @@ export class ClientWidgetApi extends EventEmitter { }); } - const isDelayedEvent = request.data.delay !== undefined || request.data.parent_delay_id !== undefined; - if (isDelayedEvent && !this.hasCapability(MatrixCapabilities.MSC4157SendDelayedEvent)) { + const delay = request.data.delay; + if (delay !== undefined && !this.hasCapability(MatrixCapabilities.MSC4157SendDelayedEvent)) { return this.transport.reply(request, { error: { message: `Missing capability for ${MatrixCapabilities.MSC4157SendDelayedEvent}` }, }); @@ -653,10 +653,9 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (isDelayedEvent) { + if (delay !== undefined) { sendEventPromise = this.driver.sendDelayedEvent( - request.data.delay ?? null, - request.data.parent_delay_id ?? null, + delay, request.data.type, request.data.content || {}, request.data.state_key, @@ -690,19 +689,17 @@ export class ClientWidgetApi extends EventEmitter { request.data.room_id, ]; - if (isDelayedEvent && request.data.sticky_duration_ms) { + if (delay !== undefined && request.data.sticky_duration_ms) { sendEventPromise = this.driver.sendDelayedStickyEvent( - request.data.delay ?? null, - request.data.parent_delay_id ?? null, + delay, request.data.sticky_duration_ms, request.data.type, content, request.data.room_id, ); - } else if (isDelayedEvent) { + } else if (delay !== undefined) { sendEventPromise = this.driver.sendDelayedEvent( - request.data.delay ?? null, - request.data.parent_delay_id ?? null, + delay, ...params, ); } else if (request.data.sticky_duration_ms) { diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 7b4db62..3ae2b36 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -440,10 +440,9 @@ export class WidgetApi extends EventEmitter { content: unknown, roomId?: string, delay?: number, - parentDelayId?: string, stickyDurationMs?: number, ): Promise { - return this.sendEvent(eventType, undefined, content, roomId, delay, parentDelayId, stickyDurationMs); + return this.sendEvent(eventType, undefined, content, roomId, delay, stickyDurationMs); } public sendStateEvent( @@ -452,9 +451,8 @@ export class WidgetApi extends EventEmitter { content: unknown, roomId?: string, delay?: number, - parentDelayId?: string, ): Promise { - return this.sendEvent(eventType, stateKey, content, roomId, delay, parentDelayId); + return this.sendEvent(eventType, stateKey, content, roomId, delay); } private sendEvent( @@ -463,7 +461,6 @@ export class WidgetApi extends EventEmitter { content: unknown, roomId?: string, delay?: number, - parentDelayId?: string, stickyDurationMs?: number, ): Promise { return this.transport.send( @@ -474,7 +471,6 @@ export class WidgetApi extends EventEmitter { ...(stateKey !== undefined && { state_key: stateKey }), ...(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 94ef7f8..afaf411 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -138,10 +138,7 @@ export abstract class WidgetDriver { * Sends a delayed event into a room. If `roomId` is falsy, the client should send it * 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|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 {number} delay How much later to send the event. * @param {string} eventType The event type of the event to be sent. * @param {*} content The content for the event to be sent. * @param {string|null} stateKey The state key if the event to be sent a state event, @@ -153,8 +150,7 @@ export abstract class WidgetDriver { * @throws Rejected when the delayed event could not be sent. */ public sendDelayedEvent( - delay: number | null, - parentDelayId: string | null, + delay: number, eventType: string, content: unknown, stateKey: string | null = null, @@ -169,10 +165,7 @@ export abstract class WidgetDriver { * 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 {number} delay How much later to send the event. * @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 @@ -182,8 +175,7 @@ export abstract class WidgetDriver { * @throws Rejected when the event could not be sent. */ public sendDelayedStickyEvent( - delay: number | null, - parentDelayId: string | null, + delay: number, stickyDurationMs: number, eventType: string, content: unknown, diff --git a/src/interfaces/SendEventAction.ts b/src/interfaces/SendEventAction.ts index a78e38d..514dde6 100644 --- a/src/interfaces/SendEventAction.ts +++ b/src/interfaces/SendEventAction.ts @@ -27,7 +27,6 @@ export interface ISendEventFromWidgetRequestData extends IWidgetApiRequestData { // MSC4157 delay?: number; // eslint-disable-line camelcase - parent_delay_id?: string; // eslint-disable-line camelcase // MSC4407 sticky_duration_ms?: number; diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 5615795..d0e5c96 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -509,116 +509,98 @@ describe("ClientWidgetApi", () => { expect(driver.sendDelayedEvent).not.toHaveBeenCalled(); }); - it.each([ - { hasDelay: true, hasParent: false }, - { hasDelay: false, hasParent: true }, - { hasDelay: true, hasParent: true }, - ])( - "sends delayed message events (hasDelay = $hasDelay, hasParent = $hasParent)", - async ({ hasDelay, hasParent }) => { - 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.message", - content: {}, - room_id: roomId, - ...(hasDelay && { delay: 5000 }), - ...(hasParent && { 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).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, - }); - }); - - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay ?? null, - event.data.parent_delay_id ?? null, - event.data.type, - event.data.content, - null, - roomId, - ); - }, - ); + it("sends delayed message events", async () => { + const roomId = "!room:example.org"; + const timeoutDelayId = "ft"; - it.each([ - { hasDelay: true, hasParent: false }, - { hasDelay: false, hasParent: true }, - { hasDelay: true, hasParent: true }, - ])( - "sends delayed state events (hasDelay = $hasDelay, hasParent = $hasParent)", - async ({ hasDelay, hasParent }) => { - 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, - ...(hasDelay && { delay: 5000 }), - ...(hasParent && { parent_delay_id: "fp" }), - }, - }; - - 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 ?? null, - event.data.parent_delay_id ?? null, - event.data.type, - event.data.content, - "", - roomId, - ); - }, - ); + 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, + }, + }; + + 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.type, + event.data.content, + null, + roomId, + ); + }); + + it("sends delayed 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, + delay: 5000, + }, + }; + + 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 ?? null, + event.data.type, + event.data.content, + "", + roomId, + ); + }); it("should reject requests when the driver throws an exception", async () => { const roomId = "!room:example.org"; @@ -635,7 +617,6 @@ describe("ClientWidgetApi", () => { content: "hello", room_id: roomId, delay: 5000, - parent_delay_id: "fp", }, }; @@ -675,7 +656,6 @@ describe("ClientWidgetApi", () => { content: "hello", room_id: roomId, delay: 5000, - parent_delay_id: "fp", }, }; @@ -786,64 +766,55 @@ describe("ClientWidgetApi", () => { 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, + it("sends sticky message events with a delay", async () => { + 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", }, - }; - - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - MatrixCapabilities.MSC4157SendDelayedEvent, - MatrixCapabilities.MSC4407SendStickyEvent, - ]); - - 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, - ); - }, - ); + room_id: roomId, + delay: 5000, + 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.MSC4407SendStickyEvent, + ]); + + 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, + 5000, + event.data.type, + event.data.content, + roomId, + ); + }); it("does not allow sticky state events", async () => { const roomId = "!room:example.org"; diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 68704d2..845b26a 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -329,34 +329,6 @@ describe("WidgetApi", () => { }); }); - 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, "parent-id")).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, "parent-id"), - ).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); - }); - it("should handle an error", async () => { widgetTransportHelper.queueResponse({ error: { message: "An error occurred" }, @@ -401,7 +373,7 @@ describe("WidgetApi", () => { } as ISendEventFromWidgetResponseData); await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, undefined, 2500), + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), ).resolves.toEqual({ room_id: "!room-id", event_id: "$event_id", @@ -414,7 +386,7 @@ describe("WidgetApi", () => { } as IWidgetApiErrorResponseData); await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, undefined, 2500), + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), ).rejects.toThrow("An error occurred"); }); @@ -439,7 +411,7 @@ describe("WidgetApi", () => { } as IWidgetApiErrorResponseData); await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, undefined, 2500), + widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), ).rejects.toThrow(new WidgetApiResponseError("An error occurred", errorDetails)); }); }); From 35a1303a296cafcf924514bff54a5ef071350c3d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 20 Feb 2026 11:38:50 -0500 Subject: [PATCH 2/2] Run prettier --- src/ClientWidgetApi.ts | 5 +---- test/WidgetApi-test.ts | 16 +++++++--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index b51a5b8..260504d 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -698,10 +698,7 @@ export class ClientWidgetApi extends EventEmitter { request.data.room_id, ); } else if (delay !== undefined) { - sendEventPromise = this.driver.sendDelayedEvent( - delay, - ...params, - ); + sendEventPromise = this.driver.sendDelayedEvent(delay, ...params); } else if (request.data.sticky_duration_ms) { sendEventPromise = this.driver.sendStickyEvent( request.data.sticky_duration_ms, diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 845b26a..3ece656 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -372,9 +372,7 @@ describe("WidgetApi", () => { event_id: "$event_id", } as ISendEventFromWidgetResponseData); - await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), - ).resolves.toEqual({ + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500)).resolves.toEqual({ room_id: "!room-id", event_id: "$event_id", }); @@ -385,9 +383,9 @@ describe("WidgetApi", () => { error: { message: "An error occurred" }, } as IWidgetApiErrorResponseData); - await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), - ).rejects.toThrow("An error occurred"); + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500)).rejects.toThrow( + "An error occurred", + ); }); it("should handle an error with details", async () => { @@ -410,9 +408,9 @@ describe("WidgetApi", () => { }, } as IWidgetApiErrorResponseData); - await expect( - widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500), - ).rejects.toThrow(new WidgetApiResponseError("An error occurred", errorDetails)); + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", undefined, 2500)).rejects.toThrow( + new WidgetApiResponseError("An error occurred", errorDetails), + ); }); });