From f5d96a5ad42cf0947030741109ebfdad00cc9c3a Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Dec 2024 16:04:05 -0500 Subject: [PATCH 1/6] Distinguish room state and timeline events when dealing with widgets --- src/stores/widgets/StopGapWidget.ts | 57 ++++++++++---- src/stores/widgets/StopGapWidgetDriver.ts | 96 +++++++++-------------- 2 files changed, 80 insertions(+), 73 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 0472b1664b1..75dd158566a 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -6,7 +6,14 @@ * Please see LICENSE files in the repository root for full details. */ -import { Room, MatrixEvent, MatrixEventEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix"; +import { + Room, + MatrixEvent, + MatrixEventEvent, + MatrixClient, + ClientEvent, + RoomStateEvent, +} from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { ClientWidgetApi, @@ -26,7 +33,6 @@ import { WidgetApiFromWidgetAction, WidgetKind, } from "matrix-widget-api"; -import { Optional } from "matrix-events-sdk"; import { EventEmitter } from "events"; import { logger } from "matrix-js-sdk/src/logger"; @@ -56,6 +62,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { UPDATE_EVENT } from "../AsyncStore"; // TODO: Destroy all of this code @@ -151,6 +158,7 @@ export class StopGapWidget extends EventEmitter { private mockWidget: ElementWidget; private scalarToken?: string; private roomId?: string; + private viewedRoomId: string | null = null; private kind: WidgetKind; private readonly virtual: boolean; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID @@ -177,17 +185,6 @@ export class StopGapWidget extends EventEmitter { this.stickyPromise = appTileProps.stickyPromise; } - private get eventListenerRoomId(): Optional { - // When widgets are listening to events, we need to make sure they're only - // receiving events for the right room. In particular, room widgets get locked - // to the room they were added in while account widgets listen to the currently - // active room. - - if (this.roomId) return this.roomId; - - return SdkContextClass.instance.roomViewStore.getRoomId(); - } - public get widgetApi(): ClientWidgetApi | null { return this.messaging; } @@ -259,6 +256,15 @@ export class StopGapWidget extends EventEmitter { }); } }; + + private onRoomViewStoreUpdate = (): void => { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null; + if (roomId !== this.viewedRoomId) { + this.messaging!.setViewedRoomId(roomId); + this.viewedRoomId = roomId; + } + }; + /** * This starts the messaging for the widget if it is not in the state `started` yet. * @param iframe the iframe the widget should use @@ -285,6 +291,17 @@ export class StopGapWidget extends EventEmitter { this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); + // When widgets are listening to events, we need to make sure they're only + // receiving events for the right room + if (this.roomId === undefined) { + // Account widgets listen to the currently active room + this.messaging.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + } else { + // Room widgets get looked to the room they were added in + this.messaging.setViewedRoomId(this.roomId); + } + // Always attach a handler for ViewRoom, but permission check it internally this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { ev.preventDefault(); // stop the widget API from auto-rejecting this @@ -329,6 +346,7 @@ export class StopGapWidget extends EventEmitter { // Attach listeners for feeding events - the underlying widget classes handle permissions for us this.client.on(ClientEvent.Event, this.onEvent); this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(RoomStateEvent.Events, this.onStateUpdate); this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging.on( @@ -457,8 +475,11 @@ export class StopGapWidget extends EventEmitter { WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); this.messaging = null; + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.client.off(ClientEvent.Event, this.onEvent); this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.off(RoomStateEvent.Events, this.onStateUpdate); this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } @@ -471,6 +492,14 @@ export class StopGapWidget extends EventEmitter { this.feedEvent(ev); }; + private onStateUpdate = (ev: MatrixEvent): void => { + if (this.messaging === null) return; + const raw = ev.getEffectiveEvent(); + this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => { + logger.error("Error sending state update to widget: ", e); + }); + }; + private onToDeviceEvent = async (ev: MatrixEvent): Promise => { await this.client.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; @@ -570,7 +599,7 @@ export class StopGapWidget extends EventEmitter { this.eventsToFeed.add(ev); } else { const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => { + this.messaging.feedEvent(raw as IRoomEvent).catch((e) => { logger.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index de7a71fa800..e08a207c244 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -19,7 +19,6 @@ import { MatrixCapabilities, OpenIDRequestState, SimpleObservable, - Symbols, Widget, WidgetDriver, WidgetEventCapability, @@ -36,7 +35,6 @@ import { IContent, MatrixError, MatrixEvent, - Room, Direction, THREAD_RELATION_TYPE, SendDelayedEventResponse, @@ -469,70 +467,44 @@ export class StopGapWidgetDriver extends WidgetDriver { } } - private pickRooms(roomIds?: (string | Symbols.AnyRoom)[]): Room[] { - const client = MatrixClientPeg.get(); - if (!client) throw new Error("Not attached to a client"); - - const targetRooms = roomIds - ? roomIds.includes(Symbols.AnyRoom) - ? client.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors")) - : roomIds.map((r) => client.getRoom(r)) - : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()!)]; - return targetRooms.filter((r) => !!r) as Room[]; - } - - public async readRoomEvents( + public async readRoomTimeline( + roomId: string, eventType: string, msgtype: string | undefined, - limitPerRoom: number, - roomIds?: (string | Symbols.AnyRoom)[], + stateKey: string | undefined, + limit: number, + since: string | undefined, ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - - const rooms = this.pickRooms(roomIds); - const allResults: IRoomEvent[] = []; - for (const room of rooms) { - const results: MatrixEvent[] = []; - const events = room.getLiveTimeline().getEvents(); // timelines are most recent last - for (let i = events.length - 1; i > 0; i--) { - if (results.length >= limitPerRoom) break; - - const ev = events[i]; - if (ev.getType() !== eventType || ev.isState()) continue; - if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue; - results.push(ev); - } - - results.forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent)); + limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (room === null) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i > 0; i--) { + const ev = events[i]; + if (results.length >= limit) break; + if (since !== undefined && ev.getId() === since) break; + + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue; + if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) continue; + results.push(ev); } - return allResults; + + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); } - public async readStateEvents( - eventType: string, - stateKey: string | undefined, - limitPerRoom: number, - roomIds?: (string | Symbols.AnyRoom)[], - ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - - const rooms = this.pickRooms(roomIds); - const allResults: IRoomEvent[] = []; - for (const room of rooms) { - const results: MatrixEvent[] = []; - const state = room.currentState.events.get(eventType); - if (state) { - if (stateKey === "" || !!stateKey) { - const forKey = state.get(stateKey); - if (forKey) results.push(forKey); - } else { - results.push(...Array.from(state.values())); - } - } + public async readRoomState(roomId: string, eventType: string, stateKey: string | undefined): Promise { + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (room === null) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) return []; - results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent)); - } - return allResults; + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; } public async askOpenID(observer: SimpleObservable): Promise { @@ -693,6 +665,12 @@ export class StopGapWidgetDriver extends WidgetDriver { return { file: blob }; } + public getKnownRooms(): string[] { + return MatrixClientPeg.safeGet() + .getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors")) + .map((r) => r.roomId); + } + /** * Expresses a {@link MatrixError} as a JSON payload * for use by Widget API error responses. From 07c70536f03e84b3e87f6c0de4b19e2c17d4492a Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jan 2025 13:08:18 -0500 Subject: [PATCH 2/6] Upgrade matrix-widget-api --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cbe07be2a19..10a410fc482 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8643,9 +8643,9 @@ matrix-web-i18n@^3.2.1: walk "^2.3.15" matrix-widget-api@^1.10.0: - 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== + 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== dependencies: "@types/events" "^3.0.0" events "^3.2.0" From de3d553ce4779dceb36fcfeaf7fbfd1545098d00 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jan 2025 13:09:07 -0500 Subject: [PATCH 3/6] Fix typo --- src/stores/widgets/StopGapWidget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 97482cfa7b2..c5cc1fb5e5d 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -298,7 +298,7 @@ export class StopGapWidget extends EventEmitter { this.messaging.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); } else { - // Room widgets get looked to the room they were added in + // Room widgets get locked to the room they were added in this.messaging.setViewedRoomId(this.roomId); } From eab2b9b8f4cd24b998720b11a1d26420b45cbe7e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jan 2025 15:15:52 -0500 Subject: [PATCH 4/6] Fix tests --- .../stores/widgets/StopGapWidget-test.ts | 22 +++++++++---------- .../widgets/StopGapWidgetDriver-test.ts | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index f767c96a028..493ac3b8a40 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -118,24 +118,24 @@ describe("StopGapWidget", () => { it("feeds incoming event to the widget", async () => { client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event2); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); }); it("should not feed incoming event to the widget if seen already", async () => { client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event2); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); client.emit(ClientEvent.Event, event1); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); }); it("feeds decrypted events asynchronously", async () => { @@ -165,7 +165,7 @@ describe("StopGapWidget", () => { decryptingSpy2.mockReturnValue(false); client.emit(MatrixEventEvent.Decrypted, event2Encrypted); expect(messaging.feedEvent).toHaveBeenCalledTimes(1); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent()); // …then event 1 event1Encrypted.event.type = event1.getType(); event1Encrypted.event.content = event1.getContent(); @@ -175,7 +175,7 @@ describe("StopGapWidget", () => { // doesn't have to be blocked on the decryption of event 1 (or // worse, dropped) expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent()); }); it("should not feed incoming event if not in timeline", () => { @@ -191,7 +191,7 @@ describe("StopGapWidget", () => { }); client.emit(ClientEvent.Event, event); - expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent()); }); it("feeds incoming event that is not in timeline but relates to unknown parent to the widget", async () => { @@ -211,15 +211,15 @@ describe("StopGapWidget", () => { }); client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); client.emit(ClientEvent.Event, event1); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); }); }); }); diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index e484d0cc33f..e82743e8dd8 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -569,7 +569,7 @@ describe("StopGapWidgetDriver", () => { it("passes the flag through to getVisibleRooms", () => { const driver = mkDefaultDriver(); - driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]); + driver.getKnownRooms(); expect(client.getVisibleRooms).toHaveBeenCalledWith(false); }); }); @@ -584,7 +584,7 @@ describe("StopGapWidgetDriver", () => { it("passes the flag through to getVisibleRooms", () => { const driver = mkDefaultDriver(); - driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]); + driver.getKnownRooms(); expect(client.getVisibleRooms).toHaveBeenCalledWith(true); }); }); From dfabea686dd53dd6c981d8408b2e6fa3caefc43f Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jan 2025 16:59:18 -0500 Subject: [PATCH 5/6] Write more tests --- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- .../stores/widgets/StopGapWidget-test.ts | 69 +++++++++++- .../widgets/StopGapWidgetDriver-test.ts | 106 +++++++++++++++++- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index b99b69c40e0..bab22127aaa 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -481,7 +481,7 @@ export class StopGapWidgetDriver extends WidgetDriver { if (room === null) return []; const results: MatrixEvent[] = []; const events = room.getLiveTimeline().getEvents(); // timelines are most recent last - for (let i = events.length - 1; i > 0; i--) { + for (let i = events.length - 1; i >= 0; i--) { const ev = events[i]; if (results.length >= limit) break; if (since !== undefined && ev.getId() === since) break; diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index 493ac3b8a40..61e96886b90 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { mocked, MockedObject } from "jest-mock"; +import { mocked, MockedFunction, MockedObject } from "jest-mock"; import { last } from "lodash"; import { MatrixEvent, @@ -15,15 +15,20 @@ import { EventTimeline, EventType, MatrixEventEvent, + RoomStateEvent, + RoomState, } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api"; import { waitFor } from "jest-matrix-react"; +import { Optional } from "matrix-events-sdk"; import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; +import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; jest.mock("matrix-widget-api/lib/ClientWidgetApi"); @@ -53,6 +58,7 @@ describe("StopGapWidget", () => { // Start messaging without an iframe, since ClientWidgetApi is mocked widget.startMessaging(null as unknown as HTMLIFrameElement); messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + messaging.feedStateUpdate.mockResolvedValue(); }); afterEach(() => { @@ -84,6 +90,20 @@ describe("StopGapWidget", () => { expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false); }); + it("feeds incoming state updates to the widget", () => { + const event = mkEvent({ + event: true, + type: "org.example.foo", + skey: "", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + + client.emit(RoomStateEvent.Events, event, {} as unknown as RoomState, null); + expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent()); + }); + describe("feed event", () => { let event1: MatrixEvent; let event2: MatrixEvent; @@ -223,6 +243,7 @@ describe("StopGapWidget", () => { }); }); }); + describe("StopGapWidget with stickyPromise", () => { let client: MockedObject; let widget: StopGapWidget; @@ -288,3 +309,49 @@ describe("StopGapWidget with stickyPromise", () => { waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); }); }); + +describe("StopGapWidget as an account widget", () => { + let widget: StopGapWidget; + let messaging: MockedObject; + let getRoomId: MockedFunction<() => Optional>; + + beforeEach(() => { + stubClient(); + // I give up, getting the return type of spyOn right is hopeless + getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction< + () => Optional + >; + getRoomId.mockReturnValue("!1:example.org"); + + widget = new StopGapWidget({ + app: { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", + roomId: "!1:example.org", + }, + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.startMessaging(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + }); + + afterEach(() => { + widget.stopMessaging(); + getRoomId.mockRestore(); + }); + + it("updates viewed room", () => { + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(1); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!1:example.org"); + getRoomId.mockReturnValue("!2:example.org"); + SdkContextClass.instance.roomViewStore.emit(UPDATE_EVENT); + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(2); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!2:example.org"); + }); +}); diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index e82743e8dd8..ccf2638d506 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -17,6 +17,7 @@ import { MatrixEvent, MsgType, RelationType, + Room, } from "matrix-js-sdk/src/matrix"; import { Widget, @@ -38,7 +39,7 @@ import { import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; -import { stubClient } from "../../../test-utils"; +import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; import dis from "../../../../src/dispatcher/dispatcher"; import Modal from "../../../../src/Modal"; @@ -692,4 +693,107 @@ describe("StopGapWidgetDriver", () => { await expect(file.text()).resolves.toEqual("test contents"); }); }); + + describe("readRoomTimeline", () => { + const event1 = mkEvent({ + event: true, + id: "$event-id1", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + const event2 = mkEvent({ + event: true, + id: "$event-id2", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + let driver: WidgetDriver; + + beforeEach(() => { + driver = mkDefaultDriver(); + client.getRoom.mockReturnValue({ + getLiveTimeline: () => ({ getEvents: () => [event1, event2] }), + } as unknown as Room); + }); + + it("reads all events", async () => { + expect( + await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 10, undefined), + ).toEqual([event2, event1].map((e) => e.getEffectiveEvent())); + }); + + it("reads up to a limit", async () => { + expect( + await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 1, undefined), + ).toEqual([event2.getEffectiveEvent()]); + }); + + it("reads up to a specific event", async () => { + expect( + await driver.readRoomTimeline( + "!1:example.org", + "org.example.foo", + undefined, + undefined, + 10, + event1.getId(), + ), + ).toEqual([event2.getEffectiveEvent()]); + }); + }); + + describe("readRoomState", () => { + const event1 = mkEvent({ + event: true, + id: "$event-id1", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + skey: "1", + room: "!1:example.org", + }); + const event2 = mkEvent({ + event: true, + id: "$event-id2", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + skey: "2", + room: "!1:example.org", + }); + let driver: WidgetDriver; + let getStateEvents: jest.Mock; + + beforeEach(() => { + driver = mkDefaultDriver(); + getStateEvents = jest.fn(); + client.getRoom.mockReturnValue({ + getLiveTimeline: () => ({ getState: () => ({ getStateEvents }) }), + } as unknown as Room); + }); + + it("reads a specific state key", async () => { + getStateEvents.mockImplementation((eventType, stateKey) => { + if (eventType === "org.example.foo" && stateKey === "1") return event1; + return undefined; + }); + expect(await driver.readRoomState("!1:example.org", "org.example.foo", "1")).toEqual([ + event1.getEffectiveEvent(), + ]); + }); + + it("reads all state keys", async () => { + getStateEvents.mockImplementation((eventType, stateKey) => { + if (eventType === "org.example.foo" && stateKey === undefined) return [event1, event2]; + return []; + }); + expect(await driver.readRoomState("!1:example.org", "org.example.foo", undefined)).toEqual( + [event1, event2].map((e) => e.getEffectiveEvent()), + ); + }); + }); }); From ef7ebe0bb88f5cb5277b056e9be9abd59d73095d Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 22 Jan 2025 09:00:02 -0500 Subject: [PATCH 6/6] Add more comments --- src/stores/widgets/StopGapWidget.ts | 4 +++ src/stores/widgets/StopGapWidgetDriver.ts | 30 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index c5cc1fb5e5d..c17aa81aab3 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -158,6 +158,8 @@ export class StopGapWidget extends EventEmitter { private mockWidget: ElementWidget; private scalarToken?: string; private roomId?: string; + // The room that we're currently allowing the widget to interact with. Only + // used for account widgets, which may follow the user to different rooms. private viewedRoomId: string | null = null; private kind: WidgetKind; private readonly virtual: boolean; @@ -257,6 +259,8 @@ export class StopGapWidget extends EventEmitter { } }; + // This listener is only active for account widgets, which may follow the + // user to different rooms private onRoomViewStoreUpdate = (): void => { const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null; if (roomId !== this.viewedRoomId) { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index bab22127aaa..7f5affab0da 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -467,6 +467,22 @@ export class StopGapWidgetDriver extends WidgetDriver { } } + /** + * Reads all events of the given type, and optionally `msgtype` (if applicable/defined), + * the user has access to. The widget API will have already verified that the widget is + * capable of receiving the events. Less events than the limit are allowed to be returned, + * but not more. + * @param roomId The ID of the room to look within. + * @param eventType The event type to be read. + * @param msgtype The msgtype of the events to be read, if applicable/defined. + * @param stateKey The state key of the events to be read, if applicable/defined. + * @param limit The maximum number of events to retrieve. Will be zero to denote "as many as + * possible". + * @param since When null, retrieves the number of events specified by the "limit" parameter. + * Otherwise, the event ID at which only subsequent events will be returned, as many as specified + * in "limit". + * @returns {Promise} Resolves to the room events, or an empty array. + */ public async readRoomTimeline( roomId: string, eventType: string, @@ -495,6 +511,15 @@ export class StopGapWidgetDriver extends WidgetDriver { return results.map((e) => e.getEffectiveEvent() as IRoomEvent); } + /** + * Reads the current values of all matching room state entries. + * @param roomId The ID of the room. + * @param eventType The event type of the entries to be read. + * @param stateKey The state key of the entry to be read. If undefined, + * all room state entries with a matching event type should be returned. + * @returns {Promise} Resolves to the events representing the + * current values of the room state entries. + */ public async readRoomState(roomId: string, eventType: string, stateKey: string | undefined): Promise { const room = MatrixClientPeg.safeGet().getRoom(roomId); if (room === null) return []; @@ -665,6 +690,11 @@ export class StopGapWidgetDriver extends WidgetDriver { return { file: blob }; } + /** + * Gets the IDs of all joined or invited rooms currently known to the + * client. + * @returns The room IDs. + */ public getKnownRooms(): string[] { return MatrixClientPeg.safeGet() .getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors"))