diff --git a/package.json b/package.json index 476cda0d1b..4c0f8ee069 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "jwt-decode": "^4.0.0", "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.10.0", + "matrix-widget-api": "^1.14.0", "oidc-client-ts": "^3.0.1", "p-retry": "7", "sdp-transform": "^3.0.0", diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index fac2c9958e..16752b3259 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -86,7 +86,9 @@ class MockWidgetApi extends EventEmitter { ? { event_id: `$${Math.random()}` } : { delay_id: `id-${Math.random()}` }, ); - public updateDelayedEvent = jest.fn().mockResolvedValue(undefined); + public cancelScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined); + public restartScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined); + public sendScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined); public sendToDevice = jest.fn().mockResolvedValue(undefined); public requestOpenIDConnectToken = jest.fn(async () => { return testOIDCToken; @@ -531,17 +533,46 @@ describe("RoomWidgetClient", () => { ).rejects.toThrow(); }); - it("updates delayed events", async () => { + it("can cancel scheduled delayed events (action in request body)", 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); - } + await client._unstable_cancelScheduledDelayedEvent("id"); + expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id"); + }); + + it("can restart scheduled delayed events (action in request body)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_restartScheduledDelayedEvent("id"); + expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id"); + }); + + it("can send scheduled delayed events (action in request body)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_sendScheduledDelayedEvent("id"); + expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id"); + }); + + it("can cancel scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_cancelScheduledDelayedEvent("id"); + expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id"); + }); + + it("can restart scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_restartScheduledDelayedEvent("id"); + expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id"); + }); + + it("can send scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_sendScheduledDelayedEvent("id"); + expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id"); }); }); @@ -583,6 +614,13 @@ describe("RoomWidgetClient", () => { "Server does not support", ); } + for (const updateDelayedEvent of [ + client._unstable_cancelScheduledDelayedEvent, + client._unstable_restartScheduledDelayedEvent, + client._unstable_sendScheduledDelayedEvent, + ]) { + await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support"); + } }); }); }); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 026142f8f1..db20f06eb6 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -801,6 +801,10 @@ describe("MatrixClient", function () { await expect( client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send), ).rejects.toThrow(errorMessage); + + await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); + await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); + await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); }); it("works with null threadId", async () => { @@ -1077,21 +1081,140 @@ describe("MatrixClient", function () { }); }); - it("can update delayed events", async () => { + it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])( + "can %s scheduled delayed events (action in request body)", + async (action: UpdateDelayedEventAction) => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action, + }, + }, + ]; + + await client._unstable_updateDelayedEvent(delayId, action); + }, + ); + + it("can cancel scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`, + }, + ]; + + await client._unstable_cancelScheduledDelayedEvent(delayId); + }); + + it("can restart scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/restart`, + }, + ]; + + await client._unstable_restartScheduledDelayedEvent(delayId); + }); + + it("can send scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/send`, + }, + ]; + + await client._unstable_sendScheduledDelayedEvent(delayId); + }); + + it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action: UpdateDelayedEventAction.Cancel, + }, + }, + ]; + + await client._unstable_cancelScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); + }); + + it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => { const delayId = "id"; - const action = UpdateDelayedEventAction.Restart; httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/restart`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, { method: "POST", prefix: unstableMSC4140Prefix, path: `/delayed_events/${encodeURIComponent(delayId)}`, data: { - action, + action: UpdateDelayedEventAction.Restart, }, }, ]; - await client._unstable_updateDelayedEvent(delayId, action); + await client._unstable_restartScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); + }); + + it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/send`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action: UpdateDelayedEventAction.Send, + }, + }, + ]; + + await client._unstable_sendScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 8eb11ecdd1..41848a8e62 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -523,6 +523,9 @@ describe("MatrixRTCSession", () => { client.sendEvent = sendEventMock; client._unstable_updateDelayedEvent = jest.fn(); + client._unstable_cancelScheduledDelayedEvent = jest.fn(); + client._unstable_restartScheduledDelayedEvent = jest.fn(); + client._unstable_sendScheduledDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 8c4f90c002..cf70c66d7a 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -94,6 +94,9 @@ describe("MembershipManager", () => { // Provide a default mock that is like the default "non error" server behaviour. (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_cancelScheduledDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_sendScheduledDelayedEvent as Mock).mockResolvedValue(undefined); (client._unstable_sendStickyEvent as Mock).mockResolvedValue({ event_id: "id" }); (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); @@ -122,7 +125,9 @@ describe("MembershipManager", () => { it("sends a membership event and schedules delayed leave when joining a call", async () => { // Spys/Mocks - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + const restartScheduledDelayedEventHandle = createAsyncHandle( + client._unstable_restartScheduledDelayedEvent as Mock, + ); // Test const memberManager = new MembershipManager(undefined, room, client, callSession); @@ -143,7 +148,7 @@ describe("MembershipManager", () => { }, "_@alice:example.org_AAAAAAA_m.call", ); - updateDelayedEventHandle.resolve?.(); + restartScheduledDelayedEventHandle.resolve?.(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 8000 }, @@ -157,13 +162,13 @@ describe("MembershipManager", () => { it("reschedules delayed leave event if sending state cancels it", async () => { const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); - const waitForUpdateDelaye = waitForMockCallOnce( - client._unstable_updateDelayedEvent, + const waitForRestartScheduledDelayedEvent = waitForMockCallOnce( + client._unstable_restartScheduledDelayedEvent, Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), ); memberManager.join([focus], focusActive); await waitForSendState; - await waitForUpdateDelaye; + await waitForRestartScheduledDelayedEvent; await jest.advanceTimersByTimeAsync(1); // Once for the initial event and once because of the errcode: "M_NOT_FOUND" // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) @@ -179,7 +184,7 @@ describe("MembershipManager", () => { if (useOwnedStateEvents) { room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); } - const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); + const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent); const sentDelayedState = waitForMockCall( client._unstable_sendDelayedStateEvent, Promise.resolve({ @@ -265,13 +270,13 @@ describe("MembershipManager", () => { await sentDelayedState; // should have prepared the heartbeat to keep delaying the leave event while still connected - await updatedDelayedEvent; - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + await restartScheduledDelayedEvent; + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. await jest.advanceTimersByTimeAsync(5000); // should update delayed disconnect - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2); } it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { @@ -343,7 +348,7 @@ describe("MembershipManager", () => { // (onRTCSessionMemberUpdate) // - Only then do we resolve the sending of the delayed event. // - We test that the manager acknowledges the leave and sends a new membership state event. - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValueOnce( new MatrixError({ errcode: "M_NOT_FOUND" }), ); @@ -404,17 +409,17 @@ describe("MembershipManager", () => { manager.join([focus]); await jest.advanceTimersByTimeAsync(1); await manager.leave(); - expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); + expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id"); expect(client.sendStateEvent).toHaveBeenCalled(); }); it("send leave event when leave is called and resolving delayed leave fails", async () => { const manager = new MembershipManager({}, room, client, callSession); manager.join([focus]); await jest.advanceTimersByTimeAsync(1); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); + (client._unstable_sendScheduledDelayedEvent as Mock).mockRejectedValue("unknown"); await manager.leave(); - // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. + // We send a normal leave event since we failed using sendScheduledDelayedEvent. expect(client.sendStateEvent).toHaveBeenLastCalledWith( room.roomId, "org.matrix.msc3401.call.member", @@ -438,6 +443,9 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { const manager = new MembershipManager({}, room, client, callSession); @@ -447,6 +455,9 @@ describe("MembershipManager", () => { // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_cancelScheduledDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); + (client._unstable_sendScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); await manager.onRTCSessionMemberUpdate([ @@ -462,6 +473,9 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { const manager = new MembershipManager({}, room, client, callSession); @@ -469,7 +483,7 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); // Our own membership is removed: @@ -478,7 +492,7 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled(); }); it("updates the UpdateExpiry entry in the action scheduler", async () => { @@ -487,10 +501,10 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValueOnce( new MatrixError({ errcode: "M_NOT_FOUND" }), ); @@ -503,7 +517,7 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled(); expect(manager.status).toBe(Status.Connected); }); }); @@ -523,17 +537,17 @@ describe("MembershipManager", () => { // The first call is from checking id the server deleted the delayed event // so it does not need a `advanceTimersByTime` - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + // expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); for (let i = 2; i <= 12; i++) { // flush promises before advancing the timers to make sure schedulers are setup await jest.advanceTimersByTimeAsync(10_000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i); // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + // expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); } }); @@ -681,7 +695,7 @@ describe("MembershipManager", () => { }); describe("retries sending update delayed leave event restart", () => { it("resends the initial check delayed update event", async () => { - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -695,17 +709,17 @@ describe("MembershipManager", () => { // Hit rate limit await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // Hit second rate limit. await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2); // Setup resolve - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue(undefined); await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); }); @@ -734,7 +748,7 @@ describe("MembershipManager", () => { // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. it("throws, when reaching maximum number of retries", async () => { const delayEventRestartError = jest.fn(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -808,11 +822,11 @@ describe("MembershipManager", () => { manager.join([focus], focusActive); try { // Let the scheduler run one iteration so that we can send the join state event - await waitForMockCall(client._unstable_updateDelayedEvent); + await waitForMockCall(client._unstable_restartScheduledDelayedEvent); // We never resolve the delayed event so that we can test the probablyLeft event. // This simulates the case where the server does not respond to the delayed event. - client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); + client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(manager.status).toBe(Status.Connected); expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); @@ -822,18 +836,18 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(5000); // No emission after 5s expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(4999); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3); expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue({}); // Emit after 10s await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4); expect(probablyLeftEmit).toHaveBeenCalledWith(true); // Mock a sync which does not include our own membership @@ -898,8 +912,8 @@ describe("MembershipManager", () => { describe("join()", () => { describe("sends an rtc membership event", () => { it("sends a membership event and schedules delayed leave when joining a call", async () => { - const updateDelayedEventHandle = createAsyncHandle( - client._unstable_updateDelayedEvent as Mock, + const restartScheduledDelayedEventHandle = createAsyncHandle( + client._unstable_restartScheduledDelayedEvent as Mock, ); const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession); @@ -925,7 +939,7 @@ describe("MembershipManager", () => { msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", }, ); - updateDelayedEventHandle.resolve?.(); + restartScheduledDelayedEventHandle.resolve?.(); // Ensure we have sent the delayed disconnect event. expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 24982afe25..999d6c92b4 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -52,6 +52,9 @@ export type MockClient = Pick< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" @@ -67,6 +70,9 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { sendStateEvent: jest.fn(), cancelPendingEvent: jest.fn(), _unstable_updateDelayedEvent: jest.fn(), + _unstable_cancelScheduledDelayedEvent: jest.fn(), + _unstable_restartScheduledDelayedEvent: jest.fn(), + _unstable_sendScheduledDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), _unstable_sendStickyEvent: jest.fn(), _unstable_sendStickyDelayedEvent: jest.fn(), diff --git a/src/client.ts b/src/client.ts index 1063806993..3da226d1ea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -106,6 +106,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts"; import { isSendDelayedEventRequestOpts, + UpdateDelayedEventAction, type DelayedEventInfo, type IAddThreePidOnlyBody, type IBindThreePidBody, @@ -130,7 +131,6 @@ import { type KnockRoomOpts, type SendDelayedEventRequestOpts, type SendDelayedEventResponse, - type UpdateDelayedEventAction, } from "./@types/requests.ts"; import { type AccountDataEvents, @@ -3583,14 +3583,100 @@ export class MatrixClient extends TypedEventEmitter { + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions); + } + + /** + * Restart the scheduled delivery of the delayed event matching the given delayId. + * + * Note: This endpoint is unstable, and can throw an `Error`. + * Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details. + * + * @throws A M_NOT_FOUND error if no matching delayed event could be found. + */ + // eslint-disable-next-line + public async _unstable_restartScheduledDelayedEvent( + delayId: string, + requestOptions: IRequestOpts = {}, + ): Promise { + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions); + } + + /** + * Immediately send the delayed event matching the given delayId, + * instead of waiting for its scheduled delivery. + * + * Note: This endpoint is unstable, and can throw an `Error`. + * Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details. + * + * @throws A M_NOT_FOUND error if no matching delayed event could be found. + */ + // eslint-disable-next-line + public async _unstable_sendScheduledDelayedEvent( + delayId: string, + requestOptions: IRequestOpts = {}, + ): Promise { + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions); + } + + private async updateScheduledDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + requestOptions: IRequestOpts = {}, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + `${action}ScheduledDelayedEvent`, + ); + } + try { + const path = utils.encodeUri("/delayed_events/$delayId/$action", { + $delayId: delayId, + $action: action, + }); + return await this.http.request(Method.Post, path, undefined, undefined, { + ...requestOptions, + prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, + }); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { + return await this.updateScheduledDelayedEventWithActionInBody(delayId, action, requestOptions); + } else { + throw e; + } + } + } + + private async updateScheduledDelayedEventWithActionInBody( + delayId: string, + action: UpdateDelayedEventAction, + requestOptions: IRequestOpts = {}, + ): Promise { const path = utils.encodeUri("/delayed_events/$delayId", { $delayId: delayId, }); const data = { action, }; - return await this.http.authedRequest(Method.Post, path, undefined, data, { + return await this.http.request(Method.Post, path, undefined, data, { ...requestOptions, prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, }); diff --git a/src/embedded.ts b/src/embedded.ts index 7bc18483c3..75ab22df23 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -37,7 +37,6 @@ import { type ISendEventResponse, type SendDelayedEventRequestOpts, type SendDelayedEventResponse, - type UpdateDelayedEventAction, } from "./@types/requests.ts"; import { EventType, type StateEvents } from "./@types/event.ts"; import { logger } from "./logger.ts"; @@ -461,15 +460,47 @@ export class RoomWidgetClient extends MatrixClient { * @experimental This currently relies on an unstable MSC (MSC4140). */ // eslint-disable-next-line - public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise { + public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise { if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { throw new UnsupportedDelayedEventsEndpointError( "Server does not support the delayed events API", - "updateDelayedEvent", + "cancelScheduledDelayedEvent", ); } - await this.widgetApi.updateDelayedEvent(delayId, action).catch(timeoutToConnectionError); + await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError); + return {}; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "restartScheduledDelayedEvent", + ); + } + + await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError); + return {}; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "sendScheduledDelayedEvent", + ); + } + + await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError); return {}; } diff --git a/src/errors.ts b/src/errors.ts index 672aee3bb4..9a9a0a548b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -59,7 +59,14 @@ export class ClientStoppedError extends Error { export class UnsupportedDelayedEventsEndpointError extends Error { public constructor( message: string, - public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents", + public clientEndpoint: + | "sendDelayedEvent" + | "updateDelayedEvent" + | "cancelScheduledDelayedEvent" + | "restartScheduledDelayedEvent" + | "sendScheduledDelayedEvent" + | "sendDelayedStateEvent" + | "getDelayedEvents", ) { super(message); this.name = "UnsupportedDelayedEventsEndpointError"; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 63bc8adf68..4e7b2b3006 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -178,7 +178,8 @@ export interface MembershipConfig { * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs * helps by keeping more delayed event reset candidates in flight, * improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration, - * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) + * but only applies to calls to the `_unstable_restartScheduledDelayedEvent` endpoint + * or the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; @@ -514,6 +515,9 @@ export class MatrixRTCSession extends TypedEventEmitter< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 533f9adfc4..f31b264772 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -16,11 +16,7 @@ limitations under the License. import { AbortError } from "p-retry"; import { EventType, RelationType } from "../@types/event.ts"; -import { - type ISendEventResponse, - type SendDelayedEventResponse, - UpdateDelayedEventAction, -} from "../@types/requests.ts"; +import { type ISendEventResponse, type SendDelayedEventResponse } from "../@types/requests.ts"; import { type EmptyObject } from "../@types/common.ts"; import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; @@ -169,7 +165,14 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number): type MembershipManagerClient = Pick< MatrixClient, - "getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "getUserId" + | "getDeviceId" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" + | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" >; /** @@ -544,7 +547,7 @@ export class MembershipManager private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise { // Remove all running updates and restarts return await this.client - ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel) + ._unstable_cancelScheduledDelayedEvent(delayId) .then(() => { this.state.delayId = undefined; this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent); @@ -552,7 +555,7 @@ export class MembershipManager }) .catch((e) => { const repeatActionType = MembershipActionType.SendDelayedEvent; - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "cancelScheduledDelayedEvent"); if (update) return update; if (this.isNotFoundError(e)) { @@ -606,10 +609,7 @@ export class MembershipManager // The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded // to the widget driver this information would get lost. That is why we mimic the AbortError using the race. - return await Promise.race([ - this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart), - abortPromise, - ]) + return await Promise.race([this.client._unstable_restartScheduledDelayedEvent(delayId), abortPromise]) .then(() => { // Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs` // which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further @@ -637,7 +637,7 @@ export class MembershipManager if (this.isUnsupportedDelayedEndpoint(e)) return {}; // TODO this also needs a test: get rate limit while checking id delayed event is scheduled - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "restartScheduledDelayedEvent"); if (update) return update; // In other error cases we have no idea what is happening @@ -647,7 +647,7 @@ export class MembershipManager private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise { return await this.client - ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send) + ._unstable_sendScheduledDelayedEvent(delayId) .then(() => { this.state.hasMemberStateEvent = false; this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent); @@ -661,7 +661,7 @@ export class MembershipManager this.state.delayId = undefined; return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "sendScheduledDelayedEvent"); if (update) return update; // On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting) diff --git a/yarn.lock b/yarn.lock index 732b54f28a..7799bdff2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5513,10 +5513,10 @@ matrix-mock-request@^2.5.0: dependencies: expect "^28.1.0" -matrix-widget-api@^1.10.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz#5b1caeed2fc58148bcd2984e0546d2d06a1713ad" - integrity sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w== +matrix-widget-api@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.14.0.tgz#aa90c40ace27d3165299f7dbc760a53001ce1446" + integrity sha512-DDvZGOQhI/rilPWg5VlLN7pHIsPt0Jt14lsuHDP+KU+fmpAQNITJ6aIld1ZoXWsrVGv2PS3x6K/MHtfruIOQJQ== dependencies: "@types/events" "^3.0.0" events "^3.2.0"