diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index c5ef3a6a2c6..11b6123ba3b 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -28,7 +28,6 @@ import { WidgetApiToWidgetAction, MatrixCapabilities, ITurnServer, - IRoomEvent, IOpenIDCredentials, ISendEventFromWidgetResponseData, WidgetApiResponseError, @@ -635,12 +634,20 @@ describe("RoomWidgetClient", () => { }); it("receives", async () => { - await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] }); + const init = 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 } }), @@ -649,25 +656,20 @@ describe("RoomWidgetClient", () => { // 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 + // However it should not have changed the room state const room = client.getRoom("!1:example.org"); - expect(room).not.toBeNull(); - expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); - }); + expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null); - it("backfills", async () => { - widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) => - eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar" - ? [event as IRoomEvent] - : [], + // 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] } }, + }), ); - - 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(); + // It should now have changed the room state expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event); }); }); diff --git a/src/embedded.ts b/src/embedded.ts index b0cc4c158e8..ee4c017ccd7 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -28,6 +28,7 @@ import { WidgetApiAction, IWidgetApiResponse, IWidgetApiResponseData, + IUpdateStateToWidgetActionRequest, } from "matrix-widget-api"; import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event.ts"; @@ -136,6 +137,7 @@ 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; @@ -189,6 +191,11 @@ 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 ( @@ -241,6 +248,7 @@ 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(); @@ -276,28 +284,6 @@ 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(); @@ -305,8 +291,9 @@ export class RoomWidgetClient extends MatrixClient { this.fetchClientWellKnown(); } + await this.roomStateSynced; this.setSyncState(SyncState.Syncing); - logger.info("Finished backfilling events"); + logger.info("Finished initial sync"); this.matrixRTC.start(); @@ -317,6 +304,7 @@ 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 @@ -574,36 +562,15 @@ 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) { - // 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]); + this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]); } else { - // 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 + // Sliding Sync + this.syncApi!.injectRoomEvents(this.room!, [], [event]); } this.emit(ClientEvent.Event, event); this.setSyncState(SyncState.Syncing); - logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); + logger.info(`Received event ${event.getId()} ${event.getType()}`); } else { const { event_id: eventId, room_id: roomId } = ev.detail.data; logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`); @@ -628,6 +595,32 @@ 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) { + this.syncApi.injectRoomEvents(this.room!, undefined, [event]); + } else { + // Sliding Sync + 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 => {