diff --git a/package.json b/package.json
index 0ba5892d1f..0bbd0283dd 100644
--- a/package.json
+++ b/package.json
@@ -130,7 +130,7 @@
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
- "matrix-widget-api": "^1.14.0",
+ "matrix-widget-api": "^1.15.0",
"memoize-one": "^6.0.0",
"mime": "^4.0.4",
"oidc-client-ts": "^3.0.1",
diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts
index c12226928e..7c2494d2ae 100644
--- a/playwright/e2e/voip/element-call.spec.ts
+++ b/playwright/e2e/voip/element-call.spec.ts
@@ -13,6 +13,7 @@ import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
import type { Credentials } from "../../plugins/homeserver";
import { Bot } from "../../pages/bot";
+import { isDendrite } from "../../plugins/homeserver/dendrite";
// Load a copy of our fake Element Call app, and the latest widget API.
// The fake call app does *just* enough to convince Element Web that a call is ongoing
@@ -578,4 +579,84 @@ test.describe("Element Call", () => {
await openAndJoinCall(page, true);
});
});
+
+ test.describe("Widget leak bug reproduction", { tag: ["@no-firefox", "@no-webkit"] }, () => {
+ test.skip(isDendrite, "No need to test on other HS, this is a client bug reproduction");
+ test.use({
+ config: {
+ features: {
+ feature_video_rooms: true,
+ feature_element_call_video_rooms: true,
+ },
+ },
+ });
+
+ const fakeCallClientSend = readFile("playwright/sample-files/fake-element-call-with-send.html", "utf-8");
+
+ let charlie: Bot;
+ test.use({
+ room: async ({ page, app, user, homeserver, bot }, use) => {
+ charlie = new Bot(page, homeserver, { displayName: "Charlie" });
+ await charlie.prepareClient();
+ const roomId = await app.client.createRoom({
+ name: "VideoRoom",
+ invite: [bot.credentials.userId, charlie.credentials.userId],
+ creation_content: {
+ type: "org.matrix.msc3417.call",
+ },
+ });
+ await app.client.createRoom({
+ name: "OtherRoom",
+ });
+ await use({ roomId });
+ },
+ });
+
+ test.beforeEach(async ({ page, user, app }) => {
+ // use a specific widget to reproduce the bug.
+ // Mock a widget page. We use a fake version of Element Call here.
+ // We should match on things after .html as these widgets get a ton of extra params.
+ await page.route(/\/widget-with-send.html.+/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ // Do enough to
+ body: (await fakeCallClientSend).replace("widgetCodeHere", await widgetApi),
+ });
+ });
+ await app.settings.setValue(
+ "Developer.elementCallUrl",
+ null,
+ SettingLevel.DEVICE,
+ new URL("/widget-with-send.html#", page.url()).toString(),
+ );
+ });
+
+ test("Switching rooms should not leak widgets", async ({ page, user, room, app }) => {
+ await app.viewRoomByName("VideoRoom");
+
+ await expect(page.getByRole("heading", { name: "Approve widget permissions" })).toBeVisible();
+ // approve
+ await page.getByTestId("dialog-primary-button").click();
+
+ // Switch back and forth a few times to trigger the bug.
+
+ await app.viewRoomByName("OtherRoom");
+ await app.viewRoomByName("VideoRoom");
+ await app.viewRoomByName("OtherRoom");
+ await app.viewRoomByName("VideoRoom");
+
+ // For this test we want to display the chat area alongside the widget
+ await page.getByRole("button", { name: "Chat" }).click();
+
+ await page
+ .locator('iframe[title="Element Call"]')
+ .contentFrame()
+ .getByRole("button", { name: "Send Room Message" })
+ .click();
+
+ const messageSent = await page.getByText("I sent this once!!").count();
+
+ expect(messageSent).toBe(1);
+ });
+ });
});
diff --git a/playwright/sample-files/fake-element-call-with-send.html b/playwright/sample-files/fake-element-call-with-send.html
new file mode 100644
index 0000000000..c7fb2fdcbc
--- /dev/null
+++ b/playwright/sample-files/fake-element-call-with-send.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
Fake Element Call
+
State: Loading
+
+
+
+
+
diff --git a/src/stores/widgets/ElementWidgetDriver.ts b/src/stores/widgets/ElementWidgetDriver.ts
index 010c777333..82da6c3d59 100644
--- a/src/stores/widgets/ElementWidgetDriver.ts
+++ b/src/stores/widgets/ElementWidgetDriver.ts
@@ -33,12 +33,14 @@ import {
EventType,
type IContent,
MatrixError,
- type MatrixEvent,
Direction,
THREAD_RELATION_TYPE,
type SendDelayedEventResponse,
type StateEvents,
type TimelineEvents,
+ type Room,
+ type SendDelayedEventRequestOpts,
+ type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
@@ -122,6 +124,7 @@ export class ElementWidgetDriver extends WidgetDriver {
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
+ this.allowedCapabilities.add(MatrixCapabilities.MSC4354SendStickyEvent);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw,
@@ -288,6 +291,13 @@ export class ElementWidgetDriver extends WidgetDriver {
return allAllowed;
}
+ private getSendEventTarget(roomId: string | null = null): { client: MatrixClient; roomId: string } {
+ const client = MatrixClientPeg.safeGet();
+ roomId = roomId || SdkContextClass.instance.roomViewStore.getRoomId() || null;
+ if (!roomId) throw new Error("No room specified and no room in RoomViewStore focus.");
+ return { client, roomId };
+ }
+
public async sendEvent(
eventType: K,
content: StateEvents[K],
@@ -306,10 +316,7 @@ export class ElementWidgetDriver extends WidgetDriver {
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise {
- const client = MatrixClientPeg.get();
- const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId();
-
- if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
+ const { client, roomId } = this.getSendEventTarget(targetRoomId);
let r: { event_id: string } | null;
if (stateKey !== null) {
@@ -348,6 +355,42 @@ export class ElementWidgetDriver extends WidgetDriver {
return { roomId, eventId: r.event_id };
}
+ /**
+ * @experimental Part of MSC4354
+ * @see {@link WidgetDriver#sendStickyEvent}
+ */
+ public async sendStickyEvent(
+ stickyDurationMs: number,
+ eventType: string,
+ content: unknown,
+ targetRoomId?: string | null,
+ ): Promise {
+ const { client, roomId } = this.getSendEventTarget(targetRoomId);
+
+ const r = await client._unstable_sendStickyEvent(
+ roomId,
+ stickyDurationMs,
+ null,
+ eventType as keyof TimelineEvents,
+ content as TimelineEvents[keyof TimelineEvents] & { msc4354_sticky_key: string },
+ );
+ return { roomId, eventId: r.event_id };
+ }
+
+ private getSendDelayedEventOpts(delay: number | null, parentDelayId: string | null): SendDelayedEventRequestOpts {
+ if (delay !== null) {
+ return {
+ delay,
+ ...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
+ };
+ } else if (parentDelayId !== null) {
+ return {
+ parent_delay_id: parentDelayId,
+ };
+ }
+ throw new Error("Must provide at least one of delay or parentDelayId");
+ }
+
/**
* @experimental Part of MSC4140 & MSC4157
* @see {@link WidgetDriver#sendDelayedEvent}
@@ -379,24 +422,8 @@ export class ElementWidgetDriver extends WidgetDriver {
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise {
- const client = MatrixClientPeg.get();
- const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId();
-
- if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
-
- let delayOpts;
- if (delay !== null) {
- delayOpts = {
- delay,
- ...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
- };
- } else if (parentDelayId !== null) {
- delayOpts = {
- parent_delay_id: parentDelayId,
- };
- } else {
- throw new Error("Must provide at least one of delay or parentDelayId");
- }
+ const { client, roomId } = this.getSendEventTarget(targetRoomId);
+ const delayOpts = this.getSendDelayedEventOpts(delay, parentDelayId);
let r: SendDelayedEventResponse | null;
if (stateKey !== null) {
@@ -425,13 +452,39 @@ export class ElementWidgetDriver extends WidgetDriver {
};
}
+ /**
+ * @experimental Part of MSC4354
+ * @see {@link WidgetDriver#sendStickyEvent}
+ */
+ public async sendDelayedStickyEvent(
+ delay: number | null,
+ parentDelayId: string | null,
+ stickyDurationMs: number,
+ eventType: string,
+ content: unknown,
+ targetRoomId?: string | null,
+ ): Promise {
+ const { client, roomId } = this.getSendEventTarget(targetRoomId);
+ const delayOpts = this.getSendDelayedEventOpts(delay, parentDelayId);
+
+ const r = await client._unstable_sendStickyDelayedEvent(
+ roomId,
+ stickyDurationMs,
+ delayOpts,
+ null,
+ eventType as keyof TimelineEvents,
+ content as TimelineEvents[keyof TimelineEvents] & { msc4354_sticky_key: string },
+ );
+ return { roomId, delayId: r.delay_id };
+ }
+
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async cancelScheduledDelayedEvent(delayId: string): Promise {
const client = MatrixClientPeg.get();
- if (!client) throw new Error("Not in a room or not attached to a client");
+ if (!client) throw new Error("Not attached to a client");
await client._unstable_cancelScheduledDelayedEvent(delayId);
}
@@ -442,7 +495,7 @@ export class ElementWidgetDriver extends WidgetDriver {
public async restartScheduledDelayedEvent(delayId: string): Promise {
const client = MatrixClientPeg.get();
- if (!client) throw new Error("Not in a room or not attached to a client");
+ if (!client) throw new Error("Not attached to a client");
await client._unstable_restartScheduledDelayedEvent(delayId);
}
@@ -453,7 +506,7 @@ export class ElementWidgetDriver extends WidgetDriver {
public async sendScheduledDelayedEvent(delayId: string): Promise {
const client = MatrixClientPeg.get();
- if (!client) throw new Error("Not in a room or not attached to a client");
+ if (!client) throw new Error("Not attached to a client");
await client._unstable_sendScheduledDelayedEvent(delayId);
}
@@ -510,6 +563,38 @@ export class ElementWidgetDriver extends WidgetDriver {
}
}
+ /**
+ * Generator function that retrieves events for readRoomTimeline
+ * @param room The room to check the timeline of.
+ * @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 A generator that emits events.
+ */
+ private *readRoomTimelineIterator(
+ room: Room,
+ eventType: string,
+ msgtype: string | undefined,
+ stateKey: string | undefined,
+ limit: number,
+ since: string | undefined,
+ ): Generator {
+ let resultCount: number = 0;
+ const events = [...room.getLiveTimeline().getEvents()]; // timelines are most recent last
+ for (let ev = events.pop(); ev && resultCount < limit && ev.getId() !== since; ev = events.pop()) {
+ if (ev.getType() !== eventType) continue;
+ if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
+ if (stateKey !== undefined && ev.getStateKey() !== stateKey) continue;
+ yield ev.getEffectiveEvent() as IRoomEvent;
+ resultCount++;
+ }
+ }
+
/**
* 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
@@ -535,23 +620,9 @@ export class ElementWidgetDriver extends WidgetDriver {
since: string | undefined,
): Promise {
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) continue;
- if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue;
- if (stateKey !== undefined && ev.getStateKey() !== stateKey) continue;
- results.push(ev);
- }
-
- return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
+ return [...this.readRoomTimelineIterator(room, eventType, msgtype, stateKey, limit, since)];
}
/**
diff --git a/src/stores/widgets/WidgetMessaging.ts b/src/stores/widgets/WidgetMessaging.ts
index 58abe5b34d..6731f1d1d9 100644
--- a/src/stores/widgets/WidgetMessaging.ts
+++ b/src/stores/widgets/WidgetMessaging.ts
@@ -508,6 +508,8 @@ export class WidgetMessaging extends TypedEventEmitter => {
- const { message, encryptionInfo } = payload;
- // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent
- await this.widgetApi?.feedToDevice(message as IRoomEvent, encryptionInfo != null);
+ private onToDeviceMessage = async ({ message, encryptionInfo }: ReceivedToDeviceMessage): Promise => {
+ await this.widgetApi?.feedToDevice(message, encryptionInfo != null);
};
/**
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 9757905882..d3d6e0bec1 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -308,6 +308,8 @@ export function createTestClient(): MatrixClient {
_unstable_cancelScheduledDelayedEvent: jest.fn(),
_unstable_restartScheduledDelayedEvent: jest.fn(),
_unstable_sendScheduledDelayedEvent: jest.fn(),
+ _unstable_sendStickyEvent: jest.fn(),
+ _unstable_sendStickyDelayedEvent: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }),
setDeviceVerified: jest.fn(),
diff --git a/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts
index 0499ba5033..0f3602bd3a 100644
--- a/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts
+++ b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts
@@ -131,6 +131,7 @@ describe("ElementWidgetDriver", () => {
"org.matrix.msc3819.receive.to_device:m.call.replaces",
"org.matrix.msc4157.send.delayed_event",
"org.matrix.msc4157.update_delayed_event",
+ "org.matrix.msc4354.send_sticky_event",
// RTC decline events (send/receive, unstable/stable)
"org.matrix.msc2762.send.event:org.matrix.msc4310.rtc.decline",
"org.matrix.msc2762.send.event:m.rtc.decline",
@@ -593,6 +594,84 @@ describe("ElementWidgetDriver", () => {
});
});
+ describe("sendStickyEvent", () => {
+ let driver: WidgetDriver;
+ const roomId = "!this-room-id";
+
+ beforeEach(() => {
+ driver = mkDefaultDriver();
+ });
+
+ it("sends sticky message events", async () => {
+ client._unstable_sendStickyEvent.mockResolvedValue({
+ event_id: "id",
+ });
+
+ await expect(driver.sendStickyEvent(2000, EventType.RoomMessage, {})).resolves.toEqual({
+ roomId,
+ eventId: "id",
+ });
+
+ expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
+ roomId,
+ 2000,
+ null,
+ EventType.RoomMessage,
+ {},
+ );
+ });
+ });
+
+ describe("sendDelayedStickyEvent", () => {
+ let driver: WidgetDriver;
+ const roomId = "!this-room-id";
+
+ beforeEach(() => {
+ driver = mkDefaultDriver();
+ });
+
+ it("sends delayed sticky message events", async () => {
+ client._unstable_sendStickyDelayedEvent.mockResolvedValue({
+ delay_id: "id",
+ });
+
+ await expect(driver.sendDelayedStickyEvent(1000, null, 2000, EventType.RoomMessage, {})).resolves.toEqual({
+ roomId,
+ delayId: "id",
+ });
+
+ expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
+ roomId,
+ 2000,
+ { delay: 1000 },
+ null,
+ EventType.RoomMessage,
+ {},
+ );
+ });
+ it("sends child action delayed sticky message events", async () => {
+ client._unstable_sendStickyDelayedEvent.mockResolvedValue({
+ delay_id: "id-child",
+ });
+
+ await expect(
+ driver.sendDelayedStickyEvent(null, "id-parent", 2000, EventType.RoomMessage, {}),
+ ).resolves.toEqual({
+ roomId,
+ delayId: "id-child",
+ });
+
+ expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
+ roomId,
+ 2000,
+ { parent_delay_id: "id-parent" },
+ null,
+ EventType.RoomMessage,
+ {},
+ );
+ });
+ });
+
describe("If the feature_dynamic_room_predecessors feature is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
diff --git a/yarn.lock b/yarn.lock
index 20ad046f5d..3d7c3ec0e0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1577,17 +1577,8 @@
yaml "^2.7.0"
"@element-hq/web-shared-components@link:packages/shared-components":
- version "0.0.0-test.12"
- dependencies:
- "@element-hq/element-web-module-api" "^1.8.0"
- "@vector-im/compound-design-tokens" "^6.3.0"
- classnames "^2.5.1"
- counterpart "^0.18.6"
- lodash "^4.17.21"
- matrix-web-i18n "^3.4.0"
- patch-package "^8.0.1"
- react-merge-refs "^3.0.2"
- temporal-polyfill "^0.3.0"
+ version "0.0.0"
+ uid ""
"@emnapi/core@^1.4.3":
version "1.7.0"
@@ -4223,6 +4214,7 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
+ uid ""
"@vector-im/matrix-wysiwyg@2.40.0":
version "2.40.0"
@@ -9693,7 +9685,7 @@ matrix-web-i18n@^3.2.1, matrix-web-i18n@^3.4.0:
minimist "^1.2.8"
walk "^2.3.15"
-matrix-widget-api@^1.14.0:
+matrix-widget-api@^1.10.0, matrix-widget-api@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.15.0.tgz#a508f72a5993a95382bdf890bd9e54525295b321"
integrity sha512-Yu9rX9wyF3A1sqviKgiYHz8aGgL3HhJe9OXKi/lccr1eZnNb6y+ELdbshTjs+VLKM4rkTWt6CE3THsw3f/CZhg==