diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index ccf5429773..c5db83fbc9 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -181,14 +181,53 @@ jobs: retention-days: 1 if-no-files-found: error - downstream-modules: - name: Downstream Playwright tests [element-modules] + modules: + name: Modules Playwright tests needs: build_ew if: needs.build_ew.outputs.skip == 'false' && github.event_name == 'merge_group' - uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main # zizmor: ignore[unpinned-uses] - with: - webapp-artifact: webapp - reporter: blob + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + repository: element-hq/element-web + + - name: 📥 Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: webapp + path: apps/web/webapp + + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + node-version: "lts/*" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup playwright + uses: ./.github/actions/setup-playwright + with: + needs-webkit: false + write-cache: ${{ github.event_name != 'merge_group' }} + + - name: Run Playwright tests + run: pnpm test:playwright --reporter=blob + working-directory: modules + env: + WEBAPP_PATH: ../webapp + + - name: Upload blob report to GitHub Actions Artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: blob-report-modules + path: modules/blob-report + retention-days: 1 + if-no-files-found: error prepare_ed: name: "Prepare Element Desktop" @@ -252,7 +291,7 @@ jobs: needs: - build_ew - playwright_ew - - downstream-modules + - modules - prepare_ed - build_ed_windows - build_ed_linux diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83a71aedde..1134326e8b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,6 +104,8 @@ jobs: - apps/desktop - packages/shared-components - packages/module-api + - modules/widget-lifecycle + - modules/widget-toggles runs-on: ubuntu-24.04 steps: # Dump the disk usage before we start: this job frequently flakes with "No space left on device" diff --git a/knip.ts b/knip.ts index ca2a378cd9..57d1e41294 100644 --- a/knip.ts +++ b/knip.ts @@ -57,6 +57,9 @@ export default { ], ignoreBinaries: ["scripts/in-docker.sh"], }, + "modules/*": { + entry: "src/index.ts{x,}", + }, ".": { entry: ["scripts/**", "docs/**"], }, diff --git a/modules/.dockerignore b/modules/.dockerignore new file mode 100644 index 0000000000..f83526d133 --- /dev/null +++ b/modules/.dockerignore @@ -0,0 +1 @@ +**/node_modules/ \ No newline at end of file diff --git a/modules/.eslintrc.cjs b/modules/.eslintrc.cjs new file mode 100644 index 0000000000..b8b9794a3c --- /dev/null +++ b/modules/.eslintrc.cjs @@ -0,0 +1,75 @@ +module.exports = { + plugins: ["matrix-org", "eslint-plugin-react-compiler"], + extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], + parserOptions: { + project: ["./tsconfig.json"], + }, + env: { + browser: true, + node: true, + }, + rules: { + "react/jsx-key": ["error"], + + "no-restricted-properties": [ + "error", + ...buildRestrictedPropertiesOptions( + ["window.innerHeight", "window.innerWidth", "window.visualViewport"], + "Use UIStore to access window dimensions instead.", + ), + ...buildRestrictedPropertiesOptions( + ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], + "Use Media helper instead to centralise access for customisation.", + ), + ...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."), + ], + "no-restricted-globals": [ + "error", + { + name: "setImmediate", + message: "Use setTimeout instead.", + }, + { + name: "Buffer", + message: "Buffer is not available in the web.", + }, + ], + + "import/no-duplicates": ["error"], + "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", + }, + overrides: [ + { + files: ["playwright/**/*.ts", "modules/*/e2e/**/*.{ts,tsx}"], + rules: { + // This is necessary for Playwright fixtures + "no-empty-pattern": "off", + // This is necessary for Playwright fixtures + "react-hooks/rules-of-hooks": "off", + // This just gets annoying in test code + "@typescript-eslint/explicit-function-return-type": "off", + }, + }, + ], + settings: { + react: { + version: "detect", + }, + }, +}; + +function buildRestrictedPropertiesOptions(properties, message) { + return properties.map((prop) => { + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } + return { + object, + property, + message, + }; + }); +} diff --git a/modules/.gitignore b/modules/.gitignore new file mode 100644 index 0000000000..331ec177d5 --- /dev/null +++ b/modules/.gitignore @@ -0,0 +1,13 @@ +lib/ +temp/ +coverage/ + +/playwright-html-report/ +**/test-results +**/_results +# Only commit snapshots from Linux +**/e2e/snapshots/**/*.png +**/e2e/snapshots/**/*.yml +!**/e2e/snapshots/**/*-linux.png +!**/e2e/snapshots/**/*-linux.aria.yml +/playwright-report/ diff --git a/modules/.lintstagedrc b/modules/.lintstagedrc new file mode 100644 index 0000000000..16e652a838 --- /dev/null +++ b/modules/.lintstagedrc @@ -0,0 +1,3 @@ +{ + "*": "prettier -u --write" +} diff --git a/modules/Dockerfile b/modules/Dockerfile new file mode 100644 index 0000000000..16ba8480e1 --- /dev/null +++ b/modules/Dockerfile @@ -0,0 +1,24 @@ +ARG ELEMENT_VERSION=latest@sha256:a84f294ce46e4327ebacecb78bfc94cf6a45c7ffa5104a28f06b5ac69d0b2548 + +FROM --platform=$BUILDPLATFORM node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 AS builder + +ARG BUILD_CONTEXT + +RUN apk add --no-cache jq + +WORKDIR /app +COPY package.json yarn.lock ./ +# Copy the package.json files of all modules & packages to ensure the frozen workspace lockfile holds up +RUN --mount=type=bind,target=/docker-context \ + cd /docker-context/; \ + find . -path ./node_modules -prune -o -name "package.json" -mindepth 0 -maxdepth 4 -exec cp --parents "{}" /app/ \; +RUN yarn install --frozen-lockfile --ignore-scripts +COPY tsconfig.json ./ +COPY ./$BUILD_CONTEXT ./$BUILD_CONTEXT +RUN cd $BUILD_CONTEXT && yarn vite build +RUN mkdir /modules +RUN cp -r ./$BUILD_CONTEXT/lib/ /modules/$(jq -r '"\(.name)-v\(.version)"' ./$BUILD_CONTEXT/package.json) + +FROM ghcr.io/element-hq/element-web:${ELEMENT_VERSION} + +COPY --from=builder /modules /modules/ \ No newline at end of file diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 0000000000..46b62e7706 --- /dev/null +++ b/modules/README.md @@ -0,0 +1 @@ +Element Web Modules (based on @element-hq/element-web-module-api) which we maintain live here. diff --git a/modules/banner/README.md b/modules/banner/README.md new file mode 100644 index 0000000000..21955c4e63 --- /dev/null +++ b/modules/banner/README.md @@ -0,0 +1,57 @@ +# @element-hq/element-web-module-banner + +Banner module for Element Web. +Allows rendering a top bar with slide out left panel menu. + +Supports the following configuration options: + +| Key | Type | Description | +| ------------- | ------ | ---------------------------------------------------------------------------------------------- | +| logo_url | string | URL to the logo to render in the banner | +| logo_link_url | string | URL to send the user to when clicking the logo in the banner | +| title | string | The title to render next to the logo, falls back to top level `brand` variable if unspecified. | +| menu | `Menu` | Data to render in the banner menu | + +The `Menu` type is fulfilled by the following discriminated union: + +### Univention menu + +| Key | Type | Description | +| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ | +| type | "univention" | The type for this menu config | +| ics_url | string | URL to the UCS Intercom Service, https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints | + +### Static menu + +| Key | Type | Description | +| ---------- | ---------------- | ------------------------------------------------------------------------- | +| type | "static" | The type for this menu config | +| logo_url | string, optional | URL to the logo to render in the menu, defaults to banner logo if omitted | +| categories | `[]Category` | Categories to render in the menu | + +The `Category` type is fulfilled by the following interface: + +| Key | Type | Description | +| ----- | -------- | -------------------------------- | +| name | string | The name of this category | +| links | `[]Link` | Links to render in this category | + +The `Link` type is fulfilled by the following interface: + +| Key | Type | Description | +| -------- | ---------------- | --------------------------------------- | +| icon_uri | string | URL to the icon to render for this link | +| name | string | The name to render for this link | +| link_url | string | The URL to link to | +| target | string, optional | The `target` to use for this link | + +## Copyright & License + +Copyright (c) 2025 New Vector Ltd + +This software is multi licensed by New Vector Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/modules/banner/e2e/banner.spec.ts b/modules/banner/e2e/banner.spec.ts new file mode 100644 index 0000000000..7a5f2a1185 --- /dev/null +++ b/modules/banner/e2e/banner.spec.ts @@ -0,0 +1,222 @@ +/* +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 { input } from "zod/mini"; + +import { test as base, expect } from "../../playwright/element-web-test.ts"; +import { type ConfigSchema } from "../src/config.ts"; + +const test = base.extend<{ + // Resolver for when to respond to the navigation.json request + navigationJsonResolver: PromiseWithResolvers; +}>({ + navigationJsonResolver: async ({}, use) => { + await use(Promise.withResolvers()); + }, +}); + +test.describe("Banner", () => { + test.use({ + displayName: "Timmy", + page: async ({ context, page, moduleDir }, use) => { + for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) { + await context.route(`/${path}*`, async (route) => { + const file = new URL(route.request().url()).pathname; + await route.fulfill({ path: `${moduleDir}/e2e/fixture/${file}` }); + }); + } + + 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 + }); + + const configs: input[] = [ + { + logo_url: "http://localhost:8080/logo.svg", + logo_link_url: "https://example.com/portal", + menu: { + type: "static", + categories: [ + { + name: "Applications", + links: [ + { + icon_uri: "http://localhost:8080/app1.png", + name: "E-Mail", + link_url: "https://example.com/email", + target: "app1", + }, + { + icon_uri: "http://localhost:8080/app2.png", + name: "Riot", + link_url: "https://riot.im/app", + target: "riot-im", + }, + ], + }, + { + name: "Links", + links: [ + { + icon_uri: "http://localhost:8080/app1.png", + name: "Link", + link_url: "https://example.com/link1", + }, + ], + }, + ], + }, + }, + { + logo_url: "http://localhost:8080/opendesk/logomark.svg", + logo_link_url: "https://example.com/portal", + menu: { + type: "univention", + logo_url: "http://localhost:8080/opendesk/logofull.svg", + ics_url: "http://localhost:8080/ics/", + }, + theme: { + triggerBackgroundColorHover: "#571EFA", + triggerBackgroundColorPressed: "#4519C2", + }, + }, + ]; + + for (const config of configs) { + const type = config.menu.type; + + test.describe(`${type} config`, () => { + test.use({ + config: { + "io.element.element-web-modules.banner": config, + }, + }); + + test.beforeEach(async ({ context, moduleDir, navigationJsonResolver }) => { + await context.route("http://localhost:8080/ics/navigation.json*", async (route) => { + await navigationJsonResolver.promise; + await route.fulfill({ + path: `${moduleDir}/e2e/fixture/navigation.json`, + contentType: "application/json", + }); + }); + await context.route("http://localhost:8080/ics/silent", async (route) => { + await route.fulfill({ + path: `${moduleDir}/e2e/fixture/silent/index.html`, + contentType: "text/html", + }); + }); + }); + + test("should render", { tag: ["@screenshot"] }, async ({ page, axe, navigationJsonResolver }) => { + await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); + await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal"); + + const nav = page.locator("nav"); + + const trigger = page.getByLabel("Show menu"); + await expect(trigger).toBeVisible(); + + // Assert the banner looks as we expect + await expect(nav).toMatchAriaSnapshot(); + await expect(nav).toMatchScreenshot(`${type}_nav.png`); + + // Check hover styles + await trigger.hover(); + await expect(nav).toMatchScreenshot(`${type}_nav_hover.png`); + + await test.step("open menu", async () => { + await trigger.click(); + const sidebar = page.getByRole("dialog"); + + if (type === "univention") { + await expect(sidebar).toMatchScreenshot(`${type}_menu_loading.png`); + navigationJsonResolver.resolve(); + } + await page.pause(); + + const emailApp = page.getByText("E-Mail"); + await expect(emailApp).toHaveAttribute("href", "https://example.com/email"); + await emailApp.hover(); + + // Assert the sidebar looks as we expect + await expect(axe).toHaveNoViolations(); + await expect(sidebar).toMatchAriaSnapshot(); + await expect(page).toMatchScreenshot(`${type}_menu.png`, { + // We exclude this as we don't want to assert Element's styling, only our own + css: ` + #matrixchat { + opacity: 0; + background: orchid; + } + `, + }); + + // Verify that the #matrixchat root got shrunk to fit rather than exploding the viewport + const bodyBoundingBox = await page.locator("body").boundingBox(); + const rootBoundingBox = await page.locator("#matrixchat").boundingBox(); + const headerBoundingBox = await page.getByTestId("banner").boundingBox(); + expect(rootBoundingBox!.height).toBeLessThan(bodyBoundingBox!.height); + expect(rootBoundingBox!.height + headerBoundingBox!.height).toEqual(bodyBoundingBox!.height); + }); + + await test.step("close menu", async () => { + await expect(axe).toHaveNoViolations(); + + // Assert it closes by clicking the overlay + await page.getByTestId("dialog-overlay").click(); + await expect(page.getByRole("dialog")).not.toBeVisible(); + }); + }); + }); + } + + test.describe("univention config", () => { + test.use({ + config: { + "io.element.element-web-modules.banner": { + logo_url: "http://localhost:8080/opendesk/logomark.svg", + logo_link_url: "https://example.com/portal", + menu: { + type: "univention", + logo_url: "http://localhost:8080/opendesk/logofull.svg", + ics_url: "http://localhost:8080/ics/", + }, + }, + }, + }); + + test.beforeEach(async ({ context }) => { + await context.route("http://localhost:8080/ics/silent", async (route) => { + await route.fulfill({ status: 500 }); + }); + await context.route("http://localhost:8080/ics/navigation.json*", async (route) => { + await route.fulfill({ status: 500 }); + }); + }); + + test("should render error", { tag: ["@screenshot"] }, async ({ page, axe }) => { + await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible(); + await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal"); + + const trigger = page.getByLabel("Show menu"); + + await trigger.click(); + const sidebar = page.getByRole("dialog"); + await expect(sidebar.getByText("Failed to load")).toBeVisible(); + await expect(sidebar).toMatchScreenshot("univention_error.png"); + await expect(axe).toHaveNoViolations(); + }); + }); +}); diff --git a/modules/banner/e2e/fixture/app1.png b/modules/banner/e2e/fixture/app1.png new file mode 100644 index 0000000000..2c34416b88 Binary files /dev/null and b/modules/banner/e2e/fixture/app1.png differ diff --git a/modules/banner/e2e/fixture/app2.png b/modules/banner/e2e/fixture/app2.png new file mode 100644 index 0000000000..ee42954c78 Binary files /dev/null and b/modules/banner/e2e/fixture/app2.png differ diff --git a/modules/banner/e2e/fixture/logo.svg b/modules/banner/e2e/fixture/logo.svg new file mode 100644 index 0000000000..90c502cacf --- /dev/null +++ b/modules/banner/e2e/fixture/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/banner/e2e/fixture/navigation.json b/modules/banner/e2e/fixture/navigation.json new file mode 100644 index 0000000000..7365ff1371 --- /dev/null +++ b/modules/banner/e2e/fixture/navigation.json @@ -0,0 +1,87 @@ +{ + "categories": [ + { + "identifier": "applications", + "display_name": "Anwendungen", + "entries": [ + { + "identifier": "app1", + "display_name": "Portal", + "icon_url": "http://localhost:8080/opendesk/app1.svg", + "link": "https://example.com/app1", + "target": "_blank" + }, + { + "identifier": "app2", + "display_name": "Aufgaben", + "icon_url": "http://localhost:8080/opendesk/app2.svg", + "link": "https://example.com/app2", + "target": "_blank" + }, + { + "identifier": "app3", + "display_name": "Chat", + "icon_url": "http://localhost:8080/opendesk/app3.svg", + "link": "https://example.com/app3", + "target": "_blank" + }, + { + "identifier": "app4", + "display_name": "Dateien", + "icon_url": "http://localhost:8080/opendesk/app4.svg", + "link": "https://example.com/ap4", + "target": "_blank" + }, + { + "identifier": "app5", + "display_name": "E-Mail", + "icon_url": "http://localhost:8080/opendesk/app5.svg", + "link": "https://example.com/email", + "target": "_blank" + }, + { + "identifier": "app6", + "display_name": "Kelender", + "icon_url": "http://localhost:8080/opendesk/app6.svg", + "link": "https://example.com/app6", + "target": "_blank" + }, + { + "identifier": "app7", + "display_name": "Kontakte", + "icon_url": "http://localhost:8080/opendesk/app7.svg", + "link": "https://example.com/app7", + "target": "_blank" + }, + { + "identifier": "app8", + "display_name": "Notizen", + "icon_url": "http://localhost:8080/opendesk/app8.svg", + "link": "https://example.com/app8", + "target": "_blank" + }, + { + "identifier": "app9", + "display_name": "Projekte", + "icon_url": "http://localhost:8080/opendesk/app9.svg", + "link": "https://example.com/app9", + "target": "_blank" + }, + { + "identifier": "app10", + "display_name": "Videokonferenz", + "icon_url": "http://localhost:8080/opendesk/app10.svg", + "link": "https://example.com/app10", + "target": "_blank" + }, + { + "identifier": "app11", + "display_name": "Wiki", + "icon_url": "http://localhost:8080/opendesk/app11.svg", + "link": "https://example.com/app11", + "target": "_blank" + } + ] + } + ] +} diff --git a/modules/banner/e2e/fixture/opendesk/app1.svg b/modules/banner/e2e/fixture/opendesk/app1.svg new file mode 100644 index 0000000000..758e28621f --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app1.svg @@ -0,0 +1,3 @@ + + + diff --git a/modules/banner/e2e/fixture/opendesk/app10.svg b/modules/banner/e2e/fixture/opendesk/app10.svg new file mode 100644 index 0000000000..147057229c --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app10.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app11.svg b/modules/banner/e2e/fixture/opendesk/app11.svg new file mode 100644 index 0000000000..d853d42473 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app11.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app2.svg b/modules/banner/e2e/fixture/opendesk/app2.svg new file mode 100644 index 0000000000..a537b52199 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app3.svg b/modules/banner/e2e/fixture/opendesk/app3.svg new file mode 100644 index 0000000000..90f21f0667 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app4.svg b/modules/banner/e2e/fixture/opendesk/app4.svg new file mode 100644 index 0000000000..6c78f1bf58 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app5.svg b/modules/banner/e2e/fixture/opendesk/app5.svg new file mode 100644 index 0000000000..90d15399f3 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app5.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app6.svg b/modules/banner/e2e/fixture/opendesk/app6.svg new file mode 100644 index 0000000000..dbdba6b547 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app6.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app7.svg b/modules/banner/e2e/fixture/opendesk/app7.svg new file mode 100644 index 0000000000..e88e54cbfe --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app7.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app8.svg b/modules/banner/e2e/fixture/opendesk/app8.svg new file mode 100644 index 0000000000..1658f680ec --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app8.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/opendesk/app9.svg b/modules/banner/e2e/fixture/opendesk/app9.svg new file mode 100644 index 0000000000..590bc7dc27 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/app9.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/banner/e2e/fixture/opendesk/logofull.svg b/modules/banner/e2e/fixture/opendesk/logofull.svg new file mode 100644 index 0000000000..eaadffcfe3 --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/logofull.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/banner/e2e/fixture/opendesk/logomark.svg b/modules/banner/e2e/fixture/opendesk/logomark.svg new file mode 100644 index 0000000000..d23f0ec64c --- /dev/null +++ b/modules/banner/e2e/fixture/opendesk/logomark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/e2e/fixture/silent/index.html b/modules/banner/e2e/fixture/silent/index.html new file mode 100644 index 0000000000..969c920d77 --- /dev/null +++ b/modules/banner/e2e/fixture/silent/index.html @@ -0,0 +1,9 @@ + + + + + + Univention Silent Login + + + diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml new file mode 100644 index 0000000000..602fa03a65 --- /dev/null +++ b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml @@ -0,0 +1,6 @@ +- navigation: + - button "Show menu": + - img + - link "Show portal": + - /url: https://example.com/portal + - img "Portal logo" diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml new file mode 100644 index 0000000000..3ce946d830 --- /dev/null +++ b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml @@ -0,0 +1,13 @@ +- dialog "Portal logo Close menu": + - heading "Portal logo Close menu" [level=2]: + - img "Portal logo" + - button "Close menu": + - img + - heading "Applications" [level=2] + - link "E-Mail": + - /url: https://example.com/email + - link "Riot": + - /url: https://riot.im/app + - heading "Links" [level=2] + - link "Link": + - /url: https://example.com/link1 diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml new file mode 100644 index 0000000000..602fa03a65 --- /dev/null +++ b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml @@ -0,0 +1,6 @@ +- navigation: + - button "Show menu": + - img + - link "Show portal": + - /url: https://example.com/portal + - img "Portal logo" diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml new file mode 100644 index 0000000000..47446acc17 --- /dev/null +++ b/modules/banner/e2e/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml @@ -0,0 +1,28 @@ +- dialog "Portal logo Close menu": + - heading "Portal logo Close menu" [level=2]: + - img "Portal logo" + - button "Close menu": + - img + - heading "Anwendungen" [level=2] + - link "Portal": + - /url: https://example.com/app1 + - link "Aufgaben": + - /url: https://example.com/app2 + - link "Chat": + - /url: https://example.com/app3 + - link "Dateien": + - /url: https://example.com/ap4 + - link "E-Mail": + - /url: https://example.com/email + - link "Kelender": + - /url: https://example.com/app6 + - link "Kontakte": + - /url: https://example.com/app7 + - link "Notizen": + - /url: https://example.com/app8 + - link "Projekte": + - /url: https://example.com/app9 + - link "Videokonferenz": + - /url: https://example.com/app10 + - link "Wiki": + - /url: https://example.com/app11 diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/static-menu-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/static-menu-linux.png new file mode 100644 index 0000000000..81982d60c0 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/static-menu-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-hover-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-hover-linux.png new file mode 100644 index 0000000000..dfd945eff1 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-hover-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-linux.png new file mode 100644 index 0000000000..3f0e72f4e7 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/static-nav-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/univention-error-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/univention-error-linux.png new file mode 100644 index 0000000000..141dc9ece0 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/univention-error-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-linux.png new file mode 100644 index 0000000000..90f47fc897 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-loading-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-loading-linux.png new file mode 100644 index 0000000000..b6ad990619 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/univention-menu-loading-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-hover-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-hover-linux.png new file mode 100644 index 0000000000..01bf1563e1 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-hover-linux.png differ diff --git a/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-linux.png b/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-linux.png new file mode 100644 index 0000000000..691c7e6093 Binary files /dev/null and b/modules/banner/e2e/snapshots/banner.spec.ts/univention-nav-linux.png differ diff --git a/modules/banner/package.json b/modules/banner/package.json new file mode 100644 index 0000000000..ae038ca28c --- /dev/null +++ b/modules/banner/package.json @@ -0,0 +1,41 @@ +{ + "name": "@element-hq/element-web-module-banner", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "license": "SEE LICENSE IN README.md", + "scripts": { + "build": "vite build", + "lint": "pnpm lint:types && pnpm lint:js", + "lint:types": "tsc --noEmit", + "lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs", + "test:playwright": "playwright test -c ../playwright.config.ts", + "test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui", + "test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot" + }, + "devDependencies": { + "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", + "@element-hq/element-web-module-api": "workspace:*", + "@types/node": "^22.10.7", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "matrix-web-i18n": "^3.6.0", + "matrix-widget-api": "^1.17.0", + "react": "catalog:", + "rollup-plugin-external-globals": "^0.13.0", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-node-polyfills": "catalog:", + "vite-plugin-svgr": "catalog:" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "@vector-im/compound-design-tokens": "^10.0.0", + "@vector-im/compound-web": "^9.0.0", + "framer-motion": "^12.4.10", + "styled-components": "^6.1.18", + "zod": "^4.0.0" + } +} diff --git a/modules/banner/src/Banner.tsx b/modules/banner/src/Banner.tsx new file mode 100644 index 0000000000..112470d4e8 --- /dev/null +++ b/modules/banner/src/Banner.tsx @@ -0,0 +1,69 @@ +/* +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 FC } from "react"; +import styled from "styled-components"; +import { type Api } from "@element-hq/element-web-module-api"; +import { Heading } from "@vector-im/compound-web"; + +import { type ModuleConfig } from "./config"; +import UniventionMenu from "./Univention/Menu"; +import Menu from "./Menu"; +import Logo from "./Logo.tsx"; + +const Root = styled.nav` + height: ${({ theme }): string => theme.bannerHeight}; + background-color: ${({ theme }): string => theme.bannerBackgroundColor}; + border-bottom: "var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary)"; + display: flex; + gap: var(--cpd-space-3x); + + h1 { + align-self: center; + } +`; + +const LogoContainer = styled.div` + display: flex; + padding: var(--cpd-space-3x) 0; +`; + +interface Props { + api: Api; + logoUrl: string; + href: string; + menu: ModuleConfig["menu"]; + title: string; +} + +const Banner: FC = ({ api, logoUrl, href, menu, title }) => { + let menuJsx; + switch (menu.type) { + case "static": { + menuJsx = ; + break; + } + case "univention": { + menuJsx = ; + break; + } + } + + return ( + + {menuJsx} + + + + + {title} + + + ); +}; + +export default Banner; diff --git a/modules/banner/src/Logo.tsx b/modules/banner/src/Logo.tsx new file mode 100644 index 0000000000..485f5afbbf --- /dev/null +++ b/modules/banner/src/Logo.tsx @@ -0,0 +1,42 @@ +/* +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 FC } from "react"; +import styled from "styled-components"; +import { type Api } from "@element-hq/element-web-module-api"; + +const Anchor = styled.a` + display: flex; +`; + +const Image = styled.img<{ + height?: string; +}>` + align-self: center; + height: ${({ height }): string => height ?? "32px"}; +`; + +interface Props { + api: Api; + src: string; + height?: string; + href?: string; +} + +const Logo: FC = ({ api, src, href, height }) => { + const img = {api.i18n.translate("logo_alt")}; + + if (!href) return img; + + return ( + + {img} + + ); +}; + +export default Logo; diff --git a/modules/banner/src/Menu.tsx b/modules/banner/src/Menu.tsx new file mode 100644 index 0000000000..b984c1b58b --- /dev/null +++ b/modules/banner/src/Menu.tsx @@ -0,0 +1,266 @@ +/* +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 FC, type JSX, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import * as Dialog from "@radix-ui/react-dialog"; +import styled, { useTheme } from "styled-components"; +import { InlineSpinner } from "@vector-im/compound-web"; +import { type Api } from "@element-hq/element-web-module-api"; + +import { type StaticConfig } from "./config"; +import Logo from "./Logo.tsx"; +import TriggerIcon from "./trigger.svg?react"; + +const Sidebar = styled(motion.div)` + padding: 0 var(--cpd-space-3x) var(--cpd-space-4x); + box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1); + overflow: auto; + position: fixed; + left: 0; + top: 0; + height: 100%; + width: ${({ theme }): string => theme.menuWidth}; + background: ${({ theme }): string => theme.menuBackgroundColor}; + border-radius: 0 16px 16px 0; + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + font-feature-settings: normal; +`; + +const SidebarHeading = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--cpd-space-5x); +`; + +const Trigger = styled.button` + align-items: center; + border: none; + cursor: pointer; + display: flex; + background-color: ${({ theme }): string => theme.triggerBackgroundColor}; + color: ${({ theme }): string => theme.triggerColor}; + width: ${({ theme }): string => theme.triggerWidth}; + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.triggerBackgroundColorHover}; + color: ${({ theme }): string => theme.triggerColorContrast}; + } + + &:active, + &[data-expanded="true"] { + background-color: ${({ theme }): string => theme.triggerBackgroundColorPressed}; + color: ${({ theme }): string => theme.triggerColorContrast}; + } + + svg { + margin: auto; + } +`; + +const CloseButton = styled.button` + /* Reset button styles */ + appearance: none; + background: none; + border: none; + padding: 0; + margin: 0; + + height: 32px; + width: 32px; + cursor: pointer; + border-radius: 8px; + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; + } + + &:active { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed}; + } +`; + +const CategoryHeading = styled.h2` + line-height: var(--cpd-font-line-height-tight); + letter-spacing: var(--cpd-font-letter-spacing-heading-lg); + font-weight: 700; + font-size: 12px; + color: ${({ theme }): string => theme.subheadingColor}; + margin-top: var(--cpd-space-4x); + margin-bottom: var(--cpd-space-2x); +`; + +const LinkButton = styled.a` + font-size: 14px; + color: var(--cpd-color-text-action-primary); + font-weight: var(--cpd-font-weight-medium); + display: flex; + border-radius: 8px; + padding: var(--cpd-space-2x); + align-items: center; + + &:link { + color: var(--cpd-color-text-action-primary); + } + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; + } + + &:active { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed}; + } +`; + +const LinkLogo = styled.img` + height: 24px; + width: 24px; + border-radius: 4px; + border: ${({ theme }): string => `var(--cpd-border-width-1) solid ${theme.menuButtonBackgroundColorPressed}`}; + background-color: ${({ theme }): string => theme.menuBackgroundColor}; + margin-right: var(--cpd-space-2x); +`; + +const CentredContainer = styled.div` + display: flex; + height: 100%; + width: 100%; + align-items: center; + text-align: center; + font-weight: var(--cpd-font-weight-semibold); + + svg { + margin: 0 auto; + } +`; + +const Overlay = styled(motion.div)` + background-color: rgba(238, 239, 242, 0.5); + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +`; + +interface Props { + api: Api; + config: StaticConfig | Error | null; // null for loading + fallbackLogoUrl: string; +} + +const Category: FC<{ + data: StaticConfig["categories"][number]; +}> = ({ data }) => { + return ( +
+ {data.name} + {data.links.map((link) => ( + + {link.name} + + ))} +
+ ); +}; + +const Menu: FC = ({ api, config, fallbackLogoUrl }) => { + const theme = useTheme(); + const width = parseInt(theme.menuWidth.slice(0, -2), 10); + const [open, setOpen] = useState(false); + + let content: JSX.Element; + let logoUrl = fallbackLogoUrl; + if (config instanceof Error) { + content = {api.i18n.translate("univention_error")}; + } else if (config) { + content = ( + <> + {config.categories.map((category) => ( + + ))} + + ); + if (config.logo_url) { + logoUrl = config.logo_url; + } + } else { + content = ( + + + + ); + } + + return ( + + + + + + + + + {open && ( + + + + + + + + + + + setOpen(false)} + > + + + + + + + + {content} + + + + )} + + + ); +}; + +export default Menu; diff --git a/modules/banner/src/Univention/Menu.tsx b/modules/banner/src/Univention/Menu.tsx new file mode 100644 index 0000000000..c1b9ecc89c --- /dev/null +++ b/modules/banner/src/Univention/Menu.tsx @@ -0,0 +1,54 @@ +/* +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 FC, useEffect, useState } from "react"; +import { type Api } from "@element-hq/element-web-module-api"; + +import { type StaticConfig, type UniventionConfig } from "../config"; +import StaticMenu from "../Menu"; +import { fetchNavigation } from "./navigation"; +import SilentLogin from "./SilentLogin"; + +interface Props { + api: Api; + config: UniventionConfig; + fallbackLogoUrl: string; +} + +const Menu: FC = ({ api, config, fallbackLogoUrl }) => { + const [loggedIn, setLoggedIn] = useState(false); + const [data, setData] = useState(); + const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en"; + + useEffect(() => { + let discard = false; + + setData(undefined); + fetchNavigation(config.ics_url, language) + .then((data) => { + if (discard) return; + setData(data); + }) + .catch((error) => { + if (discard) return; + setData(error); + }); + + return (): void => { + discard = true; + }; + }, [config, language, loggedIn]); + + return ( + <> + {!loggedIn && } + + + ); +}; + +export default Menu; diff --git a/modules/banner/src/Univention/SilentLogin.tsx b/modules/banner/src/Univention/SilentLogin.tsx new file mode 100644 index 0000000000..cc2a0f7e1b --- /dev/null +++ b/modules/banner/src/Univention/SilentLogin.tsx @@ -0,0 +1,40 @@ +/* +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 FC, useEffect } from "react"; +import styled from "styled-components"; + +const HiddenIFrame = styled.iframe` + display: none; +`; + +interface Props { + icsUrl: string; + onLoggedIn(success: boolean): void; +} + +const SilentLogin: FC = ({ onLoggedIn, icsUrl }) => { + const url = new URL("silent", icsUrl); + + useEffect(() => { + const listener = (event: MessageEvent): void => { + if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) { + onLoggedIn(true); + } + }; + + window.addEventListener("message", listener); + + return (): void => { + window.removeEventListener("message", listener); + }; + }, [onLoggedIn, url.origin]); + + return ; +}; + +export default SilentLogin; diff --git a/modules/banner/src/Univention/navigation.ts b/modules/banner/src/Univention/navigation.ts new file mode 100644 index 0000000000..9bc413aa8a --- /dev/null +++ b/modules/banner/src/Univention/navigation.ts @@ -0,0 +1,86 @@ +/* +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 { z } from "zod/mini"; + +import { type StaticConfig } from "../config"; + +/** + * Univention Central Navigation API + * https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html + */ +const UniventionCentralNavigation = z.object({ + categories: z.array( + z.object({ + identifier: z.string(), + display_name: z.string(), + entries: z.array( + z.object({ + /** + * A unique identifier for the navigation item. + */ + identifier: z.string(), + /** + * The URL to the icon in SVG format. + */ + icon_url: z.string(), + /** + * The label of the link. + */ + display_name: z.string(), + /** + * The destination URL of the link. + */ + link: z.string(), + /** + * The browsing context in which the browser opens the link. + * Corresponds to the `target` property of `` tags in HTML. + */ + target: z.string(), + /** + * It’s usually empty. + */ + keywords: z.optional(z.object({})), + }), + ), + }), + ), +}); + +type UniventionCentralNavigation = z.infer; + +function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig { + return { + type: "static", + categories: navigation.categories.map((category) => ({ + name: category.display_name, + links: category.entries.map((entry) => ({ + icon_uri: entry.icon_url, + name: entry.display_name, + link_url: entry.link, + target: entry.target, + })), + })), + }; +} + +export async function fetchNavigation(icsUrl: string, language: string): Promise { + const url = new URL("navigation.json", icsUrl); + url.search = `?language=${language}`; + + const response = await fetch(url, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch navigation: ${response.status}`); + } + + const data = await response.json(); + const config = await UniventionCentralNavigation.parseAsync(data); + return navigationToConfig(config); +} diff --git a/modules/banner/src/config.ts b/modules/banner/src/config.ts new file mode 100644 index 0000000000..678ed0360a --- /dev/null +++ b/modules/banner/src/config.ts @@ -0,0 +1,120 @@ +/* +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 { z, type ZodMiniType, type input } from "zod/mini"; + +import { Theme } from "./theme.ts"; + +z.config(z.locales.en()); + +const StaticConfig = z.object({ + type: z.literal("static"), + + /** + * Alternative logo URL to display in the popover menu. + * Will use the main logo url if omitted. + */ + logo_url: z.optional(z.url()), + + /** + * Categories of links to display in the menu. + */ + categories: z.array( + z.object({ + /** + * The category display name + */ + name: z.string(), + /** + * List of links to display in the category + */ + links: z.array( + z.object({ + /** + * The URL to the icon. + */ + icon_uri: z.url(), + /** + * The label of the link. + */ + name: z.string(), + /** + * The destination URL of the link. + */ + link_url: z.url(), + /** + * The browsing context in which the browser opens the link. + */ + target: z.optional(z.string()), + }), + ), + }), + ), +}); + +export type StaticConfig = z.infer; + +const UniventionConfig = z.object({ + type: z.literal("univention"), + + /** + * Alternative logo URL to display in the popover menu. + * Will use the main logo url if omitted. + */ + logo_url: z.optional(z.url()), + + /** + * Base URL to an Intercom Service + * https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints + */ + ics_url: z.url(), +}); + +export type UniventionConfig = z.infer; + +export const ModuleConfig = z.object({ + /** + * The URL of the portal logo.svg file. + * @example `https://example.com/logo.svg` + */ + logo_url: z.url(), + + /** + * The URL of the portal. + * @example `https://example.com` + */ + logo_link_url: z.url(), + + /** + * The title to show to the right of the Logo + * + * Will fall back to `brand` variable if undefined, can be hidden by setting to the empty string. + */ + title: z.optional(z.string()), + + /** + * Configuration for the menu. + */ + menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]), + + /** + * Theme variable overrides, optional. + */ + theme: z.prefault(Theme, {}), +}); + +export type ModuleConfig = z.infer; + +export type ConfigSchema = ZodMiniType, z.input>; + +export const CONFIG_KEY = "io.element.element-web-modules.banner"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: input; + } +} diff --git a/modules/banner/src/index.tsx b/modules/banner/src/index.tsx new file mode 100644 index 0000000000..66859118a1 --- /dev/null +++ b/modules/banner/src/index.tsx @@ -0,0 +1,57 @@ +/* +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 { ThemeProvider } from "styled-components"; +import compound from "@vector-im/compound-web/dist/style.css" with { type: "css" }; + +import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api"; +import Translations from "./translations.json"; +import { ModuleConfig, CONFIG_KEY } from "./config"; +import Banner from "./Banner"; +import { name as ModuleName } from "../package.json"; +import style from "./style.css" with { type: "css" }; + +class BannerModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + + private config?: ModuleConfig; + + public constructor(private api: Api) {} + + public async load(): Promise { + document.adoptedStyleSheets.push(compound); + document.adoptedStyleSheets.push(style); + + this.api.i18n.register(Translations); + + try { + this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY)); + } catch (e) { + console.error("Failed to init module", e); + throw new Error(`Errors in module configuration for "${ModuleName}"`); + } + + const div = document.createElement("div"); + div.dataset.testid = "banner"; + this.api.rootNode.before(div); + + const root = this.api.createRoot(div); + root.render( + + + , + ); + } +} + +export default BannerModule satisfies ModuleFactory; diff --git a/modules/banner/src/style.css b/modules/banner/src/style.css new file mode 100644 index 0000000000..6e573485f5 --- /dev/null +++ b/modules/banner/src/style.css @@ -0,0 +1,15 @@ +/* +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. +*/ + +/* Styles to ensure the banner does not push the app out of the viewport */ +body { + display: flex; + flex-direction: column; +} +#matrixchat { + flex: 1; +} diff --git a/modules/banner/src/theme.ts b/modules/banner/src/theme.ts new file mode 100644 index 0000000000..5a13f27b9f --- /dev/null +++ b/modules/banner/src/theme.ts @@ -0,0 +1,32 @@ +/* +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 { z } from "zod/mini"; + +export const Theme = z.object({ + textColor: z.prefault(z.string(), "var(--cpd-color-text-primary)"), + subheadingColor: z.prefault(z.string(), "var(--cpd-color-text-secondary)"), + bannerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"), + bannerHeight: z.prefault(z.string(), "60px"), + triggerWidth: z.prefault(z.string(), "68px"), + triggerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-subtle-secondary)"), + triggerBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-accent-hovered)"), + triggerBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-accent-pressed)"), + triggerColor: z.prefault(z.string(), "var(--cpd-color-icon-primary)"), + triggerColorContrast: z.prefault(z.string(), "var(--cpd-color-icon-on-solid-primary)"), + menuWidth: z.prefault(z.string(), "320px"), + menuBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"), + menuButtonBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-hovered)"), + menuButtonBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-pressed)"), +}); + +export type Theme = z.infer; + +declare module "styled-components" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface DefaultTheme extends Theme {} +} diff --git a/modules/banner/src/translations.json b/modules/banner/src/translations.json new file mode 100644 index 0000000000..be2627e3aa --- /dev/null +++ b/modules/banner/src/translations.json @@ -0,0 +1,26 @@ +{ + "logo_alt": { + "en": "Portal logo", + "de": "Portal Logo" + }, + "menu_label": { + "en": "Menu", + "de": "Menü" + }, + "trigger_label": { + "en": "Show menu", + "de": "Menü anzeigen" + }, + "close_label": { + "en": "Close menu", + "de": "Menü schließen" + }, + "logo_link_label": { + "en": "Show portal", + "de": "Portal anzeigen" + }, + "univention_error": { + "en": "Failed to load data from Univention", + "de": "Daten konnten nicht von Univention geladen werden" + } +} diff --git a/modules/banner/src/trigger.svg b/modules/banner/src/trigger.svg new file mode 100644 index 0000000000..72273ad287 --- /dev/null +++ b/modules/banner/src/trigger.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/modules/banner/src/vite-env.d.ts b/modules/banner/src/vite-env.d.ts new file mode 100644 index 0000000000..bfeaf6b7af --- /dev/null +++ b/modules/banner/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/* +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. +*/ + +/// +/// diff --git a/modules/banner/tsconfig.json b/modules/banner/tsconfig.json new file mode 100644 index 0000000000..ada1a94c2a --- /dev/null +++ b/modules/banner/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/banner/vite.config.ts b/modules/banner/vite.config.ts new file mode 100644 index 0000000000..e2ee02eaa2 --- /dev/null +++ b/modules/banner/vite.config.ts @@ -0,0 +1,62 @@ +/* +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 { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, esmExternalRequirePlugin } 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-banner", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + rolldownOptions: { + plugins: [ + esmExternalRequirePlugin({ + external: ["react"], + }), + ], + output: { + globals: { + // Reuse React from the host app + react: "window.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'", + "process": { env: { NODE_ENV: "production" } }, + }, +}); diff --git a/modules/playwright.config.ts b/modules/playwright.config.ts new file mode 100644 index 0000000000..3eab206c06 --- /dev/null +++ b/modules/playwright.config.ts @@ -0,0 +1,104 @@ +/* +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 { defineConfig, devices, type Project } from "@playwright/test"; +import fs, { globSync } from "node:fs"; +import path from "node:path"; + +import type { Options } from "./playwright/element-web-test.ts"; + +const chromeProject = { + ...devices["Desktop Chrome"], + channel: "chromium", + permissions: ["clipboard-write", "clipboard-read", "microphone"], + launchOptions: { + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"], + }, + connectOptions: process.env.PW_TEST_CONNECT_WS_ENDPOINT + ? { + wsEndpoint: process.env.PW_TEST_CONNECT_WS_ENDPOINT, + exposeNetwork: "", + } + : undefined, +}; + +/** +We assume that all modules will have the following directory structure: + +└── modules/ + └── my-module/ + ├── e2e/ + │ ├── test-1.spec.ts + │ └── test-2.spec.ts + └── package.json + +The following code maps each such module (my-module in the example above) to a separate +playwright project. + */ +const projects: Project[] = []; + +// Get all the directories that hold playwright tests +const moduleTestDirectories = globSync("modules/*/e2e", {}); + +// Process each directory +for (const testDirectory of moduleTestDirectories) { + // Based on the directory structure, the parent directory of the test directory holds package.json. + const moduleDirectory = path.join(testDirectory, ".."); + + // Get module name from package.json + const packageJson = JSON.parse(fs.readFileSync(path.join(moduleDirectory, "package.json"), "utf-8")); + const MODULE_PREFIX = "@element-hq/element-web-module-"; + const name = packageJson.name.startsWith(MODULE_PREFIX) + ? packageJson.name.slice(MODULE_PREFIX.length) + : packageJson.name; + + // Create playwright project + projects.push({ + name, + use: { + ...chromeProject, + moduleDir: moduleDirectory, + }, + testDir: testDirectory, + snapshotDir: `${testDirectory}/snapshots`, + outputDir: `${testDirectory}/_results`, + }); +} + +const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080"; + +export default defineConfig({ + projects, + use: { + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + video: "retain-on-failure", + baseURL, + trace: "on-first-retry", + }, + webServer: { + command: process.env.WEBAPP_PATH + ? `npx serve -p 8080 -L ${process.env.WEBAPP_PATH}` + : "docker run --rm -p 8080:80 ghcr.io/element-hq/element-web:develop", + url: `${baseURL}/config.json`, + reuseExistingServer: true, + timeout: (process.env.CI ? 30 : 120) * 1000, + }, + workers: 1, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI + ? [ + ["list"], + ["html"], + ["github"], + ["@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js"], + ] + : [["list"], ["html", { outputFolder: "playwright-html-report" }]], + // When running the browser in docker, set the platform to `linux` as that is the platform where the browser is running + snapshotPathTemplate: `{snapshotDir}/{testFilePath}/{arg}-${process.env.PW_TEST_CONNECT_WS_ENDPOINT ? "linux" : "{platform}"}{ext}`, + forbidOnly: !!process.env.CI, +}); diff --git a/modules/playwright/element-web-test.ts b/modules/playwright/element-web-test.ts new file mode 100644 index 0000000000..ca8ca83f7e --- /dev/null +++ b/modules/playwright/element-web-test.ts @@ -0,0 +1,35 @@ +/* +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 { test as base, expect, type TestFixtures } from "@element-hq/element-web-playwright-common"; + +export interface Options { + moduleDir: string; + modules: string[]; +} + +export const test = base.extend({ + moduleDir: ["", { option: true }], + modules: async ({ moduleDir }, use) => { + await use([`${moduleDir}/lib/index.js`]); + }, + + page: async ({ page, modules, config }, use) => { + config.modules = []; + for (let i = 0; i < modules.length; i++) { + const module = `/modules/module-${i}/index.js`; + await page.route(module, async (route) => { + await route.fulfill({ path: modules[i] }); + }); + config.modules.push(module); + } + + await use(page); + }, +}); + +export { expect }; diff --git a/modules/restricted-guests/README.md b/modules/restricted-guests/README.md new file mode 100644 index 0000000000..3a925d018d --- /dev/null +++ b/modules/restricted-guests/README.md @@ -0,0 +1,22 @@ +# @element-hq/element-web-module-restricted-guests + +Restricted Guests module for Element Web. + +Supports the following configuration options under the configuration key `io.element.element-web-modules.restricted-guests`: + +| Key | Type | Description | +| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| guest_user_homeserver_url | string | URL of the homeserver on which to register the guest, must be running the synapse module. | +| guest_user_prefix | string | Prefix to apply to all guests registered via the module, defaults to `@guest-`. | +| skip_single_sign_on | boolean | If true, the user will be forwarded to the login page instead of to the SSO login. This is only required if the home server has no SSO support. | + +## Copyright & License + +Copyright (c) 2025 New Vector Ltd + +This software is multi licensed by New Vector Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/modules/restricted-guests/e2e/restricted-guests.spec.ts b/modules/restricted-guests/e2e/restricted-guests.spec.ts new file mode 100644 index 0000000000..a3717e3ac1 --- /dev/null +++ b/modules/restricted-guests/e2e/restricted-guests.spec.ts @@ -0,0 +1,304 @@ +/* +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 MasConfig, + type StartedMatrixAuthenticationServiceContainer, + type StartedSynapseContainer, + type SynapseContainer, +} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; +import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api"; +import { makePostgres, makeMas } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; + +import { RestrictedGuestsSynapseContainer, RestrictedGuestsSynapseWithMasContainer } from "./services"; +import { test as subBase, expect } from "../../playwright/element-web-test"; + +const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const MAS_CLIENT_SECRET = "restricted-guests-secret"; +const MAS_SHARED_SECRET = "restricted-guests-shared-secret"; +const MAS_INTERNAL_URL = "http://guest-mas:8080"; +const GUEST_HOMESERVER_NAME = "guest-homeserver"; + +const MAS_HTTP_LISTENERS: NonNullable["listeners"] = [ + { + name: "web", + resources: [ + { name: "discovery" }, + { name: "human" }, + { name: "oauth" }, + { name: "compat" }, + { name: "graphql" }, + { name: "assets" }, + { name: "adminapi" }, + ], + binds: [ + { + address: "[::]:8080", + }, + ], + proxy_protocol: false, + }, + { + name: "internal", + resources: [ + { + name: "health", + }, + ], + binds: [ + { + address: "[::]:8081", + }, + ], + proxy_protocol: false, + }, +]; + +const BASE_MAS_CONFIG: Partial = { + http: { + listeners: MAS_HTTP_LISTENERS, + public_base: "", + }, + policy: { + data: { + admin_clients: [MAS_CLIENT_ID], + client_registration: { + allow_insecure_uris: true, + }, + }, + }, + clients: [ + { + client_id: MAS_CLIENT_ID, + client_auth_method: "client_secret_basic", + client_secret: MAS_CLIENT_SECRET, + }, + ], +}; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + embedded_pages?: { + login_for_welcome?: boolean; + }; + } +} + +// We do some wacky things here in order to run the test suite against multiple homeserver configurations +const base = subBase.extend< + { + testRoomId: string; + }, + { + auth: "mas" | "legacy"; + + bot: Credentials; + guestMas?: StartedMatrixAuthenticationServiceContainer; + guestHomeserver: StartedSynapseContainer; + } +>({ + testRoomId: [ + async ({ homeserver, bot }, use) => { + const { room_id: roomId } = (await homeserver.csApi.request("POST", "/v3/createRoom", bot.accessToken, { + name: "Test room", + preset: "public_chat", + topic: "All about happy hour", + initial_state: [ + { + // This is required to allow guests to join the room with this Synapse module + type: "m.room.join_rule", + state_key: "", + content: { join_rule: "knock" }, + }, + ], + })) as { room_id: string }; + await use(roomId); + }, + { scope: "test" }, + ], + bot: [ + async ({ homeserver }, use) => { + const bot = await homeserver.registerUser("bot", "pAs5w0rD!", "Bot"); + await use(bot); + }, + { scope: "worker" }, + ], + + auth: ["mas", { scope: "worker" }], + // Optional MAS on the default homeserver, enabled only when we are testing the non-guest login UX + mas: [ + async ({ logger, network, postgres, auth, synapseConfig }, use) => { + if (auth !== "mas" || synapseConfig.allow_guest_access !== false) { + return use(undefined); + } + const container = await makeMas( + postgres, + network, + logger, + { + ...BASE_MAS_CONFIG, + matrix: { + kind: "synapse", + homeserver: "homeserver", + endpoint: "http://homeserver:8008", + secret: MAS_SHARED_SECRET, + }, + }, + "mas", + ); + await use(container); + await container.stop(); + }, + { scope: "worker" }, + ], + // Optional MAS on the module homeserver + guestMas: [ + async ({ logger, network, auth }, use) => { + if (auth !== "mas") { + return use(undefined); + } + + // We need a separate postgres so it doesn't fight with the default MAS + const postgres = await makePostgres(network, logger, "guest-mas-postgres"); + + const container = await makeMas( + postgres, + network, + logger, + { + ...BASE_MAS_CONFIG, + matrix: { + kind: "synapse", + homeserver: GUEST_HOMESERVER_NAME, + endpoint: "http://guest-homeserver:8008", + secret: MAS_SHARED_SECRET, + }, + }, + "guest-mas", + ); + await use(container); + await container.stop(); + await postgres.stop(); + }, + { scope: "worker" }, + ], + // Module homeserver + guestHomeserver: [ + async ({ logger, synapseConfig, network, guestMas }, use) => { + let container: SynapseContainer; + if (guestMas) { + container = new RestrictedGuestsSynapseWithMasContainer({ + adminApiBaseUrl: MAS_INTERNAL_URL, + oauthBaseUrl: MAS_INTERNAL_URL, + clientId: MAS_CLIENT_ID, + clientSecret: MAS_CLIENT_SECRET, + }).withMatrixAuthenticationService(guestMas); + } else { + container = new RestrictedGuestsSynapseContainer(); + } + + const startedContainer = await container + .withConfig(synapseConfig) + .withConfig({ + server_name: GUEST_HOMESERVER_NAME, + }) + .withNetwork(network) + .withNetworkAliases(GUEST_HOMESERVER_NAME) + .withLogConsumer(logger.getConsumer("guest_homeserver")) + .start(); + + await use(startedContainer); + await startedContainer.stop(); + }, + { scope: "worker" }, + ], + displayName: "Tommy", + labsFlags: ["feature_ask_to_join"], + config: { + embedded_pages: { + login_for_welcome: true, + }, + }, +}); + +base.slow(); +for (const auth of ["mas", "legacy"] as const) { + for (const guestsEnabled of [true, false]) { + const test = base.extend({ + auth, + synapseConfig: { + allow_guest_access: guestsEnabled, + }, + }); + + test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => { + test("should error if config is missing", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + await expect(page.getByText("Errors in module configuration")).toBeVisible(); + }); + + test.describe("with config", () => { + test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => { + config["io.element.element-web-modules.restricted-guests"] = { + guest_user_homeserver_url: guestHomeserver.baseUrl, + }; + // Go to a room we are not a member of + await page.goto(`/#/room/${testRoomId}`); + }); + + if (guestsEnabled) { + // The screenshots between the two auth type tests for guests should be identical. + test( + "should show the default room preview bar for logged in users", + { tag: ["@screenshot"] }, + async ({ page, user, testRoomId }) => { + // Go to a room we are not a member of + await page.goto(`/#/room/${testRoomId}`); + const button = page.getByRole("button", { name: "Join the discussion" }); + await expect(button).toBeVisible(); + }, + ); + + test( + "should show the module's room preview bar for guests", + { tag: ["@screenshot"] }, + async ({ page }) => { + const button = page.getByRole("button", { name: "Join as guest", exact: true }); + await expect(button).toBeVisible(); + await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); + + await button.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toMatchScreenshot(`dialog.png`); + + await dialog.getByPlaceholder("Name").fill("Jim"); + await dialog.getByRole("button", { name: "Continue as guest" }).click(); + + await expect(page.getByText("Ask to join?")).toBeVisible(); + }, + ); + } else { + test("should show the module login ux", { tag: ["@screenshot"] }, async ({ page }) => { + const button = page.getByRole("button", { name: "Join as guest", exact: true }); + await expect(button).toBeVisible(); + await expect(page.getByRole("main")).toMatchScreenshot(`login-${auth}.png`); + + await button.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toMatchScreenshot(`dialog-login.png`); + + await dialog.getByPlaceholder("Name").fill("Jim"); + await dialog.getByRole("button", { name: "Continue as guest" }).click(); + + await expect(page.getByText("Join the discussion")).toBeVisible(); + }); + } + }); + }); + } +} diff --git a/modules/restricted-guests/e2e/services.ts b/modules/restricted-guests/e2e/services.ts new file mode 100644 index 0000000000..8122e671b3 --- /dev/null +++ b/modules/restricted-guests/e2e/services.ts @@ -0,0 +1,63 @@ +/* +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 { + StartedSynapseContainer, + SynapseContainer, +} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; +import path, { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// We use the SynapseContainer as a base to have all of its utilities for config setting +export class RestrictedGuestsSynapseContainer extends SynapseContainer { + protected getModuleConfig(): Record { + return {}; + } + + public override async start(): Promise { + this.withCopyDirectoriesToContainer([ + { + source: path.resolve(__dirname, "..", "..", "synapse", "synapse_guest_module"), + target: "/modules/synapse_guest_module/", + }, + ]).withEnvironment({ + PYTHONPATH: "/modules", + }); + this.config.modules.push({ + module: "synapse_guest_module.GuestModule", + config: this.getModuleConfig(), + }); + + return super.start(); + } +} + +interface RestrictedGuestsMasModuleConfig { + adminApiBaseUrl: string; + oauthBaseUrl?: string; + clientId: string; + clientSecret: string; +} + +export class RestrictedGuestsSynapseWithMasContainer extends RestrictedGuestsSynapseContainer { + public constructor(private readonly masConfig: RestrictedGuestsMasModuleConfig) { + super(); + } + + protected override getModuleConfig(): Record { + return { + mas: { + admin_api_base_url: this.masConfig.adminApiBaseUrl, + oauth_base_url: this.masConfig.oauthBaseUrl ?? this.masConfig.adminApiBaseUrl, + client_id: this.masConfig.clientId, + client_secret: this.masConfig.clientSecret, + }, + }; + } +} diff --git a/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png new file mode 100644 index 0000000000..2f8c5f8ca3 Binary files /dev/null and b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png differ diff --git a/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png new file mode 100644 index 0000000000..336427c537 Binary files /dev/null and b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/dialog-login-linux.png differ diff --git a/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png new file mode 100644 index 0000000000..e958aa4ea9 Binary files /dev/null and b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png differ diff --git a/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png new file mode 100644 index 0000000000..9d5fe7fd60 Binary files /dev/null and b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png differ diff --git a/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png new file mode 100644 index 0000000000..55ae944415 Binary files /dev/null and b/modules/restricted-guests/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png differ diff --git a/modules/restricted-guests/package.json b/modules/restricted-guests/package.json new file mode 100644 index 0000000000..8a87b5a343 --- /dev/null +++ b/modules/restricted-guests/package.json @@ -0,0 +1,35 @@ +{ + "name": "@element-hq/element-web-module-restricted-guests", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "license": "SEE LICENSE IN README.md", + "scripts": { + "build": "vite build", + "lint": "pnpm lint:types && pnpm lint:js", + "lint:types": "tsc --noEmit", + "lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs", + "test:playwright": "playwright test -c ../playwright.config.ts", + "test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui", + "test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot" + }, + "devDependencies": { + "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", + "@element-hq/element-web-module-api": "workspace:*", + "@types/node": "^22.10.7", + "@types/react": "catalog:", + "@vitejs/plugin-react": "catalog:", + "react": "catalog:", + "rollup-plugin-external-globals": "^0.13.0", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-node-polyfills": "catalog:" + }, + "dependencies": { + "@vector-im/compound-design-tokens": "^10.0.0", + "@vector-im/compound-web": "^9.0.0", + "styled-components": "^6.1.18", + "zod": "^4.0.0" + } +} diff --git a/modules/restricted-guests/src/AuthFooter.tsx b/modules/restricted-guests/src/AuthFooter.tsx new file mode 100644 index 0000000000..5ff6b11851 --- /dev/null +++ b/modules/restricted-guests/src/AuthFooter.tsx @@ -0,0 +1,59 @@ +/* +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 FC } from "react"; +import { Button } from "@vector-im/compound-web"; +import { type AccountAuthInfo, type Api } from "@element-hq/element-web-module-api"; +import styled from "styled-components"; + +import { type ModuleConfig } from "./config.ts"; +import RegisterDialog from "./RegisterDialog.tsx"; + +interface Props { + api: Api; + config: ModuleConfig; + onLoggedIn(data: AccountAuthInfo): void; +} + +const Container = styled.aside` + margin: var(--cpd-space-3x) 0; + + button { + width: 100%; + } +`; + +const AuthFooter: FC = ({ api, config, onLoggedIn }) => { + const onTryJoin = async (): Promise => { + const { finished } = api.openDialog( + { + title: api.i18n.translate("register_dialog_title"), + }, + RegisterDialog, + { + api, + config, + }, + ); + + const { model: accountAuthInfo, ok } = await finished; + + if (ok && accountAuthInfo) { + onLoggedIn(accountAuthInfo); + } + }; + + return ( + + + + ); +}; + +export default AuthFooter; diff --git a/modules/restricted-guests/src/RegisterDialog.tsx b/modules/restricted-guests/src/RegisterDialog.tsx new file mode 100644 index 0000000000..1b445df251 --- /dev/null +++ b/modules/restricted-guests/src/RegisterDialog.tsx @@ -0,0 +1,99 @@ +/* +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 FC, useState, type JSX, type FormEvent } from "react"; +import { type Api, type AccountAuthInfo, type DialogProps } from "@element-hq/element-web-module-api"; +import { Form } from "@vector-im/compound-web"; +import styled from "styled-components"; + +import { type ModuleConfig } from "./config.ts"; + +interface RegisterDialogProps extends DialogProps { + api: Api; + config: ModuleConfig; + showLoginLink?: boolean; +} + +const enum State { + Idle, + Busy, + Error, +} + +const StyledFormRoot = styled(Form.Root)` + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + font-feature-settings: normal; +`; + +const RegisterDialog: FC = ({ api, config, onCancel, onSubmit, showLoginLink }) => { + const [username, setUsername] = useState(""); + const [state, setState] = useState(State.Idle); + + async function trySubmit(ev: FormEvent): Promise { + ev.preventDefault(); + setState(State.Busy); + + try { + const homeserverUrl = config.guest_user_homeserver_url; + + const url = new URL("/_synapse/client/register_guest", homeserverUrl); + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayname: username }), + }); + + if (response.ok) { + const accountAuthInfo = await response.json(); + onSubmit(accountAuthInfo); + } + } catch (e) { + console.error("Failed to create guest account", e); + setState(State.Error); + } + } + + let message: JSX.Element | undefined; + if (state === State.Error) { + message = {api.i18n.translate("register_dialog_error")}; + } else if (state === State.Busy) { + message = {api.i18n.translate("register_dialog_busy")}; + } + + const disabled = state !== State.Idle; + + return ( + + + {api.i18n.translate("register_dialog_register_username_label")} + { + setUsername(event.currentTarget.value); + }} + placeholder={api.i18n.translate("register_dialog_field_label")} + /> + {message} + + + {showLoginLink && ( + + {api.i18n.translate("register_dialog_existing_account")} + + )} + + + {api.i18n.translate("register_dialog_continue_label")} + + + ); +}; + +export default RegisterDialog; diff --git a/modules/restricted-guests/src/RoomPreviewBar.tsx b/modules/restricted-guests/src/RoomPreviewBar.tsx new file mode 100644 index 0000000000..bda49e57c5 --- /dev/null +++ b/modules/restricted-guests/src/RoomPreviewBar.tsx @@ -0,0 +1,70 @@ +/* +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 FC, type JSX } from "react"; +import { Button } from "@vector-im/compound-web"; +import { type Api } from "@element-hq/element-web-module-api"; +import styled from "styled-components"; +import { useWatchable } from "@element-hq/element-web-module-api"; + +import { type ModuleConfig } from "./config.ts"; +import RegisterDialog from "./RegisterDialog.tsx"; + +interface RoomPreviewBarProps { + api: Api; + config: ModuleConfig; + children: JSX.Element; + roomId?: string; + roomAlias?: string; + promptAskToJoin?: boolean; +} + +const Container = styled.aside` + margin: auto; + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + font-feature-settings: normal; +`; + +const RoomPreviewBar: FC = ({ api, config, roomId, roomAlias, promptAskToJoin, children }) => { + const profile = useWatchable(api.profile); + const isGuest = profile.isGuest; + + if (promptAskToJoin || !isGuest || !(roomId || roomAlias)) return children; + + const onTryJoin = async (): Promise => { + const { finished } = api.openDialog( + { + title: api.i18n.translate("register_dialog_title"), + }, + RegisterDialog, + { + api, + config, + showLoginLink: true, + }, + ); + + const { model: accountAuthInfo, ok } = await finished; + + if (ok && accountAuthInfo) { + await api.overwriteAccountAuth(accountAuthInfo); + await api.navigation.toMatrixToLink(`https://matrix.to/#/${roomId ?? roomAlias}`, true); + } + }; + + return ( + +
{api.i18n.translate("join_message")}
+
+ +
+
+ ); +}; + +export default RoomPreviewBar; diff --git a/modules/restricted-guests/src/config.ts b/modules/restricted-guests/src/config.ts new file mode 100644 index 0000000000..766a6571bb --- /dev/null +++ b/modules/restricted-guests/src/config.ts @@ -0,0 +1,47 @@ +/* +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 { z, type ZodMiniType, type input } from "zod/mini"; + +z.config(z.locales.en()); + +export const ModuleConfig = z.object({ + /** + * The URL of the homeserver where the guest users should be registered. This + * must have the `synapse-restricted-guests-module` installed. + * @example `https://synapse.local` + */ + guest_user_homeserver_url: z.url(), + + /** + * The username prefix that identifies guest users. + * @defaultValue `@guest-` + */ + guest_user_prefix: z._default(z.string().check(z.regex(/@[a-zA-Z-_1-9]+/)), "@guest-"), + + /** + * If true, the user will be forwarded to the login page instead of to the SSO + * login. This is only required if the home server has no SSO support. + * @defaultValue `false` + */ + skip_single_sign_on: z._default(z.boolean(), false), +}); + +export type ModuleConfig = z.infer; + +type ConfigSchema = ZodMiniType, z.input>; + +export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: input; + sso_redirect_options?: { + immediate?: boolean; // incompatible option + }; + } +} diff --git a/modules/restricted-guests/src/index.tsx b/modules/restricted-guests/src/index.tsx new file mode 100644 index 0000000000..d11e4c0691 --- /dev/null +++ b/modules/restricted-guests/src/index.tsx @@ -0,0 +1,83 @@ +/* +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 compound from "@vector-im/compound-web/dist/style.css" with { type: "css" }; + +import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api"; +import Translations from "./translations.json"; +import { ModuleConfig, CONFIG_KEY } from "./config"; +import { name as ModuleName } from "../package.json"; +import RoomPreviewBar from "./RoomPreviewBar.tsx"; +import AuthFooter from "./AuthFooter.tsx"; + +const GUEST_INVISIBLE_COMPONENTS = [ + "UIComponent.sendInvites", + "UIComponent.roomCreation", + "UIComponent.spaceCreation", + "UIComponent.exploreRooms", + "UIComponent.roomOptionsMenu", + "UIComponent.addIntegrations", +]; + +class RestrictedGuestsModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + + private config?: ModuleConfig; + + public constructor(private api: Api) {} + + public async load(): Promise { + document.adoptedStyleSheets.push(compound); + + this.api.i18n.register(Translations); + + try { + this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY)); + } catch (e) { + console.error("Failed to init module", e); + throw new Error(`Errors in module configuration for "${ModuleName}"`); + } + + const appConfig = this.api.config.get(); + if (appConfig.sso_redirect_options?.immediate) { + console.warn(`${ModuleName} found incompatible option 'sso_redirect_options.immediate', turning it off.`); + appConfig.sso_redirect_options.immediate = false; + } + + // Room preview bar customisations (for Matrix guest support) + this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => ( + + + + )); + this.api.customisations.registerShouldShowComponent(this.shouldShowComponent); + + // Login component customisations (for no guest support) + this.api.customComponents.registerLoginComponent((props, OriginalComponent) => ( + + + + )); + } + + /** + * Returns true, if the `userId` should see the `component`. + * + * @param component - the name of the component that is checked + * @returns true, if the user should see the component + */ + public readonly shouldShowComponent = (component: string): boolean => { + const profile = this.api.profile.value; + if (this.config && (profile.isGuest || profile.userId?.startsWith(this.config.guest_user_prefix))) { + return GUEST_INVISIBLE_COMPONENTS.includes(component); + } + + return true; + }; +} + +export default RestrictedGuestsModule satisfies ModuleFactory; diff --git a/modules/restricted-guests/src/translations.json b/modules/restricted-guests/src/translations.json new file mode 100644 index 0000000000..315a1e938d --- /dev/null +++ b/modules/restricted-guests/src/translations.json @@ -0,0 +1,38 @@ +{ + "register_dialog_register_username_label": { + "en": "Username", + "de": "Benutzername" + }, + "register_dialog_title": { + "en": "Request access", + "de": "Zugriff anfordern" + }, + "register_dialog_busy": { + "en": "Creating your account...", + "de": "Erstelle dein Konto..." + }, + "register_dialog_continue_label": { + "en": "Continue as guest", + "de": "Als Gast fortfahren" + }, + "register_dialog_field_label": { + "en": "Name", + "de": "Name" + }, + "register_dialog_existing_account": { + "en": "I already have an account.", + "de": "Ich habe bereits einen Account." + }, + "register_dialog_error": { + "en": "The account creation failed.", + "de": "Die Anmeldung als Gastnutzer ist fehlgeschlagen." + }, + "join_message": { + "en": "Join the room to participate", + "de": "Treten Sie dem Raum bei, um teilzunehmen" + }, + "join_cta": { + "en": "Join as guest", + "de": "Als Gast beitreten" + } +} diff --git a/modules/restricted-guests/src/vite-env.d.ts b/modules/restricted-guests/src/vite-env.d.ts new file mode 100644 index 0000000000..c718659504 --- /dev/null +++ b/modules/restricted-guests/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/* +Copyright 2025 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. +*/ + +/// diff --git a/modules/restricted-guests/tsconfig.json b/modules/restricted-guests/tsconfig.json new file mode 100644 index 0000000000..ada1a94c2a --- /dev/null +++ b/modules/restricted-guests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/restricted-guests/vite.config.ts b/modules/restricted-guests/vite.config.ts new file mode 100644 index 0000000000..9dfdb5dbed --- /dev/null +++ b/modules/restricted-guests/vite.config.ts @@ -0,0 +1,60 @@ +/* +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 { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, esmExternalRequirePlugin } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import externalGlobals from "rollup-plugin-external-globals"; +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-restricted-guests", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + rolldownOptions: { + plugins: [ + esmExternalRequirePlugin({ + external: ["react"], + }), + ], + output: { + globals: { + // Reuse React from the host app + react: "window.React", + }, + }, + }, + }, + plugins: [ + importCSSSheet(), + react(), + 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'", + "process": { env: { NODE_ENV: "production" } }, + }, +}); diff --git a/modules/tsconfig.json b/modules/tsconfig.json new file mode 100644 index 0000000000..d1b893ecd8 --- /dev/null +++ b/modules/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "es2024", + "jsx": "react", + "lib": ["ESNext", "es2024", "dom", "dom.iterable"], + "types": ["node"], + "resolveJsonModule": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "module": "ESNext", + "allowImportingTsExtensions": true, + "skipLibCheck": true + }, + "include": ["playwright.config.ts", "playwright"] +} diff --git a/modules/widget-lifecycle/README.md b/modules/widget-lifecycle/README.md new file mode 100644 index 0000000000..cfe2569e4e --- /dev/null +++ b/modules/widget-lifecycle/README.md @@ -0,0 +1,63 @@ +# @element-hq/element-web-module-widget-lifecycle + +Widget lifecycle module for Element Web. + +Supports the following configuration options under the configuration key `io.element.element-web-modules.widget-lifecycle`: + +| Key | Type | Description | +| ------------------ | ------ | ------------------------------------------------ | +| widget_permissions | object | Map of widget URL patterns to approval settings. | + +Each widget configuration can use the following options: + +- `preload_approved` - if true, the preload dialog is not displayed for this widget. +- `identity_approved` - if true, requests for an identity token are automatically accepted. +- `capabilities_approved` - a list of capabilities that should be approved for this widget. + +The widget URL and capability strings can use a trailing `*` to match multiple widgets or capabilities. +This is useful when widgets have multiple routes or capabilities include variable state keys. + +## Matching and precedence + +- Patterns ending in `*` are matched by prefix; other patterns must match exactly. +- If multiple rules match, the most specific match wins per field. +- The capabilities allow-list is not merged across rules; the most specific rule that defines it wins. + +## Example configuration (exact match) + +```json +{ + "io.element.element-web-modules.widget-lifecycle": { + "widget_permissions": { + "https://widget.example.com/": { + "preload_approved": true, + "identity_approved": true, + "capabilities_approved": [ + "org.matrix.msc2931.navigate", + "org.matrix.msc2762.receive.state_event:m.room.power_levels" + ] + } + } + } +} +``` + +## Example configuration (wildcards) + +```json +{ + "io.element.element-web-modules.widget-lifecycle": { + "widget_permissions": { + "https://widget.example.com/*": { + "preload_approved": true, + "identity_approved": true, + "capabilities_approved": [ + "org.matrix.msc2931.navigate", + "org.matrix.msc2762.receive.state_event:m.room.power_levels", + "org.matrix.msc2762.send.state_event:net.custom_event#*" + ] + } + } + } +} +``` diff --git a/modules/widget-lifecycle/e2e/fixture/widget.html b/modules/widget-lifecycle/e2e/fixture/widget.html new file mode 100644 index 0000000000..4c293c5c6d --- /dev/null +++ b/modules/widget-lifecycle/e2e/fixture/widget.html @@ -0,0 +1,76 @@ + + + + + Demo Widget + + + +

Hello unknown!

+ + diff --git a/modules/widget-lifecycle/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/e2e/widget-lifecycle.spec.ts new file mode 100644 index 0000000000..be91d8bcf1 --- /dev/null +++ b/modules/widget-lifecycle/e2e/widget-lifecycle.spec.ts @@ -0,0 +1,243 @@ +/* +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 SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; +import { rejectToastIfExists } from "@element-hq/element-web-playwright-common"; + +import { test, expect } from "../../playwright/element-web-test.ts"; + +declare module "@element-hq/element-web-module-api" { + interface Config { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: { + [url: string]: { + preload_approved?: boolean; + identity_approved?: boolean; + capabilities_approved?: string[]; + }; + }; + }; + } +} + +const WIDGET_URL = "http://localhost:8080/widget.html"; + +test.use({ + displayName: "Timmy", + synapseConfig: async ({ synapseConfig, _homeserver: homeserver }, use) => { + (homeserver as SynapseContainer).withConfigField("listeners[0].resources[0].names", ["client", "openid"]); + await use(synapseConfig); + }, + page: async ({ context, page, moduleDir }, use) => { + await context.route(`${WIDGET_URL}*`, async (route) => { + await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" }); + }); + + await page.goto("/"); + await use(page); + }, + bypassCSP: true, + launchOptions: { + args: ["--disable-web-security"], + }, +}); + +test.describe("Widget Lifecycle", () => { + test.describe("trusted widgets", () => { + // Configure the module to pre-approve the widget URL for preloading, identity tokens, + // and the m.room.topic state event capability. + test.use({ + config: { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: { + [`${WIDGET_URL}*`]: { + preload_approved: true, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2762.receive.state_event:m.room.topic"], + }, + }, + }, + }, + }); + + test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => { + rejectToastIfExists(page, "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. + const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "POST", + "/v3/createRoom", + bot.accessToken, + { + name: "Trusted Widget", + }, + ); + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Trusted Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Trusted Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + // No preload dialog should appear — the widget loads immediately. + await expect(page.getByRole("button", { name: "Continue" })).not.toBeVisible(); + + // The widget greets the user by ID, proving the identity token was also auto-approved + // and passed to the widget without any consent prompts. + await expect( + page + .frameLocator('iframe[title="Trusted Widget"]') + .getByRole("heading", { name: `Hello ${user.userId}!` }), + ).toBeVisible(); + }); + + test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver }, testInfo) => { + rejectToastIfExists(page, "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", + "/v3/createRoom", + bot.accessToken, + { + name: "Capabilities Widget", + }, + ); + // The widget requests two capabilities: m.room.topic (in the allowlist) and m.room.name (not in the allowlist). + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Capabilities Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic,org.matrix.msc2762.receive.state_event:m.room.name`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Capabilities Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + // A capabilities approval dialog should appear since m.room.name was not pre-approved. + await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); + }); + }); + + test.describe("untrusted widgets", () => { + // No widget URLs are pre-approved, so all lifecycle prompts should be shown to the user. + test.use({ + config: { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: {}, + }, + }, + }); + + test("shows preload, capabilities, and OpenID dialogs for untrusted widgets", async ({ + page, + user, + homeserver, + }, testInfo) => { + rejectToastIfExists(page, "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", + "/v3/createRoom", + bot.accessToken, + { + name: "Untrusted Widget", + }, + ); + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Untrusted Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Untrusted Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + // 1. Preload consent dialog — shown because the widget was added by another user (the bot). + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + // 2. Capabilities dialog — the widget requests m.room.topic once it loads. + await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); + await page.getByRole("button", { name: "Approve" }).click(); + + // 3. OpenID identity dialog — the widget requests an identity token after capabilities are granted. + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + }); + }); +}); diff --git a/modules/widget-lifecycle/package.json b/modules/widget-lifecycle/package.json new file mode 100644 index 0000000000..534af61d9e --- /dev/null +++ b/modules/widget-lifecycle/package.json @@ -0,0 +1,28 @@ +{ + "name": "@element-hq/element-web-module-widget-lifecycle", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "license": "SEE LICENSE IN README.md", + "scripts": { + "build": "vite build", + "lint": "pnpm lint:types && pnpm lint:js", + "lint:types": "tsc --noEmit", + "lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs", + "test:playwright": "playwright test -c ../playwright.config.ts", + "test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui", + "test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "workspace:*", + "@types/node": "^22.10.7", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "zod": "^4.0.0" + } +} diff --git a/modules/widget-lifecycle/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts new file mode 100644 index 0000000000..6ad15e08bd --- /dev/null +++ b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts @@ -0,0 +1,81 @@ +/* +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 { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api"; +import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config"; +import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; +import { matchPattern } from "./utils/matchPattern"; + +/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ +export type WidgetLifecycleApiAdapter = Pick< + WidgetLifecycleApi, + "registerPreloadApprover" | "registerIdentityApprover" | "registerCapabilitiesApprover" +>; + +type ModuleApi = Pick; + +/** + * Module that auto-approves widget preloading, identity token requests, and capability + * requests based on URL-pattern rules defined in config.json. + */ +export default class WidgetLifecycleModule implements Module { + public static readonly moduleApiVersion = "^1.10.0"; + + private config: WidgetLifecycleModuleConfig = {}; + + public constructor(private api: ModuleApi) {} + + public async load(): Promise { + if (!this.api.widgetLifecycle) { + throw new Error( + "Widget lifecycle API is not available. Update Element Web to a build that provides widget lifecycle module support.", + ); + } + + try { + this.config = parseWidgetLifecycleConfig(this.api.config.get(CONFIG_KEY)); + } catch (error) { + console.error("[WidgetLifecycle] Failed to init module", error); + this.config = {}; + } + + this.api.widgetLifecycle.registerPreloadApprover((widget) => this.preapprovePreload(widget)); + this.api.widgetLifecycle.registerIdentityApprover((widget) => this.preapproveIdentity(widget)); + this.api.widgetLifecycle.registerCapabilitiesApprover((widget, requested) => + this.preapproveCapabilities(widget, requested), + ); + } + + private preapprovePreload(widget: WidgetDescriptor): boolean { + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); + return configuration.preload_approved === true; + } + + private preapproveIdentity(widget: WidgetDescriptor): boolean { + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); + return configuration.identity_approved === true; + } + + private preapproveCapabilities( + widget: WidgetDescriptor, + requestedCapabilities: Set, + ): Set | undefined { + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); + const capabilitiesApproved = configuration.capabilities_approved; + + if (!capabilitiesApproved) return undefined; + + const approvedCapabilities = new Set(); + for (const requestedCapability of requestedCapabilities) { + if (capabilitiesApproved.some((capability) => matchPattern(requestedCapability, capability))) { + approvedCapabilities.add(requestedCapability); + } + } + + return approvedCapabilities.size > 0 ? approvedCapabilities : undefined; + } +} diff --git a/modules/widget-lifecycle/src/config.ts b/modules/widget-lifecycle/src/config.ts new file mode 100644 index 0000000000..c2e5b9166c --- /dev/null +++ b/modules/widget-lifecycle/src/config.ts @@ -0,0 +1,54 @@ +/* +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 } from "zod/mini"; + +z.config(z.locales.en()); + +/** The config.json key under which widget lifecycle module configuration is stored. */ +export const CONFIG_KEY = "io.element.element-web-modules.widget-lifecycle"; + +const WidgetConfigurationSchema = z.partial( + z.looseObject({ + preload_approved: z.boolean(), + identity_approved: z.boolean(), + capabilities_approved: z.array(z.string().check(z.minLength(1))), + }), +); + +/** Per-widget approval settings: preload, identity, and capabilities. */ +export type WidgetConfiguration = z.infer; + +const ModuleConfigSchema = z.partial( + z.looseObject({ + widget_permissions: z.record(z.string(), WidgetConfigurationSchema), + }), +); + +/** Map from URL patterns to their widget approval configuration. */ +export type WidgetLifecycleModuleConfig = Record; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: z.input; + } +} + +/** + * Parse and validate the widget lifecycle module configuration. + * Returns an empty config if the input is falsy; throws on schema violations. + */ +export const parseWidgetLifecycleConfig = (value: unknown): WidgetLifecycleModuleConfig => { + if (!value) return {}; + + const result = ModuleConfigSchema.safeParse(value); + if (!result.success) { + throw new Error(`Errors in the module configuration for "${CONFIG_KEY}": ${result.error.message}`); + } + + return result.data.widget_permissions ?? {}; +}; diff --git a/modules/widget-lifecycle/src/index.ts b/modules/widget-lifecycle/src/index.ts new file mode 100644 index 0000000000..7cff3faacc --- /dev/null +++ b/modules/widget-lifecycle/src/index.ts @@ -0,0 +1,11 @@ +/* +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 { ModuleFactory } from "@element-hq/element-web-module-api"; +import WidgetLifecycleModule from "./WidgetLifecycleModule"; + +export default WidgetLifecycleModule satisfies ModuleFactory; diff --git a/modules/widget-lifecycle/src/utils/constructWidgetPermissions.ts b/modules/widget-lifecycle/src/utils/constructWidgetPermissions.ts new file mode 100644 index 0000000000..07943c1b82 --- /dev/null +++ b/modules/widget-lifecycle/src/utils/constructWidgetPermissions.ts @@ -0,0 +1,28 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { WidgetLifecycleModuleConfig, WidgetConfiguration } from "../config"; +import { matchPattern } from "./matchPattern"; + +/** + * Returns the WidgetConfiguration for a widget. + * If multiple WidgetConfigurations match, the most specific match wins per field. + */ +export function constructWidgetPermissions( + config: WidgetLifecycleModuleConfig, + widgetUrl: string, +): WidgetConfiguration { + const widgetPermissionsMatched = Object.keys(config).filter((pattern) => matchPattern(widgetUrl, pattern)); + + return widgetPermissionsMatched.sort(sortLongestMatchLast).reduce((prev, key) => ({ ...prev, ...config[key] }), {}); +} + +/** Sort strings alphabetically so longer, more-specific patterns are applied last. */ +export function sortLongestMatchLast(a: string, b: string): number { + return a.localeCompare(b, "en", { sensitivity: "base" }); +} diff --git a/modules/widget-lifecycle/src/utils/matchPattern.ts b/modules/widget-lifecycle/src/utils/matchPattern.ts new file mode 100644 index 0000000000..8271649291 --- /dev/null +++ b/modules/widget-lifecycle/src/utils/matchPattern.ts @@ -0,0 +1,14 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Checks if string matches pattern. Pattern can end with '*' to support prefix matching. + */ +export function matchPattern(value: string, pattern: string): boolean { + return pattern.endsWith("*") ? value.startsWith(pattern.slice(0, pattern.length - 1)) : value === pattern; +} diff --git a/modules/widget-lifecycle/tests/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/tests/WidgetLifecycleModule.test.ts new file mode 100644 index 0000000000..d513a083f9 --- /dev/null +++ b/modules/widget-lifecycle/tests/WidgetLifecycleModule.test.ts @@ -0,0 +1,140 @@ +/* +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, it } from "vitest"; + +import WidgetLifecycleModule, { type WidgetLifecycleApiAdapter } from "../src/WidgetLifecycleModule"; +import type { + CapabilitiesApprover, + IdentityApprover, + PreloadApprover, + WidgetDescriptor, +} from "@element-hq/element-web-module-api"; + +const createApi = (config: unknown = {}) => { + const handlers: { + preload?: PreloadApprover; + identity?: IdentityApprover; + capabilities?: CapabilitiesApprover; + } = {}; + + const widgetLifecycle: WidgetLifecycleApiAdapter = { + registerPreloadApprover: (approver) => { + handlers.preload = approver; + }, + registerIdentityApprover: (approver) => { + handlers.identity = approver; + }, + registerCapabilitiesApprover: (approver) => { + handlers.capabilities = approver; + }, + }; + + return { + api: { + config: { + get: () => config, + }, + widgetLifecycle, + }, + handlers, + }; +}; + +const widget: WidgetDescriptor = { + id: "widget-id", + templateUrl: "https://example.com/", + creatorUserId: "@user-id", + kind: "room", +}; + +describe("WidgetLifecycleModule", () => { + it("approves preload when configured", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { preload_approved: true }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(true); + }); + + it("approves identity when configured", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { identity_approved: true }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.identity?.(widget)).toBe(true); + }); + + it("approves configured capabilities", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { + capabilities_approved: ["org.matrix.msc2931.navigate"], + }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + const approved = await handlers.capabilities?.( + widget, + new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]), + ); + + expect(approved).toEqual(new Set(["org.matrix.msc2931.navigate"])); + }); + + it("returns no approvals when not configured", async () => { + const { api, handlers } = createApi({}); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); + + it("returns no approvals when config is missing", async () => { + const { api, handlers } = createApi(undefined); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); + + it("fails closed on invalid config", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { + preload_approved: null, + }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); +}); diff --git a/modules/widget-lifecycle/tests/config.test.ts b/modules/widget-lifecycle/tests/config.test.ts new file mode 100644 index 0000000000..ed131db338 --- /dev/null +++ b/modules/widget-lifecycle/tests/config.test.ts @@ -0,0 +1,88 @@ +/* +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, it } from "vitest"; + +import { parseWidgetLifecycleConfig } from "../src/config"; + +describe("parseWidgetLifecycleConfig", () => { + it("accepts missing configuration", () => { + expect(parseWidgetLifecycleConfig(undefined)).toEqual({}); + }); + + it("accepts empty configuration", () => { + expect(parseWidgetLifecycleConfig({})).toEqual({}); + }); + + it("accepts valid configuration", () => { + expect( + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: [], + }, + }, + }), + ).toEqual({ + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: [], + }, + }); + }); + + it("accepts additional properties", () => { + expect( + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + additional: "tmp", + }, + }, + }), + ).toEqual({ + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + additional: "tmp", + }, + }); + }); + + it.each([ + { preload_approved: null }, + { preload_approved: 123 }, + { identity_approved: null }, + { identity_approved: 123 }, + { capabilities_approved: null }, + { capabilities_approved: 123 }, + { capabilities_approved: [undefined] }, + { capabilities_approved: [null] }, + { capabilities_approved: [123] }, + { capabilities_approved: [""] }, + ])("rejects invalid widget configuration %j", (patch) => { + expect(() => + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + ...patch, + }, + }, + }), + ).toThrow(); + }); +}); diff --git a/modules/widget-lifecycle/tests/constructWidgetPermissions.test.ts b/modules/widget-lifecycle/tests/constructWidgetPermissions.test.ts new file mode 100644 index 0000000000..25bc60660d --- /dev/null +++ b/modules/widget-lifecycle/tests/constructWidgetPermissions.test.ts @@ -0,0 +1,76 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +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, it } from "vitest"; + +import { constructWidgetPermissions, sortLongestMatchLast } from "../src/utils/constructWidgetPermissions"; + +describe("constructWidgetPermissions", () => { + it("finds exact match", () => { + expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/")).toEqual({ + preload_approved: true, + }); + }); + + it("finds prefix match", () => { + expect( + constructWidgetPermissions({ "https://a.com/*": { preload_approved: true } }, "https://a.com/some"), + ).toEqual({ preload_approved: true }); + }); + + it("merges multiple permissions", () => { + expect( + constructWidgetPermissions( + { + "https://b.com/path": { + preload_approved: false, + capabilities_approved: ["org.matrix.msc2762.timeline:*"], + }, + "https://b.com/*": { + preload_approved: true, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2931.navigate"], + }, + }, + "https://b.com/path", + ), + ).toEqual({ + preload_approved: false, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2762.timeline:*"], + }); + }); + + it("skips unknown url", () => { + expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/x")).toEqual( + {}, + ); + }); +}); + +describe("sortLongestMatchLast", () => { + it("sorts longest match last", () => { + expect( + [ + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "*", + ].sort(sortLongestMatchLast), + ).toEqual([ + "*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + ]); + }); +}); diff --git a/modules/widget-lifecycle/tests/matchPattern.test.ts b/modules/widget-lifecycle/tests/matchPattern.test.ts new file mode 100644 index 0000000000..0cc2c25c03 --- /dev/null +++ b/modules/widget-lifecycle/tests/matchPattern.test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +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, it } from "vitest"; + +import { matchPattern } from "../src/utils/matchPattern"; + +describe("matchPattern", () => { + it.each([ + "*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + "org.matrix.msc2762.receive.state_event:m.custom#state_key*", + ])("matches %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(true); + }); + + it.each([ + "org.matrix.msc2762.receive.state_event:", + "org.matrix.msc2762.receive.state_event:m.custom", + "org.matrix.msc2762.receive.state_event:m.custom#other_key", + "org.matrix.msc2762.receive.*:m.custom#state_key", + "org.matrix.msc2762.receive.room_event:m.custom", + ])("does not match %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(false); + }); +}); diff --git a/modules/widget-lifecycle/tsconfig.json b/modules/widget-lifecycle/tsconfig.json new file mode 100644 index 0000000000..25392e3cc8 --- /dev/null +++ b/modules/widget-lifecycle/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["src"] +} diff --git a/modules/widget-lifecycle/vite.config.ts b/modules/widget-lifecycle/vite.config.ts new file mode 100644 index 0000000000..66bd04e4e1 --- /dev/null +++ b/modules/widget-lifecycle/vite.config.ts @@ -0,0 +1,37 @@ +/* +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 "vitest/config"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.ts"), + name: "element-web-module-widget-lifecycle", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + minify: false, + }, + test: { + include: ["tests/**/*.test.ts"], + exclude: ["./e2e/**/*", "./node_modules/**/*"], + reporters: ["default"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + reporter: [["lcov", { projectRoot: "../../../" }], "text"], + }, + }, +}); 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/e2e/fixture/widget.html b/modules/widget-toggles/e2e/fixture/widget.html new file mode 100644 index 0000000000..c2c28e414e --- /dev/null +++ b/modules/widget-toggles/e2e/fixture/widget.html @@ -0,0 +1,5 @@ + + +

