From dbb2ae5c0752c28639502e93f26cb3003d0d0595 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 22 Jan 2026 17:07:13 +0100 Subject: [PATCH] Give RoomWidgetClient the ability to send and receive sticky events (#5142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Give RoomWidgetClient the ability to send and receive sticky events * linter * Fix existing tests * Add tests for sticky event support in embedded clients * Update sticky event widget capability identifiers In matrix-widget-api 0.16.1 they are updated to use the new unstable prefix from MSC4407. * Explicitly require matrix-widget-api ≥ 1.16.1 * remove TODO comment * simplify type lint checks This is needed for EW donwstream tests. Otherwise it will through: Error: matrix-js-sdk/src/embedded.ts(417,21): error TS2345: Argument of type 'string | number | boolean | string[]' is not assignable to parameter of type 'number'. --------- Co-authored-by: Timo K --- package.json | 2 +- spec/unit/embedded.spec.ts | 172 ++++++++++++++++++++++++++++++++++++- src/embedded.ts | 57 ++++++++++-- yarn.lock | 8 +- 4 files changed, 228 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a5a078305..186e79fe3 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.14.0", + "matrix-widget-api": "^1.16.1", "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 641e194bf..25e3a8069 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -32,7 +32,14 @@ import { type IRoomEvent, } from "matrix-widget-api"; -import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix"; +import { + createRoomWidgetClient, + EventType, + type IEvent, + MatrixError, + MsgType, + UpdateDelayedEventAction, +} from "../../src/matrix"; import { MatrixClient, ClientEvent, type ITurnServer as IClientTurnServer } from "../../src/client"; import { SyncState } from "../../src/sync"; import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded"; @@ -42,6 +49,7 @@ import { sleep } from "../../src/utils"; import { SlidingSync } from "../../src/sliding-sync"; import { logger } from "../../src/logger"; import { flushPromises } from "../test-utils/flushPromises"; +import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../../src/models/room-sticky-events"; const testOIDCToken = { access_token: "12345678", @@ -49,6 +57,7 @@ const testOIDCToken = { matrix_server_name: "homeserver.oabc", token_type: "Bearer", }; + class MockWidgetApi extends EventEmitter { public start = vi.fn().mockResolvedValue(undefined); public getClientVersions = vi.fn(); @@ -167,6 +176,9 @@ describe("RoomWidgetClient", () => { "org.matrix.rageshake_request", { request_id: 123 }, "!1:example.org", + undefined, + undefined, + undefined, ); }); @@ -422,6 +434,7 @@ describe("RoomWidgetClient", () => { "!1:example.org", 2000, undefined, + undefined, ); }); @@ -442,6 +455,7 @@ describe("RoomWidgetClient", () => { "!1:example.org", undefined, parentDelayId, + undefined, ); }); @@ -855,6 +869,162 @@ describe("RoomWidgetClient", () => { }); }); + describe("sticky events", () => { + describe("when supported", () => { + const doesServerSupportUnstableFeatureMock = vi.fn((feature) => + Promise.resolve(feature === "org.matrix.msc4354"), + ); + + beforeAll(() => { + MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock; + }); + + afterAll(() => { + doesServerSupportUnstableFeatureMock.mockReset(); + }); + + it("requests capabilities when set", async () => { + await makeClient({ sendSticky: true, receiveSticky: true }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407ReceiveStickyEvent); + }); + + it("does not request capabilities when unset", async () => { + await makeClient({}); + expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent); + expect(widgetApi.requestCapability).not.toHaveBeenCalledWith( + MatrixCapabilities.MSC4407ReceiveStickyEvent, + ); + }); + + it("sends", async () => { + await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true }); + expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); + expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith(EventType.RTCMembership); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent); + await client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, { + msc4354_sticky_key: "test", + }); + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + EventType.RTCMembership, + { msc4354_sticky_key: "test" }, + "!1:example.org", + undefined, + undefined, + 2000, + ); + }); + + it("receives (adds, updates, then removes when redacted)", async () => { + await makeClient({ receiveEvent: [EventType.RTCMembership, EventType.RoomRedaction] }); + const room = client.getRoom("!1:example.org")!; + + function expectStickyEvents(events: IEvent[]) { + expect([...room._unstable_getStickyEvents()].map((e) => e.getEffectiveEvent())).toEqual(events); + } + + async function sendAndExpectStickyUpdate( + eventToSend: IEvent, + added: IEvent[], + updated: { current: IEvent; previous: IEvent }[], + removed: IEvent[], + ) { + const emittedStickyUpdate = new Promise< + Parameters + >((resolve) => room.once(RoomStickyEventsEvent.Update, (...args) => resolve(args))); + + widgetApi.emit( + `action:${WidgetApiToWidgetAction.SendEvent}`, + new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { + detail: { data: eventToSend }, + }), + ); + + const [addedReceived, updatedReceived, removedReceived] = await emittedStickyUpdate; + expect(addedReceived.map((e) => e.getEffectiveEvent())).toEqual(added); + expect( + updatedReceived.map(({ current, previous }) => ({ + current: current.getEffectiveEvent(), + previous: previous.getEffectiveEvent(), + })), + ).toEqual(updated); + expect(removedReceived.map((e) => e.getEffectiveEvent())).toEqual(removed); + } + + // First, add a new sticky event to the map. The client should emit. + const event1 = new MatrixEvent({ + type: EventType.RTCMembership, + event_id: "$pduhfiidph", + room_id: "!1:example.org", + sender: "@alice:example.org", + msc4354_sticky: { duration_ms: 1200000 }, + content: { msc4354_sticky_key: "test" }, + }).getEffectiveEvent(); + await sendAndExpectStickyUpdate(event1, [event1], [], []); + // It should remain cached in the sticky map + expectStickyEvents([event1]); + + // Next, update the same key in the sticky map + const event2 = new MatrixEvent({ + type: EventType.RTCMembership, + event_id: "$zshgyutptfh", + room_id: "!1:example.org", + sender: "@alice:example.org", + msc4354_sticky: { duration_ms: 1200000 }, + content: { msc4354_sticky_key: "test" }, + }).getEffectiveEvent(); + await sendAndExpectStickyUpdate(event2, [], [{ current: event2, previous: event1 }], []); + expectStickyEvents([event2]); + + // Next, redact the second event. Because it has the first as a predecessor, the map should revert to + // the first event. + const redaction1 = new MatrixEvent({ + type: EventType.RoomRedaction, + event_id: "$cimoexnvz", + room_id: "!1:example.org", + sender: "@alice:example.org", + redacts: event2.event_id, + content: { redacts: event2.event_id }, + }).getEffectiveEvent(); + await sendAndExpectStickyUpdate(redaction1, [], [{ current: event1, previous: event2 }], []); + expectStickyEvents([event1]); + + // Finally, redact the first event. Now everything should be gone from the map. + const redaction2 = new MatrixEvent({ + type: EventType.RoomRedaction, + event_id: "$drgzmenlh", + room_id: "!1:example.org", + sender: "@alice:example.org", + redacts: event1.event_id, + content: { redacts: event1.event_id }, + }).getEffectiveEvent(); + await sendAndExpectStickyUpdate(redaction2, [], [], [event1]); + expectStickyEvents([]); + }); + }); + + describe("when unsupported", () => { + const doesServerSupportUnstableFeatureMock = vi.fn().mockResolvedValue(false); + + beforeAll(() => { + MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock; + }); + + afterAll(() => { + doesServerSupportUnstableFeatureMock.mockReset(); + }); + + it("fails to send", async () => { + await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true }); + await expect( + client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, { + msc4354_sticky_key: "test", + }), + ).rejects.toThrow("Server does not support"); + }); + }); + }); + describe("to-device messages", () => { const unencryptedContentMap = new Map([ ["@alice:example.org", new Map([["*", { hello: "alice!" }]])], diff --git a/src/embedded.ts b/src/embedded.ts index 6b1fec2b1..09b34bd0c 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -38,6 +38,7 @@ import { type SendDelayedEventRequestOpts, type SendDelayedEventResponse, UpdateDelayedEventAction, + isSendDelayedEventRequestOpts, } from "./@types/requests.ts"; import { EventType, type StateEvents } from "./@types/event.ts"; import { logger } from "./logger.ts"; @@ -56,7 +57,7 @@ import { ConnectionError, MatrixError } from "./http-api/errors.ts"; import { User } from "./models/user.ts"; import { type Room } from "./models/room.ts"; import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts"; -import { MapWithDefault, recursiveMapToObject } from "./utils.ts"; +import { MapWithDefault, type QueryDict, recursiveMapToObject } from "./utils.ts"; import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts"; interface IStateEventRequest { @@ -122,6 +123,20 @@ export interface ICapabilities { * @defaultValue false */ updateDelayedEvents?: boolean; + + /** + * Whether this client needs to be able to send sticky events. + * @experimental Part of MSC4354 & MSC4407 + * @defaultValue false + */ + sendSticky?: boolean; + + /** + * Whether this client needs to be able to receive sticky events. + * @experimental Part of MSC4354 & MSC4407 + * @defaultValue false + */ + receiveSticky?: boolean; } export enum RoomWidgetClientEvent { @@ -242,6 +257,12 @@ export class RoomWidgetClient extends MatrixClient { if (capabilities.updateDelayedEvents) { widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent); } + if (capabilities.sendSticky) { + widgetApi.requestCapability(MatrixCapabilities.MSC4407SendStickyEvent); + } + if (capabilities.receiveSticky) { + widgetApi.requestCapability(MatrixCapabilities.MSC4407ReceiveStickyEvent); + } if (capabilities.turnServers) { widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers); } @@ -346,17 +367,41 @@ export class RoomWidgetClient extends MatrixClient { throw new Error(`Unknown room: ${roomIdOrAlias}`); } - protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise; + protected async encryptAndSendEvent( + room: Room, + event: MatrixEvent, + queryDict?: QueryDict, + ): Promise; protected async encryptAndSendEvent( room: Room, event: MatrixEvent, delayOpts: SendDelayedEventRequestOpts, - ): Promise; + queryDict?: QueryDict, + ): Promise; protected async encryptAndSendEvent( room: Room, event: MatrixEvent, - delayOpts?: SendDelayedEventRequestOpts, + delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict, + queryDict?: QueryDict, ): Promise { + let queryOpts = queryDict; + let delayOpts: SendDelayedEventRequestOpts | undefined; + if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) { + delayOpts = delayOptsOrQuery; + } else if (!queryOpts) { + queryOpts = delayOptsOrQuery; + } + + const stickyDurationMs = queryOpts?.["org.matrix.msc4354.sticky_duration_ms"]; + if (stickyDurationMs !== undefined && typeof stickyDurationMs !== "number") { + throw new Error("Sticky duration must be a number when defined"); + } + // This is save since we just checked that above + // We need the additional as assertion for the EW linter to be happy. + // It is not capable of implying the type based on the throw if `stickyDurationMs !== undefined && typeof stickyDurationMs !== "number"` + // above + const stickyDurationMsAsNumber: number | undefined = stickyDurationMs as number | undefined; + // We need to extend the content with the redacts parameter // The js sdk uses event.redacts but the widget api uses event.content.redacts // This will be converted back to event.redacts in the widget driver. @@ -374,6 +419,7 @@ export class RoomWidgetClient extends MatrixClient { room.roomId, "delay" in delayOpts ? delayOpts.delay : undefined, "parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined, + stickyDurationMsAsNumber, ) .catch(timeoutToConnectionError); return this.validateSendDelayedEventResponse(response); @@ -386,7 +432,7 @@ export class RoomWidgetClient extends MatrixClient { let response: ISendEventFromWidgetResponseData; try { response = await this.widgetApi - .sendRoomEvent(event.getType(), content, room.roomId) + .sendRoomEvent(event.getType(), content, room.roomId, undefined, undefined, stickyDurationMsAsNumber) .catch(timeoutToConnectionError); } catch (e) { this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT); @@ -704,6 +750,7 @@ export class RoomWidgetClient extends MatrixClient { } this.emit(ClientEvent.Event, event); + if (event.unstableStickyInfo !== undefined) this.room!._unstable_addStickyEvents([event]); this.setSyncState(SyncState.Syncing); logger.info(`Received event ${event.getId()} ${event.getType()}`); } else { diff --git a/yarn.lock b/yarn.lock index 2922ca4fc..707adde21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4416,10 +4416,10 @@ matrix-mock-request@^2.5.0: dependencies: expect "^28.1.0" -matrix-widget-api@^1.14.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.16.0.tgz#e232f1ed6b840feea58d693d877fb8a05b181aee" - integrity sha512-OCsCzEN54jWamvWkBa7PqcKdlOhLA+nJbUyqsATHvzb4/NMcjdUZWSDurZxyNE5eYlNwxClA6Hw20mzJEKJbvg== +matrix-widget-api@^1.16.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz#a447f28f0af07e1bdc960881971de7d1ec9e6464" + integrity sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw== dependencies: "@types/events" "^3.0.0" events "^3.2.0"