From f5fcf55c600e60274a8efcdff83b2ed9e113cad0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 28 Jun 2024 10:50:48 -0400 Subject: [PATCH] Support delayed events (MSC4140) for call widget The Widget API spec for delayed events is defined by MSC4157. Also support "parent" delayed events, which were in a previous version of MSC4140 and may be reintroduced or be part of a new MSC later. --- package.json | 2 +- src/stores/widgets/StopGapWidgetDriver.ts | 105 ++++++++++++++- .../widgets/StopGapWidgetDriver-test.ts | 122 ++++++++++++++++++ test/test-utils/test-utils.ts | 4 + yarn.lock | 8 +- 5 files changed, 230 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 4e11ae1325d..52d9131786b 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,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.5.0", + "matrix-widget-api": "^1.8.2", "memoize-one": "^6.0.0", "minimist": "^1.2.5", "oidc-client-ts": "^3.0.1", diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index cce1ca18a65..6bb3e887da8 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -19,6 +19,7 @@ import { EventDirection, IOpenIDCredentials, IOpenIDUpdate, + ISendDelayedEventDetails, ISendEventDetails, ITurnServer, IReadEventRelationsResult, @@ -33,6 +34,7 @@ import { WidgetKind, ISearchUserDirectoryResult, IGetMediaConfigResult, + UpdateDelayedEventAction, } from "matrix-widget-api"; import { ClientEvent, @@ -43,6 +45,7 @@ import { Room, Direction, THREAD_RELATION_TYPE, + SendDelayedEventResponse, StateEvents, TimelineEvents, } from "matrix-js-sdk/src/matrix"; @@ -128,6 +131,8 @@ export class StopGapWidgetDriver extends WidgetDriver { this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen); this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`); + this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); + this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); this.allowedCapabilities.add( WidgetEventCapability.forRoomEvent(EventDirection.Send, "org.matrix.rageshake_request").raw, @@ -160,7 +165,7 @@ export class StopGapWidgetDriver extends WidgetDriver { `_${clientUserId}_${clientDeviceId}`, ).raw, ); - // MSC3779 version, with no leading underscore + // Version with no leading underscore, for room versions whose auth rules allow it this.allowedCapabilities.add( WidgetEventCapability.forStateEvent( EventDirection.Send, @@ -271,20 +276,20 @@ export class StopGapWidgetDriver extends WidgetDriver { public async sendEvent( eventType: K, content: StateEvents[K], - stateKey?: string, - targetRoomId?: string, + stateKey: string | null, + targetRoomId: string | null, ): Promise; public async sendEvent( eventType: K, content: TimelineEvents[K], stateKey: null, - targetRoomId?: string, + targetRoomId: string | null, ): Promise; public async sendEvent( eventType: string, content: IContent, - stateKey?: string | null, - targetRoomId?: string, + stateKey: string | null = null, + targetRoomId: string | null = null, ): Promise { const client = MatrixClientPeg.get(); const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); @@ -328,6 +333,94 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } + /** + * @experimental Part of MSC4140 & MSC4157 + * @see {@link WidgetDriver#sendDelayedEvent} + */ + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null, + ): Promise; + /** + * @experimental Part of MSC4140 & MSC4157 + */ + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null, + ): Promise; + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + 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"); + } + + let r: SendDelayedEventResponse | null; + if (stateKey !== null) { + // state event + r = await client._unstable_sendDelayedStateEvent( + roomId, + delayOpts, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey, + ); + } else { + // message event + r = await client._unstable_sendDelayedEvent( + roomId, + delayOpts, + null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents], + ); + } + + return { + roomId, + delayId: r.delay_id, + }; + } + + /** + * @experimental Part of MSC4140 & MSC4157 + */ + public async updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise { + const client = MatrixClientPeg.get(); + + if (!client) throw new Error("Not in a room or not attached to a client"); + + await client._unstable_updateDelayedEvent(delayId, action); + } + public async sendToDevice( eventType: string, encrypted: boolean, diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index b0ec31044ae..efe0fafe554 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -35,6 +35,7 @@ import { SimpleObservable, OpenIDRequestState, IOpenIDUpdate, + UpdateDelayedEventAction, } from "matrix-widget-api"; import { ApprovalOpts, @@ -122,6 +123,8 @@ describe("StopGapWidgetDriver", () => { "org.matrix.msc3819.receive.to_device:org.matrix.call.sdp_stream_metadata_changed", "org.matrix.msc3819.send.to_device:m.call.replaces", "org.matrix.msc3819.receive.to_device:m.call.replaces", + "org.matrix.msc4157.send.delayed_event", + "org.matrix.msc4157.update_delayed_event", ]); // As long as this resolves, we'll know that it didn't try to pop up a modal @@ -388,6 +391,125 @@ describe("StopGapWidgetDriver", () => { }); }); + describe("sendDelayedEvent", () => { + let driver: WidgetDriver; + const roomId = "!this-room-id"; + + beforeEach(() => { + driver = mkDefaultDriver(); + }); + + it("cannot send delayed events with missing arguments", async () => { + await expect(driver.sendDelayedEvent(null, null, EventType.RoomMessage, {})).rejects.toThrow( + "Must provide at least one of", + ); + }); + + it("sends delayed message events", async () => { + client._unstable_sendDelayedEvent.mockResolvedValue({ + delay_id: "id", + }); + + await expect(driver.sendDelayedEvent(2000, null, EventType.RoomMessage, {})).resolves.toEqual({ + roomId, + delayId: "id", + }); + + expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith( + roomId, + { delay: 2000 }, + null, + EventType.RoomMessage, + {}, + ); + }); + + it("sends child action delayed message events", async () => { + client._unstable_sendDelayedEvent.mockResolvedValue({ + delay_id: "id-child", + }); + + await expect(driver.sendDelayedEvent(null, "id-parent", EventType.RoomMessage, {})).resolves.toEqual({ + roomId, + delayId: "id-child", + }); + + expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith( + roomId, + { parent_delay_id: "id-parent" }, + null, + EventType.RoomMessage, + {}, + ); + }); + + it("sends delayed state events", async () => { + client._unstable_sendDelayedStateEvent.mockResolvedValue({ + delay_id: "id", + }); + + await expect(driver.sendDelayedEvent(2000, null, EventType.RoomTopic, {}, "")).resolves.toEqual({ + roomId, + delayId: "id", + }); + + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + roomId, + { delay: 2000 }, + EventType.RoomTopic, + {}, + "", + ); + }); + + it("sends child action delayed state events", async () => { + client._unstable_sendDelayedStateEvent.mockResolvedValue({ + delay_id: "id-child", + }); + + await expect(driver.sendDelayedEvent(null, "id-parent", EventType.RoomTopic, {}, "")).resolves.toEqual({ + roomId, + delayId: "id-child", + }); + + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + roomId, + { parent_delay_id: "id-parent" }, + EventType.RoomTopic, + {}, + "", + ); + }); + }); + + describe("updateDelayedEvent", () => { + let driver: WidgetDriver; + + beforeEach(() => { + driver = mkDefaultDriver(); + }); + + it("updates delayed events", async () => { + client._unstable_updateDelayedEvent.mockResolvedValue({}); + for (const action of [ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ]) { + await expect(driver.updateDelayedEvent("id", action)).resolves.toBeUndefined(); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledWith("id", action); + } + }); + + it("fails to update delayed events", async () => { + const errorMessage = "Cannot restart this delayed event"; + client._unstable_updateDelayedEvent.mockRejectedValue(new Error(errorMessage)); + await expect(driver.updateDelayedEvent("id", UpdateDelayedEventAction.Restart)).rejects.toThrow( + errorMessage, + ); + }); + }); + describe("If the feature_dynamic_room_predecessors feature is not enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 7b9cdf725d3..a263ff296ce 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -252,6 +252,10 @@ export function createTestClient(): MatrixClient { }); }), + _unstable_sendDelayedEvent: jest.fn(), + _unstable_sendDelayedStateEvent: jest.fn(), + _unstable_updateDelayedEvent: jest.fn(), + searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }), setDeviceVerified: jest.fn(), joinRoom: jest.fn(), diff --git a/yarn.lock b/yarn.lock index 2c08edc1978..3eea3c39250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6938,10 +6938,10 @@ matrix-web-i18n@^3.2.1: minimist "^1.2.8" walk "^2.3.15" -matrix-widget-api@^1.5.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.7.0.tgz#ae3b44380f11bb03519d0bf0373dfc3341634667" - integrity sha512-dzSnA5Va6CeIkyWs89xZty/uv38HLyfjOrHGbbEikCa2ZV0HTkUNtrBMKlrn4CRYyDJ6yoO/3ssRwiR0jJvOkQ== +matrix-widget-api@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.8.2.tgz#28d344502a85593740f560b0f8120e474a054505" + integrity sha512-kdmks3CvFNPIYN669Y4rO13KrazDvX8KHC7i6jOzJs8uZ8s54FNkuRVVyiQHeVCSZG5ixUqW9UuCj9lf03qxTQ== dependencies: "@types/events" "^3.0.0" events "^3.2.0"