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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 316 B |
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user