Merge branch 'main' of github.com:/element-hq/element-web-modules into t3chguy/monorepo-module-api

# Conflicts:
#	package.json
#	yarn.lock
This commit is contained in:
Michael Telatynski
2026-04-14 14:48:42 +01:00
18 changed files with 883 additions and 2 deletions
@@ -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",
+16
View File
@@ -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"]
}
```
@@ -0,0 +1,5 @@
<html>
<body>
<h1>This is the content of the widget</h1>
</body>
</html>
@@ -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<void>;
}>({
navigationJsonResolver: async ({}, use) => {
await use(Promise.withResolvers<void>());
},
});
const TEST_WIDGET_NAME = "Name of the test widget";
async function makeRoomWithWidgetAndGoTo(
homeserver: StartedHomeserverContainer,
user: Credentials,
page: Page,
avatarUrl?: string,
): Promise<string> {
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<void> {
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",
);
},
);
});
});
@@ -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"
}
}
@@ -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<typeof WidgetTogglesConfig>;
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<WidgetTogglesConfig>;
}
}
@@ -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<void> {
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(
<WidgetToggle
app={widget}
roomId={roomId}
widgetApi={this.api.widget}
i18nApi={this.api.i18n}
/>,
);
}
}
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 <TooltipProvider>{toggleElements}</TooltipProvider>;
});
}
}
export default WidgetToggleModule satisfies ModuleFactory;
@@ -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<Props>`
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<Props>`
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<HTMLButtonElement>) => {
event.stopPropagation();
if (inContainer) {
widgetApi.moveAppToContainer(app, "right", roomId);
} else {
widgetApi.moveAppToContainer(app, "top", roomId);
}
},
[app, inContainer, roomId, widgetApi],
);
return (
<Tooltip label={label} key={app.id}>
<IconButton aria-label={label} onClick={onClick}>
{appAvatarUrl ? (
<Img $isInContainer={inContainer} alt={appNameOrType} src={appAvatarUrl} />
) : (
<Svg $isInContainer={inContainer} role="presentation">
<path d="M16 2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3ZM16 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3ZM4 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z M8 2H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Z" />
</Svg>
)}
</IconButton>
</Tooltip>
);
}
@@ -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);
});
});
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({ types: ["m.custom"] });
const module = new WidgetToggleModule(api);
await module.load();
return (api.extras.addRoomHeaderButtonCallback as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockImplementation(
(key: string, vars?: Record<string, string>) => {
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<typeof vi.fn>).mockImplementation(
(key: string, vars?: Record<string, string>) => {
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);
});
});
});
@@ -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> = {}): 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> = {}): 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<string, string>) => {
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;
}
@@ -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();
});
@@ -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 => <TooltipProvider>{children}</TooltipProvider>;
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<typeof vi.fn>).mockReturnValue("https://example.com/avatar.png");
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(false);
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(true);
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(false);
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(true);
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { wrapper });
const button = screen.getByRole("button", { name: "Hide My Widget" });
await user.click(button);
expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "right", roomId);
});
});
@@ -0,0 +1,7 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx"
},
"include": ["."]
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "lib",
"jsx": "react-jsx"
},
"include": ["src"]
}
@@ -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" } },
},
});
@@ -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"],
},
});