diff --git a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts
index 83ad62ec65..36022edbf5 100644
--- a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts
+++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts
@@ -63,7 +63,9 @@ test.describe("Widget Lifecycle", () => {
},
});
- test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => {
+ test("auto-approves preload and identity", async ({ page, user, homeserver, toasts }, testInfo) => {
+ toasts.rejectToastIfExists("Verify this device");
+
// A bot creates a room with the widget pinned to the top panel, then invites the test user.
// Because the widget was added by a different user (the bot), Element would normally show a
// preload consent dialog before loading it — this test verifies that dialog is skipped.
@@ -119,7 +121,9 @@ test.describe("Widget Lifecycle", () => {
).toBeVisible();
});
- test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver }, testInfo) => {
+ test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver, toasts }, testInfo) => {
+ toasts.rejectToastIfExists("Verify this device");
+
const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
"POST",
@@ -180,7 +184,10 @@ test.describe("Widget Lifecycle", () => {
page,
user,
homeserver,
+ toasts,
}, testInfo) => {
+ toasts.rejectToastIfExists("Verify this device");
+
const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
"POST",
diff --git a/modules/widget-toggles/README.md b/modules/widget-toggles/README.md
new file mode 100644
index 0000000000..e117e706e2
--- /dev/null
+++ b/modules/widget-toggles/README.md
@@ -0,0 +1,16 @@
+# Widget Toggles Module
+
+Adds room header buttons for widgets in the room.
+
+This module needs to be configured to control what widget types get buttons added for them.
+The following config snippet enables the module and configures it to add buttons for both
+custom and jitsi widgets:
+
+```
+"modules": [
+ "/modules/widget-toggles/lib/index.js"
+],
+"io.element.element-web-modules.widget-toggles": {
+ "types": ["m.custom", "jitsi"]
+}
+```
diff --git a/modules/widget-toggles/element-web/e2e/fixture/widget.html b/modules/widget-toggles/element-web/e2e/fixture/widget.html
new file mode 100644
index 0000000000..c2c28e414e
--- /dev/null
+++ b/modules/widget-toggles/element-web/e2e/fixture/widget.html
@@ -0,0 +1,5 @@
+
+
+ This is the content of the widget
+
+
diff --git a/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png b/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png
new file mode 100644
index 0000000000..a171d0adc8
Binary files /dev/null and b/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png differ
diff --git a/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts b/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts
new file mode 100644
index 0000000000..556408b56f
--- /dev/null
+++ b/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts
@@ -0,0 +1,170 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type Page } from "@playwright/test";
+import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
+import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
+import { type Container } from "@element-hq/element-web-module-api";
+
+import { test as base, expect } from "../../../../playwright/element-web-test.ts";
+
+const test = base.extend<{
+ // Resolver for when to respond to the navigation.json request
+ navigationJsonResolver: PromiseWithResolvers;
+}>({
+ navigationJsonResolver: async ({}, use) => {
+ await use(Promise.withResolvers());
+ },
+});
+
+const TEST_WIDGET_NAME = "Name of the test widget";
+
+async function makeRoomWithWidgetAndGoTo(
+ homeserver: StartedHomeserverContainer,
+ user: Credentials,
+ page: Page,
+ avatarUrl?: string,
+): Promise {
+ const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
+ "POST",
+ "/v3/createRoom",
+ user.accessToken,
+ {
+ name: "Come on in we've got widgets",
+ },
+ );
+ await homeserver.csApi.request<{
+ event_id: string;
+ }>("PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, user.accessToken, {
+ id: "1",
+ creatorUserId: user.userId,
+ type: "m.custom",
+ name: TEST_WIDGET_NAME,
+ url: `http://localhost:8080/widget.html`,
+ avatar_url: avatarUrl,
+ });
+
+ await page.goto(`/#/room/${roomId}`);
+
+ return roomId;
+}
+
+async function moveWidgetToContainer(
+ homeserver: StartedHomeserverContainer,
+ user: Credentials,
+ roomId: string,
+ container: Container,
+): Promise {
+ await homeserver.csApi.request(
+ "PUT",
+ `/v3/user/${encodeURIComponent(user.userId)}/rooms/${encodeURIComponent(roomId)}/account_data/im.vector.web.settings`,
+ user.accessToken,
+ {
+ "Widgets.layout": {
+ widgets: {
+ "1": { container },
+ },
+ },
+ },
+ );
+}
+
+test.describe("widget-toggles", () => {
+ test.use({
+ displayName: "Timmy",
+ page: async ({ context, page, moduleDir }, use) => {
+ await context.route("http://localhost:8080/widget.html*", async (route) => {
+ await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" });
+ });
+
+ await context.route("http://localhost:8080/wigeon.png", async (route) => {
+ await route.fulfill({ path: `${moduleDir}/e2e/fixture/wigeon.png`, contentType: "image/png" });
+ });
+
+ await page.goto("/");
+ await use(page);
+ },
+ });
+
+ test("should error if config is missing", async ({ page }) => {
+ await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
+ await expect(page.getByText("Errors in module configuration")).toBeVisible();
+ // We don't take a screenshot as we don't want to assert Element's styling, only our own
+ });
+
+ test.describe("with correct config", () => {
+ test.use({
+ config: {
+ "io.element.element-web-modules.widget-toggles": {
+ types: ["m.custom"],
+ },
+ },
+ });
+
+ test(
+ "should render 'show' button for widget not in top",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toBeVisible();
+ },
+ );
+
+ test(
+ "should render 'hide' button for widget in top",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await moveWidgetToContainer(homeserver, user, roomId, "top");
+
+ await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
+ },
+ );
+
+ test("should move widget to top when 'show' button is clicked", async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).click();
+ await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
+ });
+
+ test("should move widget to left when 'hide' button is clicked", async ({ homeserver, page, user }) => {
+ const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+ await moveWidgetToContainer(homeserver, user, roomId, "top");
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
+
+ await page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME }).click();
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).not.toBeVisible();
+ });
+
+ test("uses widget icon for button image if present", async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page, "mxc://fakehomeserver/fake_content_id");
+
+ await expect(
+ page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).getByRole("img"),
+ ).toHaveAttribute("src", /\/_matrix\/media\/v3\/download\/fakehomeserver\/fake_content_id$/);
+ });
+
+ test(
+ "uses built in icon for widgets with no avatar",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toMatchScreenshot(
+ "widget-toggle-button-default-icon.png",
+ );
+ },
+ );
+ });
+});
diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json
new file mode 100644
index 0000000000..b4f1a3e1bf
--- /dev/null
+++ b/modules/widget-toggles/element-web/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@element-hq/element-web-module-widget-toggle",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "lib/index.js",
+ "license": "SEE LICENSE IN README.md",
+ "scripts": {
+ "prepare": "vite build",
+ "lint:types": "tsc --noEmit",
+ "lint:codestyle": "echo 'handled by lint:eslint'",
+ "test": "vitest run --coverage"
+ },
+ "devDependencies": {
+ "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
+ "@element-hq/element-web-module-api": "^1.0.0",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^22.10.7",
+ "@types/react": "^19",
+ "@vitejs/plugin-react": "^5.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/coverage-v8": "^4.0.0",
+ "react": "^19",
+ "rollup-plugin-external-globals": "^0.13.0",
+ "typescript": "^5.7.3",
+ "vite": "^7.1.11",
+ "vite-plugin-node-polyfills": "^0.25.0",
+ "vite-plugin-svgr": "^4.3.0",
+ "vitest": "^4.0.0",
+ "vitest-sonar-reporter": "^2.0.0"
+ },
+ "dependencies": {
+ "@vector-im/compound-design-tokens": "^6.0.0",
+ "@vector-im/compound-web": "^8.0.0",
+ "matrix-widget-api": "^1.17.0",
+ "styled-components": "^6.3.11",
+ "zod": "^4.3.6"
+ }
+}
diff --git a/modules/widget-toggles/element-web/src/config.ts b/modules/widget-toggles/element-web/src/config.ts
new file mode 100644
index 0000000000..54e2d075a4
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/config.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { z, type input } from "zod/mini";
+
+z.config(z.locales.en());
+
+export const WidgetTogglesConfig = z.object({
+ /**
+ * The widget types to show a toggle for.
+ */
+ types: z.array(z.string()),
+});
+
+export type WidgetTogglesConfig = z.infer;
+
+export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles";
+
+declare module "@element-hq/element-web-module-api" {
+ export interface Config {
+ [CONFIG_KEY]: input;
+ }
+}
diff --git a/modules/widget-toggles/element-web/src/index.tsx b/modules/widget-toggles/element-web/src/index.tsx
new file mode 100644
index 0000000000..ec6542106a
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/index.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type JSX } from "react";
+import { TooltipProvider } from "@vector-im/compound-web";
+
+import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
+import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
+import { WidgetToggle } from "./toggle";
+
+class WidgetToggleModule implements Module {
+ public static readonly moduleApiVersion = "^1.12.0";
+ private config?: WidgetTogglesConfig;
+
+ public constructor(private api: Api) {}
+
+ public async load(): Promise {
+ try {
+ this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY));
+ } catch (e) {
+ console.error("Failed to init module", e);
+ throw new Error(`Errors in module configuration for widget toggles module`);
+ }
+
+ this.api.extras.addRoomHeaderButtonCallback((roomId: string) => {
+ const widgets = this.api.widget.getWidgetsInRoom(roomId);
+ const toggleElements: JSX.Element[] = [];
+
+ for (const widget of widgets) {
+ if (this.config?.types.includes(widget.type)) {
+ toggleElements.push(
+ ,
+ );
+ }
+ }
+
+ if (toggleElements.length === 0) return undefined;
+
+ // XXX: We shouldn't have to add another TooltipProvider here, it should
+ // be using the one in MatrixChat in Element Web, but thanks to the fact
+ // that contexts are "magically" identified by class, it doesn't pick up the
+ // context because we use a different copy of compound-web. We'll probably
+ // need to fix this at some point, possibly by moving compound's tooltip stuff
+ // out to its own mini-module that can be provided at runtime by Element Web,
+ // unless React make contexts more sensible.
+ // Annoyingly this does actually cause the tooltips to behave a bit weirdly:
+ // there's a delay before the tooltip appears when yo move between these buttons
+ // and the rest of the header buttons, whereas moving between the other header
+ // buttons, the tooltips appear straight away once one has appeared.
+ return {toggleElements};
+ });
+ }
+}
+
+export default WidgetToggleModule satisfies ModuleFactory;
diff --git a/modules/widget-toggles/element-web/src/toggle.tsx b/modules/widget-toggles/element-web/src/toggle.tsx
new file mode 100644
index 0000000000..741904c442
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/toggle.tsx
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 Nordeck IT + Consulting GmbH
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
+import { IconButton, Tooltip } from "@vector-im/compound-web";
+import { type IWidget } from "matrix-widget-api";
+import React, { type JSX } from "react";
+import styled from "styled-components";
+
+type Props = { $isInContainer: boolean };
+
+const Img = styled.img`
+ border-color: ${(): string => "var(--cpd-color-text-action-accent)"};
+ border-radius: 50%;
+ border-style: solid;
+ border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")};
+ box-sizing: border-box;
+ height: 24px;
+ width: 24px;
+`;
+
+const Svg = styled.svg`
+ height: 24px;
+ fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")};
+ width: 24px;
+`;
+
+function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null {
+ if (app.type.match(/jitsi/i)) {
+ return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K";
+ }
+
+ return widgetApi.getAppAvatarUrl(app);
+}
+
+function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean {
+ return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId);
+}
+
+type WidgetToggleProps = {
+ app: IWidget;
+ roomId: string;
+ widgetApi: WidgetApi;
+ i18nApi: I18nApi;
+};
+
+export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element {
+ const appAvatarUrl = avatarUrl(app, widgetApi);
+ const appNameOrType = app.name ?? app.type;
+ const inContainer = isInContainer(app, widgetApi, roomId);
+
+ const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType });
+
+ const onClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (inContainer) {
+ widgetApi.moveAppToContainer(app, "right", roomId);
+ } else {
+ widgetApi.moveAppToContainer(app, "top", roomId);
+ }
+ },
+ [app, inContainer, roomId, widgetApi],
+ );
+
+ return (
+
+
+ {appAvatarUrl ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/modules/widget-toggles/element-web/tests/config.test.ts b/modules/widget-toggles/element-web/tests/config.test.ts
new file mode 100644
index 0000000000..7863ffadce
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/config.test.ts
@@ -0,0 +1,43 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { describe, expect, test } from "vitest";
+
+import { WidgetTogglesConfig } from "../src/config";
+
+describe("WidgetTogglesConfig", () => {
+ test("parses a valid config with an array of widget types", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: ["m.video", "m.audio"] });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.types).toEqual(["m.video", "m.audio"]);
+ }
+ });
+
+ test("parses a valid config with an empty types array", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: [] });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.types).toEqual([]);
+ }
+ });
+
+ test("rejects a config missing the types field", () => {
+ const result = WidgetTogglesConfig.safeParse({});
+ expect(result.success).toBe(false);
+ });
+
+ test("rejects a config where types is not an array", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: "m.video" });
+ expect(result.success).toBe(false);
+ });
+
+ test("rejects a config where types contains non-string values", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: [1, 2, 3] });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/index.test.ts b/modules/widget-toggles/element-web/tests/index.test.ts
new file mode 100644
index 0000000000..d1258e56a2
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/index.test.ts
@@ -0,0 +1,175 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { type Api } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+
+import WidgetToggleModule from "../src/index";
+import { CONFIG_KEY, WidgetTogglesConfig } from "../src/config";
+import { mockWidget, mockWidgetApi } from "./mocks";
+
+const makeApi = (widgets: IWidget[] = []): Api => {
+ const addRoomHeaderButtonCallback = vi.fn();
+ return {
+ config: {
+ get: vi.fn().mockReturnValue({ types: ["m.custom"] }),
+ },
+ extras: {
+ addRoomHeaderButtonCallback,
+ },
+ widget: mockWidgetApi({
+ getWidgetsInRoom: vi.fn().mockReturnValue(widgets),
+ }),
+ i18n: {
+ translate: vi.fn().mockImplementation((key: string) => key),
+ },
+ } as unknown as Api;
+};
+
+vi.mock("../src/config", async () => {
+ return {
+ CONFIG_KEY: "fake_config_key",
+ WidgetTogglesConfig: {
+ parse: vi.fn(),
+ },
+ };
+});
+
+describe("WidgetToggleModule", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("load", () => {
+ test("reads config using CONFIG_KEY", async () => {
+ const api = makeApi();
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(api.config.get).toHaveBeenCalledWith(CONFIG_KEY);
+ });
+
+ test("parses config with WidgetTogglesConfig.parse", async () => {
+ const api = makeApi();
+ const rawConfig = { types: ["m.custom"] };
+ (api.config.get as ReturnType).mockReturnValue(rawConfig);
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(WidgetTogglesConfig.parse).toHaveBeenCalledWith(rawConfig);
+ });
+
+ test("registers a room header button callback", async () => {
+ const api = makeApi();
+ (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] });
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(api.extras.addRoomHeaderButtonCallback).toHaveBeenCalledOnce();
+ });
+
+ test("throws error when config parsing fails", async () => {
+ const api = makeApi();
+ (WidgetTogglesConfig.parse as ReturnType).mockImplementation(() => {
+ throw new Error("Invalid config");
+ });
+
+ const module = new WidgetToggleModule(api);
+ await expect(module.load()).rejects.toThrow("Errors in module configuration for widget toggles module");
+ });
+ });
+
+ describe("room header button callback", () => {
+ const roomId = "!room:example.com";
+
+ const getCallback = async (api: Api): Promise<(roomId: string) => React.JSX.Element | undefined> => {
+ (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] });
+ const module = new WidgetToggleModule(api);
+ await module.load();
+ return (api.extras.addRoomHeaderButtonCallback as ReturnType).mock.calls[0][0];
+ };
+
+ test("returns undefined when there are no widgets in the room", async () => {
+ const api = makeApi([]);
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeUndefined();
+ });
+
+ test("returns undefined when no widgets match the configured types", async () => {
+ const api = makeApi([mockWidget({ type: "m.other" })]);
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeUndefined();
+ });
+
+ test("renders WidgetToggle for each matching widget", async () => {
+ const api = makeApi([
+ mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
+ mockWidget({ id: "w2", type: "m.custom", name: "Widget Two" }),
+ ]);
+ (api.i18n.translate as ReturnType).mockImplementation(
+ (key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ },
+ );
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeDefined();
+ render(result!);
+
+ expect(screen.getAllByRole("button").length).toBe(2);
+ });
+
+ test("does not render WidgetToggle for non-matching widget types", async () => {
+ const api = makeApi([
+ mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
+ mockWidget({ id: "w2", type: "m.other", name: "Widget Other" }),
+ ]);
+ (api.i18n.translate as ReturnType).mockImplementation(
+ (key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ },
+ );
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeDefined();
+ render(result!);
+
+ expect(screen.getAllByRole("button").length).toBe(1);
+ });
+
+ test("calls getWidgetsInRoom with correct roomId", async () => {
+ const api = makeApi([]);
+ const callback = await getCallback(api);
+
+ callback(roomId);
+ expect(api.widget.getWidgetsInRoom).toHaveBeenCalledWith(roomId);
+ });
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/mocks.ts b/modules/widget-toggles/element-web/tests/mocks.ts
new file mode 100644
index 0000000000..e37cd67b01
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/mocks.ts
@@ -0,0 +1,45 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+import { vi } from "vitest";
+
+export function mockWidget(overrides: Partial = {}): IWidget {
+ return {
+ id: "widget-1",
+ creatorUserId: "@user:example.com",
+ type: "m.custom",
+ name: "My Widget",
+ url: "https://example.com",
+ ...overrides,
+ };
+}
+
+export function mockWidgetApi(overrides: Partial = {}): WidgetApi {
+ return {
+ getWidgetsInRoom: vi.fn().mockReturnValue([]),
+ getAppAvatarUrl: vi.fn().mockReturnValue(null),
+ isAppInContainer: vi.fn().mockReturnValue(false),
+ moveAppToContainer: vi.fn(),
+ ...overrides,
+ } as unknown as WidgetApi;
+}
+
+export function mockI18nApi(): I18nApi {
+ return {
+ translate: vi.fn().mockImplementation((key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ }),
+ } as unknown as I18nApi;
+}
diff --git a/modules/widget-toggles/element-web/tests/setupTests.ts b/modules/widget-toggles/element-web/tests/setupTests.ts
new file mode 100644
index 0000000000..d7601a1499
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/setupTests.ts
@@ -0,0 +1,13 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { afterEach } from "vitest";
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/modules/widget-toggles/element-web/tests/toggle.test.tsx b/modules/widget-toggles/element-web/tests/toggle.test.tsx
new file mode 100644
index 0000000000..0a0e40449f
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/toggle.test.tsx
@@ -0,0 +1,82 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { beforeEach, describe, expect, test, type vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { type WidgetApi, type I18nApi } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+import { type PropsWithChildren } from "react";
+import { TooltipProvider } from "@vector-im/compound-web";
+import userEvent from "@testing-library/user-event";
+
+import { WidgetToggle } from "../src/toggle";
+import { mockI18nApi, mockWidget, mockWidgetApi } from "./mocks";
+
+const roomId = "!room:example.com";
+
+const wrapper = ({ children }: PropsWithChildren): React.JSX.Element => {children};
+
+describe("WidgetToggle", () => {
+ let widgetApi: WidgetApi;
+ let i18nApi: I18nApi;
+ let app: IWidget;
+
+ beforeEach(() => {
+ widgetApi = mockWidgetApi();
+ i18nApi = mockI18nApi();
+ app = mockWidget();
+ });
+
+ test("displays avatar image when widget has an avatar URL", () => {
+ (widgetApi.getAppAvatarUrl as ReturnType).mockReturnValue("https://example.com/avatar.png");
+ render(, { wrapper });
+ const img = screen.getByRole("img", { name: app.name });
+ expect(img).toBeDefined();
+ expect(img.getAttribute("src")).toBe("https://example.com/avatar.png");
+ });
+
+ test("renders the Jitsi avatar for Jitsi widgets", () => {
+ app = mockWidget({ type: "m.jitsi", name: "Jitsi" });
+ render(, { wrapper });
+ const img = screen.getByRole("img", { name: "Jitsi" });
+ expect(img.getAttribute("src")).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ test("shows 'Show' label when widget is not in container", () => {
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Show My Widget" });
+ expect(button).toBeDefined();
+ });
+
+ test("shows 'Hide' label when widget is in container", () => {
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Hide My Widget" });
+ expect(button).toBeDefined();
+ });
+
+ test("calls moveAppToContainer with 'top' when widget is not in container and button is clicked", async () => {
+ const user = userEvent.setup();
+
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Show My Widget" });
+ await user.click(button);
+ expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "top", roomId);
+ });
+
+ test("calls moveAppToContainer with 'right' when widget is in container and button is clicked", async () => {
+ const user = userEvent.setup();
+
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Hide My Widget" });
+ await user.click(button);
+ expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "right", roomId);
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/tsconfig.json b/modules/widget-toggles/element-web/tests/tsconfig.json
new file mode 100644
index 0000000000..f5eb2ca359
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ },
+ "include": ["."]
+}
diff --git a/modules/widget-toggles/element-web/tsconfig.json b/modules/widget-toggles/element-web/tsconfig.json
new file mode 100644
index 0000000000..fbda6380e7
--- /dev/null
+++ b/modules/widget-toggles/element-web/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "lib",
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts
new file mode 100644
index 0000000000..b65e3ec8ea
--- /dev/null
+++ b/modules/widget-toggles/element-web/vite.config.ts
@@ -0,0 +1,51 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { nodePolyfills } from "vite-plugin-node-polyfills";
+import externalGlobals from "rollup-plugin-external-globals";
+import svgr from "vite-plugin-svgr";
+import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, "src/index.tsx"),
+ name: "element-web-module-widget-toggles",
+ fileName: "index",
+ formats: ["es"],
+ },
+ outDir: "lib",
+ target: "esnext",
+ sourcemap: true,
+ rollupOptions: {
+ external: ["react"],
+ },
+ },
+ plugins: [
+ importCSSSheet(),
+ react(),
+ svgr(),
+ nodePolyfills({
+ include: ["events"],
+ }),
+ externalGlobals({
+ // Reuse React from the host app
+ react: "window.React",
+ }),
+ ],
+ define: {
+ // Use production mode for the build as it is tested against production builds of Element Web,
+ // this is required for React JSX versions to be compatible.
+ process: { env: { NODE_ENV: "production" } },
+ },
+});
diff --git a/modules/widget-toggles/element-web/vitest.config.ts b/modules/widget-toggles/element-web/vitest.config.ts
new file mode 100644
index 0000000000..4a5b5a32cc
--- /dev/null
+++ b/modules/widget-toggles/element-web/vitest.config.ts
@@ -0,0 +1,34 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { defineConfig } from "vitest/config";
+import { env } from "node:process";
+import { playwright } from "@vitest/browser-playwright";
+
+const isGHA = env["GITHUB_ACTIONS"] !== undefined;
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.{ts,tsx}"],
+ exclude: ["./e2e/**/*", "./node_modules/**/*"],
+ reporters: isGHA
+ ? ["default", ["vitest-sonar-reporter", { outputFile: "coverage/sonar-report.xml" }]]
+ : ["default"],
+ coverage: {
+ provider: "v8",
+ include: ["src/**/*.ts"],
+ reporter: [["lcov", { projectRoot: "../../../" }], "text"],
+ },
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright({}),
+ instances: [{ browser: "chromium" }],
+ },
+ setupFiles: ["tests/setupTests.ts"],
+ },
+});