This is the content of the widget

+ + diff --git a/modules/widget-toggles/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png b/modules/widget-toggles/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/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png differ diff --git a/modules/widget-toggles/e2e/widget-toggles.spec.ts b/modules/widget-toggles/e2e/widget-toggles.spec.ts new file mode 100644 index 0000000000..d5b9245d7f --- /dev/null +++ b/modules/widget-toggles/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/package.json b/modules/widget-toggles/package.json new file mode 100644 index 0000000000..172f73135b --- /dev/null +++ b/modules/widget-toggles/package.json @@ -0,0 +1,44 @@ +{ + "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": { + "build": "vite build", + "lint": "pnpm lint:types && pnpm lint:js", + "lint:types": "tsc --noEmit", + "lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs", + "test": "vitest run --coverage", + "test:playwright": "playwright test -c ../playwright.config.ts", + "test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui", + "test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot" + }, + "devDependencies": { + "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", + "@element-hq/element-web-module-api": "workspace:*", + "@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": "catalog:", + "@vitest/browser-playwright": "catalog:", + "@vitest/coverage-v8": "catalog:", + "react": "catalog:", + "typescript": "catalog:", + "rollup-plugin-external-globals": "^0.13.0", + "vite": "catalog:", + "vite-plugin-node-polyfills": "catalog:", + "vite-plugin-svgr": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "@vector-im/compound-design-tokens": "^10.0.0", + "@vector-im/compound-web": "^9.0.0", + "matrix-widget-api": "^1.17.0", + "styled-components": "^6.3.11", + "zod": "^4.3.6" + } +} diff --git a/modules/widget-toggles/src/config.ts b/modules/widget-toggles/src/config.ts new file mode 100644 index 0000000000..54e2d075a4 --- /dev/null +++ b/modules/widget-toggles/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/src/index.tsx b/modules/widget-toggles/src/index.tsx new file mode 100644 index 0000000000..ec6542106a --- /dev/null +++ b/modules/widget-toggles/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/src/toggle.tsx b/modules/widget-toggles/src/toggle.tsx new file mode 100644 index 0000000000..741904c442 --- /dev/null +++ b/modules/widget-toggles/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/tests/config.test.ts b/modules/widget-toggles/tests/config.test.ts new file mode 100644 index 0000000000..7863ffadce --- /dev/null +++ b/modules/widget-toggles/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/tests/index.test.ts b/modules/widget-toggles/tests/index.test.ts new file mode 100644 index 0000000000..d1258e56a2 --- /dev/null +++ b/modules/widget-toggles/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/tests/mocks.ts b/modules/widget-toggles/tests/mocks.ts new file mode 100644 index 0000000000..e37cd67b01 --- /dev/null +++ b/modules/widget-toggles/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/tests/setupTests.ts b/modules/widget-toggles/tests/setupTests.ts new file mode 100644 index 0000000000..d7601a1499 --- /dev/null +++ b/modules/widget-toggles/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/tests/toggle.test.tsx b/modules/widget-toggles/tests/toggle.test.tsx new file mode 100644 index 0000000000..0a0e40449f --- /dev/null +++ b/modules/widget-toggles/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/tests/tsconfig.json b/modules/widget-toggles/tests/tsconfig.json new file mode 100644 index 0000000000..9ab45e2cd7 --- /dev/null +++ b/modules/widget-toggles/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["."] +} diff --git a/modules/widget-toggles/tsconfig.json b/modules/widget-toggles/tsconfig.json new file mode 100644 index 0000000000..ada1a94c2a --- /dev/null +++ b/modules/widget-toggles/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/widget-toggles/vite.config.ts b/modules/widget-toggles/vite.config.ts new file mode 100644 index 0000000000..af7dd703b9 --- /dev/null +++ b/modules/widget-toggles/vite.config.ts @@ -0,0 +1,81 @@ +/* +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 { esmExternalRequirePlugin } 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"; +import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; + +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, + rolldownOptions: { + plugins: [ + esmExternalRequirePlugin({ + external: ["react"], + }), + ], + output: { + globals: { + // Reuse React from the host app + react: "window.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'", + "process": { env: { NODE_ENV: "production" } }, + }, + test: { + include: ["tests/**/*.test.{ts,tsx}"], + exclude: ["./e2e/**/*", "./node_modules/**/*"], + reporters: ["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"], + }, +}); diff --git a/package.json b/package.json index b74c1433dc..967d43c316 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@action-validator/cli": "^0.6.0", "@action-validator/core": "^0.6.0", + "@element-hq/element-web-playwright-common": "workspace:*", "@nx-tools/nx-container": "^7.2.1", "@playwright/test": "catalog:", "@types/node": "22", diff --git a/patches/@arcmantle__vite-plugin-import-css-sheet.patch b/patches/@arcmantle__vite-plugin-import-css-sheet.patch new file mode 100644 index 0000000000..477223146d --- /dev/null +++ b/patches/@arcmantle__vite-plugin-import-css-sheet.patch @@ -0,0 +1,10 @@ +diff --git a/client.d.ts b/client.d.ts +index d884a7e4491d62031b7df17250957084c4b3f70e..bf2a78c177e14109c062d09cd35884470ef7ebf0 100644 +--- a/client.d.ts ++++ b/client.d.ts +@@ -1,3 +1,4 @@ + declare module '*.css' { +- export default styles as CSSStyleSheet; ++ const styles: CSSStyleSheet; ++ export default styles; + } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 718ae52740..0780d0f307 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ ignoreWorkspaceRootCheck: true packages: - "apps/*" - "packages/*" + - "modules/*" catalog: # typescript @@ -26,8 +27,13 @@ catalog: "@fontsource/inter": 5.2.8 # vite vite: 8.0.15 + vite-plugin-node-polyfills: 0.28.0 + vite-plugin-svgr: 5.2.0 vitest: 4.1.7 "@vitest/coverage-v8": 4.1.7 + "@vitest/browser-playwright": 4.1.7 + "@vitejs/plugin-react": 6.0.2 + unplugin-dts: 1.0.1 packageExtensions: fdir: @@ -88,6 +94,8 @@ patchedDependencies: plist: patches/plist.patch # Workaround for type fails "@dnd-kit/abstract": patches/@dnd-kit__abstract.patch + # Workaround for type fails + "@arcmantle/vite-plugin-import-css-sheet": patches/@arcmantle__vite-plugin-import-css-sheet.patch peerDependencyRules: allowedVersions: @@ -108,7 +116,7 @@ overrides: caniuse-lite: 1.0.30001793 markdown-it: 14.1.1 matrix-widget-api: "^1.17.0" - "@types/node": 18.19.130 + "@types/node": 22.13.14 config-file-ts: 0.2.8-rc1 node-abi: 4.31.0 "@types/pg-pool": 2.0.7