From b8903ddf3e66bc9e3415f3e841a054568b51829b Mon Sep 17 00:00:00 2001
From: Robin <robin@robin.town>
Date: Tue, 20 May 2025 12:03:24 -0400
Subject: [PATCH] Reapply "Distinguish room state and timeline events in
 embedded clients" (#4790)

This reverts commit fd9a44e701532aaa969dff131bd85707dd5bdda3.

We are ready to reintroduce support for the `update_state` widget action (https://github.com/matrix-org/matrix-spec-proposals/pull/4237) now that matrix-rust-sdk is about to gain support for it as well.
---
 spec/unit/embedded.spec.ts | 56 +++++++++++++++++-------
 src/embedded.ts            | 89 ++++++++++++++++++--------------------
 2 files changed, 81 insertions(+), 64 deletions(-)

diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts
index f23fd75e9..6da1b7c6e 100644
--- a/spec/unit/embedded.spec.ts
+++ b/spec/unit/embedded.spec.ts
@@ -28,7 +28,6 @@ import {
     WidgetApiToWidgetAction,
     MatrixCapabilities,
     type ITurnServer,
-    type IRoomEvent,
     type IOpenIDCredentials,
     type ISendEventFromWidgetResponseData,
     WidgetApiResponseError,
@@ -634,12 +633,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<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
             const emittedSync = new Promise<SyncState>((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 } }),
@@ -648,26 +655,43 @@ 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")).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!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
         });
 
-        it("backfills", async () => {
-            widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
-                eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
-                    ? [event as IRoomEvent]
-                    : [],
+        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: [] } } }),
             );
+            await init;
 
-            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);
+            // 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);
+            }
         });
     });
 
diff --git a/src/embedded.ts b/src/embedded.ts
index f96043795..092ff3747 100644
--- a/src/embedded.ts
+++ b/src/embedded.ts
@@ -28,6 +28,7 @@ import {
     type WidgetApiAction,
     type IWidgetApiResponse,
     type IWidgetApiResponseData,
+    type IUpdateStateToWidgetActionRequest,
 } from "matrix-widget-api";
 
 import { MatrixEvent, type IEvent, type IContent, EventStatus } from "./models/event.ts";
@@ -135,6 +136,7 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
 export class RoomWidgetClient extends MatrixClient {
     private room?: Room;
     private readonly widgetApiReady: Promise<void>;
+    private readonly roomStateSynced: Promise<void>;
     private lifecycle?: AbortController;
     private syncState: SyncState | null = null;
 
@@ -188,6 +190,11 @@ export class RoomWidgetClient extends MatrixClient {
         };
 
         this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
+        this.roomStateSynced = capabilities.receiveState?.length
+            ? new Promise<void>((resolve) =>
+                  this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),
+              )
+            : Promise.resolve();
 
         // Request capabilities for the functionality this client needs to support
         if (
@@ -240,6 +247,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();
@@ -275,28 +283,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<IEvent>));
-
-                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();
@@ -304,8 +290,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();
 
@@ -316,6 +303,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
@@ -600,36 +588,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]);
+                await 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
+                await 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`);
@@ -654,6 +621,32 @@ export class RoomWidgetClient extends MatrixClient {
         await this.ack(ev);
     };
 
+    private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
+        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<IEvent>);
+
+                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<void> {
         const servers = this.widgetApi.getTurnServers();
         const onClientStopped = (): void => {
-- 
GitLab