Allow the embedded client to work without update_state support (#4849)

* Allow the embedded client to work without UpdateState version

* Test that RoomWidgetClient can receive state without update_state

* add sliding sync test

* sliding sync receive test

* review

* add doc comment

---------

Co-authored-by: Timo <toger5@hotmail.de>
This commit is contained in:
Robin
2025-05-23 11:09:52 -04:00
committed by GitHub
parent 9398271695
commit bf6dc16ad3
2 changed files with 169 additions and 7 deletions
+115 -2
View File
@@ -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
+54 -5
View File
@@ -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