From 62b34e09c21d4314f81f8024f668331fa5c7e5fd Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 10 Nov 2025 17:08:08 +0000 Subject: [PATCH 1/4] Update StopGapWidgetDriver to support sticky events --- src/stores/widgets/StopGapWidgetDriver.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 42abdc801db..afc3a45cb19 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -121,6 +121,7 @@ export class StopGapWidgetDriver extends WidgetDriver { this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`); this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); + this.allowedCapabilities.add(MatrixCapabilities.MSC4354SendStickyEvent); this.allowedCapabilities.add( WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw, @@ -344,6 +345,31 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } + /** + * @experimental Part of MSC4354 + * @see {@link WidgetDriver#sendStickyEvent} + */ + public async sendStickyEvent( + stickyDurationMs: number, + eventType: string, + content: unknown, + targetRoomId?: string | null, + ): Promise { + const client = MatrixClientPeg.get(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); + + if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + + const r = await client._unstable_sendStickyEvent( + roomId, + stickyDurationMs, + null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] & { msc4354_sticky_key: string }, + ); + return { roomId, eventId: r.event_id }; + } + /** * @experimental Part of MSC4140 & MSC4157 * @see {@link WidgetDriver#sendDelayedEvent} @@ -421,6 +447,48 @@ export class StopGapWidgetDriver extends WidgetDriver { }; } + /** + * @experimental Part of MSC4354 + * @see {@link WidgetDriver#sendStickyEvent} + */ + public async sendDelayedStickyEvent( + delay: number | null, + parentDelayId: string | null, + stickyDurationMs: number, + eventType: string, + content: unknown, + targetRoomId?: string | null, + ): Promise { + const client = MatrixClientPeg.get(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); + + if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + + let delayOpts; + if (delay !== null) { + delayOpts = { + delay, + ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), + }; + } else if (parentDelayId !== null) { + delayOpts = { + parent_delay_id: parentDelayId, + }; + } else { + throw new Error("Must provide at least one of delay or parentDelayId"); + } + + const r = await client._unstable_sendStickyDelayedEvent( + roomId, + stickyDurationMs, + delayOpts, + null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] & { msc4354_sticky_key: string }, + ); + return { roomId, delayId: r.delay_id }; + } + /** * @experimental Part of MSC4140 & MSC4157 */ From 262842750ee24b56128542f2f2fbe1158bce1ff6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 17 Nov 2025 10:45:33 +0000 Subject: [PATCH 2/4] Update to-device --- package.json | 2 +- src/stores/widgets/StopGapWidget.ts | 6 ++---- yarn.lock | 13 +++++++++++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 0e4f6337558..b3ac2263a0c 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^1.14.0", + "matrix-widget-api": "^1.15.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", "oidc-client-ts": "^3.0.1", diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index c8de3bf0f13..d43fd140fc8 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -522,10 +522,8 @@ export class StopGapWidget extends EventEmitter { }); }; - private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise => { - const { message, encryptionInfo } = payload; - // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent - await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); + private onToDeviceMessage = async ({ message, encryptionInfo }: ReceivedToDeviceMessage): Promise => { + await this.messaging?.feedToDevice(message, encryptionInfo != null); }; /** diff --git a/yarn.lock b/yarn.lock index 1a235877ebd..ce2fbef342a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4220,13 +4220,14 @@ "@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" + uid "" "@vector-im/matrix-wysiwyg@2.40.0": version "2.40.0" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@vitest/expect@3.2.4": version "3.2.4" @@ -9669,7 +9670,7 @@ matrix-events-sdk@0.0.1: jwt-decode "^4.0.0" loglevel "^1.9.2" matrix-events-sdk "0.0.1" - matrix-widget-api "^1.14.0" + matrix-widget-api "^1.10.0" oidc-client-ts "^3.0.1" p-retry "7" sdp-transform "^3.0.0" @@ -9695,6 +9696,14 @@ matrix-widget-api@^1.14.0: "@types/events" "^3.0.0" events "^3.2.0" +matrix-widget-api@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.15.0.tgz#a508f72a5993a95382bdf890bd9e54525295b321" + integrity sha512-Yu9rX9wyF3A1sqviKgiYHz8aGgL3HhJe9OXKi/lccr1eZnNb6y+ELdbshTjs+VLKM4rkTWt6CE3THsw3f/CZhg== + dependencies: + "@types/events" "^3.0.0" + events "^3.2.0" + mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" From 9cc554c170b9ce7206e1a69af0f16d33c8e1ca65 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 18 Nov 2025 13:05:53 +0000 Subject: [PATCH 3/4] Update tests --- test/test-utils/test-utils.ts | 2 + .../widgets/StopGapWidgetDriver-test.ts | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a0fa715bad0..4d46b4c5643 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -279,6 +279,8 @@ export function createTestClient(): MatrixClient { _unstable_cancelScheduledDelayedEvent: jest.fn(), _unstable_restartScheduledDelayedEvent: jest.fn(), _unstable_sendScheduledDelayedEvent: jest.fn(), + _unstable_sendStickyEvent: jest.fn(), + _unstable_sendStickyDelayedEvent: jest.fn(), searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }), setDeviceVerified: jest.fn(), diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index 9b6411b134a..4ab05c8413f 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -133,6 +133,7 @@ describe("StopGapWidgetDriver", () => { "org.matrix.msc3819.receive.to_device:m.call.replaces", "org.matrix.msc4157.send.delayed_event", "org.matrix.msc4157.update_delayed_event", + "org.matrix.msc4354.send_sticky_event", // RTC decline events "org.matrix.msc2762.send.event:org.matrix.msc4310.rtc.decline", "org.matrix.msc2762.receive.event:org.matrix.msc4310.rtc.decline", @@ -590,6 +591,63 @@ describe("StopGapWidgetDriver", () => { }); }); + describe("sendStickyEvent", () => { + let driver: WidgetDriver; + const roomId = "!this-room-id"; + + beforeEach(() => { + driver = mkDefaultDriver(); + }); + + it("sends sticky message events", async () => { + client._unstable_sendStickyEvent.mockResolvedValue({ + event_id: "id", + }); + + await expect(driver.sendStickyEvent(2000, EventType.RoomMessage, {})).resolves.toEqual({ + roomId, + eventId: "id", + }); + + expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith( + roomId, + 2000, + null, + EventType.RoomMessage, + {}, + ); + }); + }); + + describe("sendDelayedStickyEvent", () => { + let driver: WidgetDriver; + const roomId = "!this-room-id"; + + beforeEach(() => { + driver = mkDefaultDriver(); + }); + + it("sends delayed sticky message events", async () => { + client._unstable_sendStickyDelayedEvent.mockResolvedValue({ + delay_id: "id", + }); + + await expect(driver.sendDelayedStickyEvent(1000, null, 2000, EventType.RoomMessage, {})).resolves.toEqual({ + roomId, + delayId: "id", + }); + + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( + roomId, + 2000, + { delay: 1000 }, + null, + EventType.RoomMessage, + {}, + ); + }); + }); + describe("If the feature_dynamic_room_predecessors feature is not enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); From 922fcb687d02465295a9f84b528cfe4c9a3c7c5c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 18 Nov 2025 15:02:42 +0000 Subject: [PATCH 4/4] Clean up duplication --- src/stores/widgets/StopGapWidgetDriver.ts | 78 ++++++++----------- .../widgets/StopGapWidgetDriver-test.ts | 21 +++++ 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index afc3a45cb19..cb44c81e0d5 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -39,6 +39,7 @@ import { type SendDelayedEventResponse, type StateEvents, type TimelineEvents, + SendDelayedEventRequestOpts, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { @@ -285,6 +286,13 @@ export class StopGapWidgetDriver extends WidgetDriver { return allAllowed; } + private getSendEventTarget(roomId: string | null = null) { + const client = MatrixClientPeg.safeGet(); + roomId = roomId || SdkContextClass.instance.roomViewStore.getRoomId() || null; + if (!roomId) throw new Error("No room specified and no room in RoomViewStore focus."); + return { client, roomId }; + } + public async sendEvent( eventType: K, content: StateEvents[K], @@ -303,10 +311,7 @@ export class StopGapWidgetDriver extends WidgetDriver { stateKey: string | null = null, targetRoomId: string | null = null, ): Promise { - const client = MatrixClientPeg.get(); - const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); - - if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + const { client, roomId } = this.getSendEventTarget(targetRoomId); let r: { event_id: string } | null; if (stateKey !== null) { @@ -355,10 +360,7 @@ export class StopGapWidgetDriver extends WidgetDriver { content: unknown, targetRoomId?: string | null, ): Promise { - const client = MatrixClientPeg.get(); - const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); - - if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + const { client, roomId } = this.getSendEventTarget(targetRoomId); const r = await client._unstable_sendStickyEvent( roomId, @@ -370,6 +372,20 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } + private getSendDelayedEventOpts(delay: number | null, parentDelayId: string | null): SendDelayedEventRequestOpts { + if (delay !== null) { + return { + delay, + ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), + }; + } else if (parentDelayId !== null) { + return { + parent_delay_id: parentDelayId, + }; + } + throw new Error("Must provide at least one of delay or parentDelayId"); + } + /** * @experimental Part of MSC4140 & MSC4157 * @see {@link WidgetDriver#sendDelayedEvent} @@ -401,24 +417,8 @@ export class StopGapWidgetDriver extends WidgetDriver { stateKey: string | null = null, targetRoomId: string | null = null, ): Promise { - const client = MatrixClientPeg.get(); - const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); - - if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); - - let delayOpts; - if (delay !== null) { - delayOpts = { - delay, - ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), - }; - } else if (parentDelayId !== null) { - delayOpts = { - parent_delay_id: parentDelayId, - }; - } else { - throw new Error("Must provide at least one of delay or parentDelayId"); - } + const { client, roomId } = this.getSendEventTarget(targetRoomId); + let delayOpts = this.getSendDelayedEventOpts(delay, parentDelayId); let r: SendDelayedEventResponse | null; if (stateKey !== null) { @@ -459,24 +459,8 @@ export class StopGapWidgetDriver extends WidgetDriver { content: unknown, targetRoomId?: string | null, ): Promise { - const client = MatrixClientPeg.get(); - const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); - - if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); - - let delayOpts; - if (delay !== null) { - delayOpts = { - delay, - ...(parentDelayId !== null && { parent_delay_id: parentDelayId }), - }; - } else if (parentDelayId !== null) { - delayOpts = { - parent_delay_id: parentDelayId, - }; - } else { - throw new Error("Must provide at least one of delay or parentDelayId"); - } + const { client, roomId } = this.getSendEventTarget(targetRoomId); + let delayOpts = this.getSendDelayedEventOpts(delay, parentDelayId); const r = await client._unstable_sendStickyDelayedEvent( roomId, @@ -495,7 +479,7 @@ export class StopGapWidgetDriver extends WidgetDriver { public async cancelScheduledDelayedEvent(delayId: string): Promise { const client = MatrixClientPeg.get(); - if (!client) throw new Error("Not in a room or not attached to a client"); + if (!client) throw new Error("Not attached to a client"); await client._unstable_cancelScheduledDelayedEvent(delayId); } @@ -506,7 +490,7 @@ export class StopGapWidgetDriver extends WidgetDriver { public async restartScheduledDelayedEvent(delayId: string): Promise { const client = MatrixClientPeg.get(); - if (!client) throw new Error("Not in a room or not attached to a client"); + if (!client) throw new Error("Not attached to a client"); await client._unstable_restartScheduledDelayedEvent(delayId); } @@ -517,7 +501,7 @@ export class StopGapWidgetDriver extends WidgetDriver { public async sendScheduledDelayedEvent(delayId: string): Promise { const client = MatrixClientPeg.get(); - if (!client) throw new Error("Not in a room or not attached to a client"); + if (!client) throw new Error("Not attached to a client"); await client._unstable_sendScheduledDelayedEvent(delayId); } diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index 4ab05c8413f..4db05b5b79a 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -646,6 +646,27 @@ describe("StopGapWidgetDriver", () => { {}, ); }); + it("sends child action delayed sticky message events", async () => { + client._unstable_sendStickyDelayedEvent.mockResolvedValue({ + delay_id: "id-child", + }); + + await expect( + driver.sendDelayedStickyEvent(null, "id-parent", 2000, EventType.RoomMessage, {}), + ).resolves.toEqual({ + roomId, + delayId: "id-child", + }); + + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( + roomId, + 2000, + { parent_delay_id: "id-parent" }, + null, + EventType.RoomMessage, + {}, + ); + }); }); describe("If the feature_dynamic_room_predecessors feature is not enabled", () => {