diff --git a/package.json b/package.json index 9fa380021..c6b2a2350 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "jwt-decode": "^4.0.0", "loglevel": "^1.7.1", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.6.0", + "matrix-widget-api": "^1.8.1", "oidc-client-ts": "^3.0.1", "p-retry": "4", "sdp-transform": "^2.14.1", diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 4910759ea..e64ba412a 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -268,7 +268,8 @@ describe("MSC4108SignInWithQR", () => { it("should abort if device doesn't come up by timeout", async () => { jest.spyOn(global, "setTimeout").mockImplementation((fn) => { (fn)(); - return -1; + // TODO: mock timers properly + return -1 as any; }); jest.spyOn(Date, "now").mockImplementation(() => { return 12345678 + mocked(setTimeout).mock.calls.length * 1000; @@ -320,7 +321,8 @@ describe("MSC4108SignInWithQR", () => { it("should not send secrets if user cancels", async () => { jest.spyOn(global, "setTimeout").mockImplementation((fn) => { (fn)(); - return -1; + // TODO: mock timers properly + return -1 as any; }); await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index d9ac5a62f..d3b3e1235 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -32,7 +32,7 @@ import { IOpenIDCredentials, } from "matrix-widget-api"; -import { createRoomWidgetClient, MsgType } from "../../src/matrix"; +import { createRoomWidgetClient, MsgType, UpdateDelayedEventAction } from "../../src/matrix"; import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client"; import { SyncState } from "../../src/sync"; import { ICapabilities } from "../../src/embedded"; @@ -59,8 +59,26 @@ class MockWidgetApi extends EventEmitter { public requestCapabilityToReceiveState = jest.fn(); public requestCapabilityToSendToDevice = jest.fn(); public requestCapabilityToReceiveToDevice = jest.fn(); - public sendRoomEvent = jest.fn(() => ({ event_id: `$${Math.random()}` })); - public sendStateEvent = jest.fn(); + public sendRoomEvent = jest.fn( + (eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) => + delay === undefined && parentDelayId === undefined + ? { event_id: `$${Math.random()}` } + : { delay_id: `id-${Math.random()}` }, + ); + public sendStateEvent = jest.fn( + ( + eventType: string, + stateKey: string, + content: unknown, + roomId?: string, + delay?: number, + parentDelayId?: string, + ) => + delay === undefined && parentDelayId === undefined + ? { event_id: `$${Math.random()}` } + : { delay_id: `id-${Math.random()}` }, + ); + public updateDelayedEvent = jest.fn(); public sendToDevice = jest.fn(); public requestOpenIDConnectToken = jest.fn(() => { return testOIDCToken; @@ -125,6 +143,17 @@ describe("RoomWidgetClient", () => { ); }); + it("send handles wrong field in response", async () => { + await makeClient({ sendEvent: ["org.matrix.rageshake_request"] }); + widgetApi.sendRoomEvent.mockResolvedValueOnce({ + room_id: "!1:example.org", + delay_id: `id-${Math.random}`, + }); + await expect( + client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 }), + ).rejects.toThrow(); + }); + it("receives", async () => { const event = new MatrixEvent({ type: "org.matrix.rageshake_request", @@ -160,6 +189,199 @@ describe("RoomWidgetClient", () => { }); }); + describe("delayed events", () => { + describe("when supported", () => { + const doesServerSupportUnstableFeatureMock = jest.fn((feature) => + Promise.resolve(feature === "org.matrix.msc4140"), + ); + + beforeAll(() => { + MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock; + }); + + afterAll(() => { + doesServerSupportUnstableFeatureMock.mockReset(); + }); + + it("sends delayed message events", async () => { + await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent); + await client._unstable_sendDelayedEvent( + "!1:example.org", + { delay: 2000 }, + null, + "org.matrix.rageshake_request", + { request_id: 123 }, + ); + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + "org.matrix.rageshake_request", + { request_id: 123 }, + "!1:example.org", + 2000, + undefined, + ); + }); + + it("sends child action delayed message events", async () => { + await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent); + const parentDelayId = `id-${Math.random()}`; + await client._unstable_sendDelayedEvent( + "!1:example.org", + { parent_delay_id: parentDelayId }, + null, + "org.matrix.rageshake_request", + { request_id: 123 }, + ); + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + "org.matrix.rageshake_request", + { request_id: 123 }, + "!1:example.org", + undefined, + parentDelayId, + ); + }); + + it("sends delayed state events", async () => { + await makeClient({ + sendDelayedEvents: true, + sendState: [{ eventType: "org.example.foo", stateKey: "bar" }], + }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent); + await client._unstable_sendDelayedStateEvent( + "!1:example.org", + { delay: 2000 }, + "org.example.foo", + { hello: "world" }, + "bar", + ); + expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( + "org.example.foo", + "bar", + { hello: "world" }, + "!1:example.org", + 2000, + undefined, + ); + }); + + it("sends child action delayed state events", async () => { + await makeClient({ + sendDelayedEvents: true, + sendState: [{ eventType: "org.example.foo", stateKey: "bar" }], + }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent); + const parentDelayId = `fg-${Math.random()}`; + await client._unstable_sendDelayedStateEvent( + "!1:example.org", + { parent_delay_id: parentDelayId }, + "org.example.foo", + { hello: "world" }, + "bar", + ); + expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( + "org.example.foo", + "bar", + { hello: "world" }, + "!1:example.org", + undefined, + parentDelayId, + ); + }); + + it("send delayed message events handles wrong field in response", async () => { + await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + widgetApi.sendRoomEvent.mockResolvedValueOnce({ + room_id: "!1:example.org", + event_id: `$${Math.random()}`, + }); + await expect( + client._unstable_sendDelayedEvent( + "!1:example.org", + { delay: 2000 }, + null, + "org.matrix.rageshake_request", + { request_id: 123 }, + ), + ).rejects.toThrow(); + }); + + it("send delayed state events handles wrong field in response", async () => { + await makeClient({ + sendDelayedEvents: true, + sendState: [{ eventType: "org.example.foo", stateKey: "bar" }], + }); + widgetApi.sendStateEvent.mockResolvedValueOnce({ + room_id: "!1:example.org", + event_id: `$${Math.random()}`, + }); + await expect( + client._unstable_sendDelayedStateEvent( + "!1:example.org", + { delay: 2000 }, + "org.example.foo", + { hello: "world" }, + "bar", + ), + ).rejects.toThrow(); + }); + + it("updates delayed events", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + for (const action of [ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ]) { + await client._unstable_updateDelayedEvent("id", action); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action); + } + }); + }); + + describe("when unsupported", () => { + it("fails to send delayed message events", async () => { + await makeClient({ sendEvent: ["org.matrix.rageshake_request"] }); + await expect( + client._unstable_sendDelayedEvent( + "!1:example.org", + { delay: 2000 }, + null, + "org.matrix.rageshake_request", + { request_id: 123 }, + ), + ).rejects.toThrow("Server does not support"); + }); + + it("fails to send delayed state events", async () => { + await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + await expect( + client._unstable_sendDelayedStateEvent( + "!1:example.org", + { delay: 2000 }, + "org.example.foo", + { hello: "world" }, + "bar", + ), + ).rejects.toThrow("Server does not support"); + }); + + it("fails to update delayed state events", async () => { + await makeClient({}); + for (const action of [ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ]) { + await expect(client._unstable_updateDelayedEvent("id", action)).rejects.toThrow( + "Server does not support", + ); + } + }); + }); + }); + describe("initialization", () => { it("requests permissions for specific message types", async () => { await makeClient({ sendMessage: [MsgType.Text], receiveMessage: [MsgType.Text] }); @@ -211,6 +433,17 @@ describe("RoomWidgetClient", () => { ); }); + it("send handles incorrect response", async () => { + await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + widgetApi.sendStateEvent.mockResolvedValueOnce({ + room_id: "!1:example.org", + delay_id: `id-${Math.random}`, + }); + await expect( + client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar"), + ).rejects.toThrow(); + }); + it("receives", async () => { await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); diff --git a/src/client.ts b/src/client.ts index 6bff7bab5..de0af218f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -535,7 +535,7 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666"; export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms"; export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms"; -const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; +export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; enum CrossSigningKeyType { MasterKey = "master_key", diff --git a/src/embedded.ts b/src/embedded.ts index d2ba19a57..8b99836bc 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -26,8 +26,13 @@ import { } from "matrix-widget-api"; import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event"; -import { ISendEventResponse, SendDelayedEventRequestOpts, SendDelayedEventResponse } from "./@types/requests"; -import { EventType } from "./@types/event"; +import { + ISendEventResponse, + SendDelayedEventRequestOpts, + SendDelayedEventResponse, + UpdateDelayedEventAction, +} from "./@types/requests"; +import { EventType, StateEvents } from "./@types/event"; import { logger } from "./logger"; import { MatrixClient, @@ -36,6 +41,7 @@ import { IStartClientOpts, SendToDeviceContentMap, IOpenIDToken, + UNSTABLE_MSC4140_DELAYED_EVENTS, } from "./client"; import { SyncApi, SyncState } from "./sync"; import { SlidingSyncSdk } from "./sliding-sync-sdk"; @@ -95,6 +101,20 @@ export interface ICapabilities { * @defaultValue false */ turnServers?: boolean; + + /** + * Whether this client needs to be able to send delayed events. + * @experimental Part of MSC4140 & MSC4157 + * @defaultValue false + */ + sendDelayedEvents?: boolean; + + /** + * Whether this client needs to be able to update delayed events. + * @experimental Part of MSC4140 & MSC4157 + * @defaultValue false + */ + updateDelayedEvents?: boolean; } /** @@ -162,6 +182,18 @@ export class RoomWidgetClient extends MatrixClient { ); capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType)); capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType)); + if ( + capabilities.sendDelayedEvents && + (capabilities.sendEvent?.length || + capabilities.sendMessage === true || + (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) || + capabilities.sendState?.length) + ) { + widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent); + } + if (capabilities.updateDelayedEvents) { + widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent); + } if (capabilities.turnServers) { widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers); } @@ -260,8 +292,17 @@ export class RoomWidgetClient extends MatrixClient { delayOpts?: SendDelayedEventRequestOpts, ): Promise { if (delayOpts) { - throw new Error("Delayed event sending via widgets is not implemented"); + // TODO: updatePendingEvent for delayed events? + const response = await this.widgetApi.sendRoomEvent( + event.getType(), + event.getContent(), + room.roomId, + "delay" in delayOpts ? delayOpts.delay : undefined, + "parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined, + ); + return this.validateSendDelayedEventResponse(response); } + let response: ISendEventFromWidgetResponseData; try { response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId); @@ -270,6 +311,7 @@ export class RoomWidgetClient extends MatrixClient { throw e; } + // This also checks for an event id on the response room.updatePendingEvent(event, EventStatus.SENT, response.event_id); return { event_id: response.event_id! }; } @@ -280,7 +322,56 @@ export class RoomWidgetClient extends MatrixClient { content: any, stateKey = "", ): Promise { - return (await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId)) as ISendEventResponse; + const response = await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId); + if (response.event_id === undefined) { + throw new Error("'event_id' absent from response to an event request"); + } + return { event_id: response.event_id }; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_sendDelayedStateEvent( + roomId: string, + delayOpts: SendDelayedEventRequestOpts, + eventType: K, + content: StateEvents[K], + stateKey = "", + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw Error("Server does not support the delayed events API"); + } + + const response = await this.widgetApi.sendStateEvent( + eventType, + stateKey, + content, + roomId, + "delay" in delayOpts ? delayOpts.delay : undefined, + "parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined, + ); + return this.validateSendDelayedEventResponse(response); + } + + private validateSendDelayedEventResponse(response: ISendEventFromWidgetResponseData): SendDelayedEventResponse { + if (response.delay_id === undefined) { + throw new Error("'delay_id' absent from response to a delayed event request"); + } + return { delay_id: response.delay_id }; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<{}> { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw Error("Server does not support the delayed events API"); + } + + return await this.widgetApi.updateDelayedEvent(delayId, action); } public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<{}> { diff --git a/yarn.lock b/yarn.lock index 7cadabeff..1396c0b89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4722,10 +4722,10 @@ matrix-mock-request@^2.5.0: dependencies: expect "^28.1.0" -matrix-widget-api@^1.6.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.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.8.1.tgz#90fe4814956a27d6c6cff55c2aa67da516722eb1" + integrity sha512-oISJ9rmkfxNonrrWKUB2HCwj0i+J0b7zNGqwNudOjXj9Jsv7r8UWqowE7AEWyAsPp4IwW/hEvNeLbNo2wUsXwg== dependencies: "@types/events" "^3.0.0" events "^3.2.0"