Distinguish room state and timeline events when dealing with widgets (#28681)

* Distinguish room state and timeline events when dealing with widgets

* Upgrade matrix-widget-api

* Fix typo

* Fix tests

* Write more tests

* Add more comments

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
This commit is contained in:
Robin
2025-01-22 12:50:52 -05:00
committed by GitHub
parent ad01218942
commit a0ab88943b
4 changed files with 298 additions and 86 deletions

View File

@@ -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<MatrixClient>;
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<ClientWidgetApi>;
let getRoomId: MockedFunction<() => Optional<string>>;
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<string>
>;
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");
});
});

View File

@@ -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()),
);
});
});
});