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 ? ( + {appNameOrType} + ) : ( + + + + )} + + + ); +} 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"], + }, +});