diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 6da1b7c6e8dbcd375b126db3aa25a0a33062d77e..fac2c9958eb8595cb57d3860e59a8644f2f0b792 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -31,6 +31,9 @@ import { type IOpenIDCredentials, type ISendEventFromWidgetResponseData, WidgetApiResponseError, + UnstableApiVersion, + type ApiVersion, + type IRoomEvent, } from "matrix-widget-api"; import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix"; @@ -40,6 +43,9 @@ import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded"; import { MatrixEvent } from "../../src/models/event"; import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage"; import { sleep } from "../../src/utils"; +import { SlidingSync } from "../../src/sliding-sync"; +import { logger } from "../../src/logger"; +import { flushPromises } from "../test-utils/flushPromises"; const testOIDCToken = { access_token: "12345678", @@ -49,6 +55,7 @@ const testOIDCToken = { }; class MockWidgetApi extends EventEmitter { public start = jest.fn().mockResolvedValue(undefined); + public getClientVersions = jest.fn(); public requestCapability = jest.fn().mockResolvedValue(undefined); public requestCapabilities = jest.fn().mockResolvedValue(undefined); public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined); @@ -96,6 +103,15 @@ class MockWidgetApi extends EventEmitter { send: jest.fn(), sendComplete: jest.fn(), }; + + /** + * This mocks the widget's view of what is supported by its environment. + * @param clientVersions The versions that the widget believes are supported by the host client's widget driver. + */ + public constructor(clientVersions: ApiVersion[]) { + super(); + this.getClientVersions.mockResolvedValue(clientVersions); + } } declare module "../../src/types" { @@ -117,7 +133,7 @@ describe("RoomWidgetClient", () => { let client: MatrixClient; beforeEach(() => { - widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>; + widgetApi = new MockWidgetApi([UnstableApiVersion.MSC2762_UPDATE_STATE]) as unknown as MockedObject<WidgetApi>; }); afterEach(() => { @@ -128,6 +144,7 @@ describe("RoomWidgetClient", () => { capabilities: ICapabilities, sendContentLoaded: boolean | undefined = undefined, userId?: string, + useSlidingSync?: boolean, ): Promise<void> => { const baseUrl = "https://example.org"; client = createRoomWidgetClient( @@ -139,7 +156,7 @@ describe("RoomWidgetClient", () => { ); expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages widgetApi.emit("ready"); - await client.startClient(); + await client.startClient(useSlidingSync ? { slidingSync: new SlidingSync("", new Map(), {}, client, 0) } : {}); }; describe("events", () => { @@ -668,10 +685,106 @@ describe("RoomWidgetClient", () => { detail: { data: { state: [event] } }, }), ); + // Allow the getClientVersions promise to resolve + await flushPromises(); // It should now have changed the room state expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); }); + describe("without support for update_state", () => { + beforeEach(() => { + widgetApi = new MockWidgetApi([]) as unknown as MockedObject<WidgetApi>; + }); + + it("receives", async () => { + await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); + expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); + + const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve)); + const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve)); + widgetApi.emit( + `action:${WidgetApiToWidgetAction.SendEvent}`, + new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }), + ); + + // The client should've emitted about the received event + expect((await emittedEvent).getEffectiveEvent()).toEqual(event); + expect(await emittedSync).toEqual(SyncState.Syncing); + // It should've also inserted the event into the room object + const room = client.getRoom("!1:example.org"); + expect(room).not.toBeNull(); + expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); + }); + + it("does not receive with sliding sync (update_state is needed for sliding sync)", async () => { + await makeClient( + { receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }, + undefined, + undefined, + true, + ); + expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); + expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); + + const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve)); + const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve)); + const logSpy = jest.spyOn(logger, "error"); + widgetApi.emit( + `action:${WidgetApiToWidgetAction.SendEvent}`, + new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }), + ); + + // The client should've emitted about the received event + expect((await emittedEvent).getEffectiveEvent()).toEqual(event); + expect(await emittedSync).toEqual(SyncState.Syncing); + + // The incompatibility of sliding sync without update_state to get logged. + expect(logSpy).toHaveBeenCalledWith( + "slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'", + ); + // It should not have inserted the event into the room object + const room = client.getRoom("!1:example.org"); + expect(room).not.toBeNull(); + expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toEqual(null); + }); + + it("backfills", async () => { + widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) => + eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar" + ? [event as IRoomEvent] + : [], + ); + + await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); + expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); + + const room = client.getRoom("!1:example.org"); + expect(room).not.toBeNull(); + expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); + }); + it("backfills with sliding sync", async () => { + widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) => + eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar" + ? [event as IRoomEvent] + : [], + ); + await makeClient( + { receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }, + undefined, + undefined, + true, + ); + expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); + expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); + + const room = client.getRoom("!1:example.org"); + expect(room).not.toBeNull(); + expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); + }); + }); + it("ignores state updates for other rooms", async () => { const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); // Client needs to be told that the room state is loaded diff --git a/src/embedded.ts b/src/embedded.ts index 092ff3747c27bd004282ed69b9c9cd8dc4c8fe44..7bc18483c3dba69179c2c16d0a240bfc827bd4b6 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -29,6 +29,7 @@ import { type IWidgetApiResponse, type IWidgetApiResponseData, type IUpdateStateToWidgetActionRequest, + UnstableApiVersion, } from "matrix-widget-api"; import { MatrixEvent, type IEvent, type IContent, EventStatus } from "./models/event.ts"; @@ -259,6 +260,10 @@ export class RoomWidgetClient extends MatrixClient { if (sendContentLoaded) widgetApi.sendContentLoaded(); } + public async supportUpdateState(): Promise<boolean> { + return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE); + } + public async startClient(opts: IStartClientOpts = {}): Promise<void> { this.lifecycle = new AbortController(); @@ -283,14 +288,41 @@ export class RoomWidgetClient extends MatrixClient { await this.widgetApiReady; + // sync room state: + if (await this.supportUpdateState()) { + // This will resolve once the client driver has sent us all the allowed room state. + await this.roomStateSynced; + } else { + // Backfill the requested events + // We only get the most recent event for every type + state key combo, + // so it doesn't really matter what order we inject them in + await Promise.all( + this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => { + const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [ + this.roomId, + ]); + const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>)); + + if (this.syncApi instanceof SyncApi) { + // Passing events as `stateAfterEventList` will update the state. + await this.syncApi.injectRoomEvents(this.room!, undefined, events); + } else { + await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync + } + events.forEach((event) => { + this.emit(ClientEvent.Event, event); + logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); + }); + }) ?? [], + ); + } + if (opts.clientWellKnownPollPeriod !== undefined) { this.clientWellKnownIntervalID = setInterval(() => { this.fetchClientWellKnown(); }, 1000 * opts.clientWellKnownPollPeriod); this.fetchClientWellKnown(); } - - await this.roomStateSynced; this.setSyncState(SyncState.Syncing); logger.info("Finished initial sync"); @@ -589,11 +621,24 @@ export class RoomWidgetClient extends MatrixClient { await this.updateTxId(event); if (this.syncApi instanceof SyncApi) { - await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]); + if (await this.supportUpdateState()) { + await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]); + } else { + // Passing undefined for `stateAfterEventList` will make `injectRoomEvents` run in legacy mode + // -> state events in `timelineEventList` will update the state. + await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]); + } } else { // Sliding Sync - await this.syncApi!.injectRoomEvents(this.room!, [], [event]); + if (await this.supportUpdateState()) { + await this.syncApi!.injectRoomEvents(this.room!, [], [event]); + } else { + logger.error( + "slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'", + ); + } } + this.emit(ClientEvent.Event, event); this.setSyncState(SyncState.Syncing); logger.info(`Received event ${event.getId()} ${event.getType()}`); @@ -623,7 +668,11 @@ export class RoomWidgetClient extends MatrixClient { private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => { ev.preventDefault(); - + if (!(await this.supportUpdateState())) { + logger.warn( + "received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'", + ); + } for (const rawEvent of ev.detail.data.state) { // Verify the room ID matches, since it's possible for the client to // send us state updates from other rooms if this widget is always