diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 5e35c02c96..c5ef3a6a2c 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -28,6 +28,7 @@ import { WidgetApiToWidgetAction, MatrixCapabilities, ITurnServer, + IRoomEvent, IOpenIDCredentials, ISendEventFromWidgetResponseData, WidgetApiResponseError, @@ -634,20 +635,12 @@ describe("RoomWidgetClient", () => { }); it("receives", async () => { - const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org"); expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar"); - // Client needs to be told that the room state is loaded - widgetApi.emit( - `action:${WidgetApiToWidgetAction.UpdateState}`, - new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }), - ); - await init; const emittedEvent = new Promise((resolve) => client.once(ClientEvent.Event, resolve)); const emittedSync = new Promise((resolve) => client.once(ClientEvent.Sync, resolve)); - // Let's assume that a state event comes in but it doesn't actually - // update the state of the room just yet (maybe it's unauthorized) widgetApi.emit( `action:${WidgetApiToWidgetAction.SendEvent}`, new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }), @@ -656,43 +649,26 @@ describe("RoomWidgetClient", () => { // The client should've emitted about the received event expect((await emittedEvent).getEffectiveEvent()).toEqual(event); expect(await emittedSync).toEqual(SyncState.Syncing); - // However it should not have changed the room state + // It should've also inserted the event into the room object const room = client.getRoom("!1:example.org"); - expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null); - - // Now assume that the state event becomes favored by state - // resolution for whatever reason and enters into the current state - // of the room - widgetApi.emit( - `action:${WidgetApiToWidgetAction.UpdateState}`, - new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { - detail: { data: { state: [event] } }, - }), - ); - // It should now have changed the room state + 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 - widgetApi.emit( - `action:${WidgetApiToWidgetAction.UpdateState}`, - new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }), + it("backfills", async () => { + widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) => + eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar" + ? [event as IRoomEvent] + : [], ); - await init; - // Now a room we're not interested in receives a state update - widgetApi.emit( - `action:${WidgetApiToWidgetAction.UpdateState}`, - new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { - detail: { data: { state: [{ ...event, room_id: "!other-room:example.org" }] } }, - }), - ); - // No change to the room state - for (const room of client.getRooms()) { - expect(room.currentState.getStateEvents("org.example.foo", "bar")).toBe(null); - } + 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); }); }); diff --git a/src/embedded.ts b/src/embedded.ts index 53154e40e6..b0cc4c158e 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -28,7 +28,6 @@ import { WidgetApiAction, IWidgetApiResponse, IWidgetApiResponseData, - IUpdateStateToWidgetActionRequest, } from "matrix-widget-api"; import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event.ts"; @@ -137,7 +136,6 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: () export class RoomWidgetClient extends MatrixClient { private room?: Room; private readonly widgetApiReady: Promise; - private readonly roomStateSynced: Promise; private lifecycle?: AbortController; private syncState: SyncState | null = null; @@ -191,11 +189,6 @@ export class RoomWidgetClient extends MatrixClient { }; this.widgetApiReady = new Promise((resolve) => this.widgetApi.once("ready", resolve)); - this.roomStateSynced = capabilities.receiveState?.length - ? new Promise((resolve) => - this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve), - ) - : Promise.resolve(); // Request capabilities for the functionality this client needs to support if ( @@ -248,7 +241,6 @@ export class RoomWidgetClient extends MatrixClient { widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent); widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); - widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate); // Open communication with the host widgetApi.start(); @@ -284,6 +276,28 @@ export class RoomWidgetClient extends MatrixClient { await this.widgetApiReady; + // 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)); + + if (this.syncApi instanceof SyncApi) { + // Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode + // -> state events in `timelineEventList` 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(); @@ -291,9 +305,8 @@ export class RoomWidgetClient extends MatrixClient { this.fetchClientWellKnown(); } - await this.roomStateSynced; this.setSyncState(SyncState.Syncing); - logger.info("Finished initial sync"); + logger.info("Finished backfilling events"); this.matrixRTC.start(); @@ -304,7 +317,6 @@ export class RoomWidgetClient extends MatrixClient { public stopClient(): void { this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent); this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice); - this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate); super.stopClient(); this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped @@ -562,15 +574,36 @@ export class RoomWidgetClient extends MatrixClient { // Only inject once we have update the txId await this.updateTxId(event); + // The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now. if (this.syncApi instanceof SyncApi) { - await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]); + // The code will want to be something like: + // ``` + // if (!params.addToTimeline && !params.addToState) { + // // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode" + // // -> state events part of the `timelineEventList` parameter will update the state. + // this.injectRoomEvents(this.room!, [], undefined, [event]); + // } else { + // this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []); + // } + // ``` + + // Passing undefined for `stateAfterEventList` allows 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]); + // The code will want to be something like: + // ``` + // if (!params.addToTimeline && !params.addToState) { + // this.injectRoomEvents(this.room!, [], [event]); + // } else { + // this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []); + // } + // ``` + await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync } this.emit(ClientEvent.Event, event); this.setSyncState(SyncState.Syncing); - logger.info(`Received event ${event.getId()} ${event.getType()}`); + logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); } else { const { event_id: eventId, room_id: roomId } = ev.detail.data; logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`); @@ -595,32 +628,6 @@ export class RoomWidgetClient extends MatrixClient { await this.ack(ev); }; - private onStateUpdate = async (ev: CustomEvent): Promise => { - ev.preventDefault(); - - 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 - // on screen - if (rawEvent.room_id === this.roomId) { - const event = new MatrixEvent(rawEvent as Partial); - - if (this.syncApi instanceof SyncApi) { - await this.syncApi.injectRoomEvents(this.room!, undefined, [event]); - } else { - // Sliding Sync - await this.syncApi!.injectRoomEvents(this.room!, [event]); - } - logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`); - } else { - const { event_id: eventId, room_id: roomId } = ev.detail.data; - logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`); - } - } - - await this.ack(ev); - }; - private async watchTurnServers(): Promise { const servers = this.widgetApi.getTurnServers(); const onClientStopped = (): void => { diff --git a/yarn.lock b/yarn.lock index 6df971ed32..c02d9f3744 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4875,9 +4875,9 @@ matrix-mock-request@^2.5.0: expect "^28.1.0" matrix-widget-api@^1.10.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz#b3d22bab1670051c8eeee66bb96d08b33148bc99" - integrity sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww== + version "1.10.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.10.0.tgz#d31ea073a5871a1fb1a511ef900b0c125a37bf55" + integrity sha512-rkAJ29briYV7TJnfBVLVSKtpeBrBju15JZFSDP6wj8YdbCu1bdmlplJayQ+vYaw1x4fzI49Q+Nz3E85s46sRDw== dependencies: "@types/events" "^3.0.0" events "^3.2.0"