diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png deleted file mode 100644 index 8e335bd2323..00000000000 Binary files a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png and /dev/null differ diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 40e36473e31..c17aa81aab3 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,9 @@ 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; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID @@ -177,17 +187,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 +258,17 @@ 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) { + 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 +295,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 locked 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 +350,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 +479,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 +496,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 +603,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 fa5a43f248c..7f5affab0da 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,69 @@ 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( + /** + * 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, 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; - } - 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())); - } - } + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } - results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent)); - } - return allResults; + /** + * 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 []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) return []; + + 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 +690,17 @@ 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")) + .map((r) => r.roomId); + } + /** * Expresses a {@link MatrixError} as a JSON payload * for use by Widget API error responses. diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index f767c96a028..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; @@ -118,24 +138,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 +185,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 +195,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 +211,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,18 +231,19 @@ 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()); }); }); }); + 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 e484d0cc33f..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"; @@ -569,7 +570,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 +585,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); }); }); @@ -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()), + ); + }); + }); });