Skip to content

Commit 4d7cc4a

Browse files
authored
Merge pull request #220 from element-hq/dbkr/widget-toggles-module
Add widget toggles module
2 parents bff0652 + 9666869 commit 4d7cc4a

20 files changed

Lines changed: 1060 additions & 14 deletions

.github/workflows/tests.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ jobs:
2828
- name: Install Deps
2929
run: "yarn install --frozen-lockfile"
3030

31+
- name: Get installed Playwright version
32+
id: playwright
33+
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
34+
35+
- name: Cache playwright binaries
36+
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
37+
id: playwright-cache
38+
with:
39+
path: ~/.cache/ms-playwright
40+
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
41+
42+
- name: Install Playwright browsers
43+
if: steps.playwright-cache.outputs.cache-hit != 'true'
44+
run: "YARN_ENABLE_SCRIPTS=false yarn playwright install --with-deps --only-shell"
45+
3146
- name: Run tests
3247
run: yarn test
3348

modules/widget-toggles/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Widget Toggles Module
2+
3+
Adds room header buttons for widgets in the room.
4+
5+
This module needs to be configured to control what widget types get buttons added for them.
6+
The following config snippet enables the module and configures it to add buttons for both
7+
custom and jitsi widgets:
8+
9+
```
10+
"modules": [
11+
"/modules/widget-toggles/lib/index.js"
12+
],
13+
"io.element.element-web-modules.widget-toggles": {
14+
"types": ["m.custom", "jitsi"]
15+
}
16+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
<h1>This is the content of the widget</h1>
4+
</body>
5+
</html>
316 Bytes
Loading
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type Page } from "@playwright/test";
9+
import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
10+
import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
11+
import { type Container } from "@element-hq/element-web-module-api";
12+
13+
import { test as base, expect } from "../../../../playwright/element-web-test.ts";
14+
15+
const test = base.extend<{
16+
// Resolver for when to respond to the navigation.json request
17+
navigationJsonResolver: PromiseWithResolvers<void>;
18+
}>({
19+
navigationJsonResolver: async ({}, use) => {
20+
await use(Promise.withResolvers<void>());
21+
},
22+
});
23+
24+
const TEST_WIDGET_NAME = "Name of the test widget";
25+
26+
async function makeRoomWithWidgetAndGoTo(
27+
homeserver: StartedHomeserverContainer,
28+
user: Credentials,
29+
page: Page,
30+
avatarUrl?: string,
31+
): Promise<string> {
32+
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
33+
"POST",
34+
"/v3/createRoom",
35+
user.accessToken,
36+
{
37+
name: "Come on in we've got widgets",
38+
},
39+
);
40+
await homeserver.csApi.request<{
41+
event_id: string;
42+
}>("PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, user.accessToken, {
43+
id: "1",
44+
creatorUserId: user.userId,
45+
type: "m.custom",
46+
name: TEST_WIDGET_NAME,
47+
url: `http://localhost:8080/widget.html`,
48+
avatar_url: avatarUrl,
49+
});
50+
51+
await page.goto(`/#/room/${roomId}`);
52+
53+
return roomId;
54+
}
55+
56+
async function moveWidgetToContainer(
57+
homeserver: StartedHomeserverContainer,
58+
user: Credentials,
59+
roomId: string,
60+
container: Container,
61+
): Promise<void> {
62+
await homeserver.csApi.request(
63+
"PUT",
64+
`/v3/user/${encodeURIComponent(user.userId)}/rooms/${encodeURIComponent(roomId)}/account_data/im.vector.web.settings`,
65+
user.accessToken,
66+
{
67+
"Widgets.layout": {
68+
widgets: {
69+
"1": { container },
70+
},
71+
},
72+
},
73+
);
74+
}
75+
76+
test.describe("widget-toggles", () => {
77+
test.use({
78+
displayName: "Timmy",
79+
page: async ({ context, page, moduleDir }, use) => {
80+
await context.route("http://localhost:8080/widget.html*", async (route) => {
81+
await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" });
82+
});
83+
84+
await context.route("http://localhost:8080/wigeon.png", async (route) => {
85+
await route.fulfill({ path: `${moduleDir}/e2e/fixture/wigeon.png`, contentType: "image/png" });
86+
});
87+
88+
await page.goto("/");
89+
await use(page);
90+
},
91+
});
92+
93+
test("should error if config is missing", async ({ page }) => {
94+
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
95+
await expect(page.getByText("Errors in module configuration")).toBeVisible();
96+
// We don't take a screenshot as we don't want to assert Element's styling, only our own
97+
});
98+
99+
test.describe("with correct config", () => {
100+
test.use({
101+
config: {
102+
"io.element.element-web-modules.widget-toggles": {
103+
types: ["m.custom"],
104+
},
105+
},
106+
});
107+
108+
test(
109+
"should render 'show' button for widget not in top",
110+
{ tag: ["@screenshot"] },
111+
async ({ homeserver, page, user }) => {
112+
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
113+
114+
await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toBeVisible();
115+
},
116+
);
117+
118+
test(
119+
"should render 'hide' button for widget in top",
120+
{ tag: ["@screenshot"] },
121+
async ({ homeserver, page, user }) => {
122+
const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
123+
124+
await moveWidgetToContainer(homeserver, user, roomId, "top");
125+
126+
await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
127+
},
128+
);
129+
130+
test("should move widget to top when 'show' button is clicked", async ({ homeserver, page, user }) => {
131+
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
132+
133+
await page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).click();
134+
await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
135+
136+
await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
137+
});
138+
139+
test("should move widget to left when 'hide' button is clicked", async ({ homeserver, page, user }) => {
140+
const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
141+
await moveWidgetToContainer(homeserver, user, roomId, "top");
142+
143+
await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
144+
145+
await page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME }).click();
146+
147+
await expect(page.locator('iframe[title="Name of the test widget"]')).not.toBeVisible();
148+
});
149+
150+
test("uses widget icon for button image if present", async ({ homeserver, page, user }) => {
151+
await makeRoomWithWidgetAndGoTo(homeserver, user, page, "mxc://fakehomeserver/fake_content_id");
152+
153+
await expect(
154+
page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).getByRole("img"),
155+
).toHaveAttribute("src", /\/_matrix\/media\/v3\/download\/fakehomeserver\/fake_content_id$/);
156+
});
157+
158+
test(
159+
"uses built in icon for widgets with no avatar",
160+
{ tag: ["@screenshot"] },
161+
async ({ homeserver, page, user }) => {
162+
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
163+
164+
await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toMatchScreenshot(
165+
"widget-toggle-button-default-icon.png",
166+
);
167+
},
168+
);
169+
});
170+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "@element-hq/element-web-module-widget-toggle",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"main": "lib/index.js",
7+
"license": "SEE LICENSE IN README.md",
8+
"scripts": {
9+
"prepare": "vite build",
10+
"lint:types": "tsc --noEmit",
11+
"lint:codestyle": "echo 'handled by lint:eslint'",
12+
"test": "vitest run --coverage"
13+
},
14+
"devDependencies": {
15+
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
16+
"@element-hq/element-web-module-api": "^1.0.0",
17+
"@testing-library/dom": "^10.4.1",
18+
"@testing-library/react": "^16.3.2",
19+
"@testing-library/user-event": "^14.6.1",
20+
"@types/node": "^22.10.7",
21+
"@types/react": "^19",
22+
"@vitejs/plugin-react": "^5.0.0",
23+
"@vitest/browser-playwright": "4.0.18",
24+
"@vitest/coverage-v8": "^4.0.0",
25+
"react": "^19",
26+
"rollup-plugin-external-globals": "^0.13.0",
27+
"typescript": "^5.7.3",
28+
"vite": "^7.1.11",
29+
"vite-plugin-node-polyfills": "^0.25.0",
30+
"vite-plugin-svgr": "^4.3.0",
31+
"vitest": "^4.0.0",
32+
"vitest-sonar-reporter": "^2.0.0"
33+
},
34+
"dependencies": {
35+
"@vector-im/compound-design-tokens": "^6.0.0",
36+
"@vector-im/compound-web": "^8.0.0",
37+
"matrix-widget-api": "^1.17.0",
38+
"styled-components": "^6.3.11",
39+
"zod": "^4.3.6"
40+
}
41+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { z, type input } from "zod/mini";
9+
10+
z.config(z.locales.en());
11+
12+
export const WidgetTogglesConfig = z.object({
13+
/**
14+
* The widget types to show a toggle for.
15+
*/
16+
types: z.array(z.string()),
17+
});
18+
19+
export type WidgetTogglesConfig = z.infer<typeof WidgetTogglesConfig>;
20+
21+
export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles";
22+
23+
declare module "@element-hq/element-web-module-api" {
24+
export interface Config {
25+
[CONFIG_KEY]: input<WidgetTogglesConfig>;
26+
}
27+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type JSX } from "react";
9+
import { TooltipProvider } from "@vector-im/compound-web";
10+
11+
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
12+
import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
13+
import { WidgetToggle } from "./toggle";
14+
15+
class WidgetToggleModule implements Module {
16+
public static readonly moduleApiVersion = "^1.12.0";
17+
private config?: WidgetTogglesConfig;
18+
19+
public constructor(private api: Api) {}
20+
21+
public async load(): Promise<void> {
22+
try {
23+
this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY));
24+
} catch (e) {
25+
console.error("Failed to init module", e);
26+
throw new Error(`Errors in module configuration for widget toggles module`);
27+
}
28+
29+
this.api.extras.addRoomHeaderButtonCallback((roomId: string) => {
30+
const widgets = this.api.widget.getWidgetsInRoom(roomId);
31+
const toggleElements: JSX.Element[] = [];
32+
33+
for (const widget of widgets) {
34+
if (this.config?.types.includes(widget.type)) {
35+
toggleElements.push(
36+
<WidgetToggle
37+
app={widget}
38+
roomId={roomId}
39+
widgetApi={this.api.widget}
40+
i18nApi={this.api.i18n}
41+
/>,
42+
);
43+
}
44+
}
45+
46+
if (toggleElements.length === 0) return undefined;
47+
48+
// XXX: We shouldn't have to add another TooltipProvider here, it should
49+
// be using the one in MatrixChat in Element Web, but thanks to the fact
50+
// that contexts are "magically" identified by class, it doesn't pick up the
51+
// context because we use a different copy of compound-web. We'll probably
52+
// need to fix this at some point, possibly by moving compound's tooltip stuff
53+
// out to its own mini-module that can be provided at runtime by Element Web,
54+
// unless React make contexts more sensible.
55+
// Annoyingly this does actually cause the tooltips to behave a bit weirdly:
56+
// there's a delay before the tooltip appears when yo move between these buttons
57+
// and the rest of the header buttons, whereas moving between the other header
58+
// buttons, the tooltips appear straight away once one has appeared.
59+
return <TooltipProvider>{toggleElements}</TooltipProvider>;
60+
});
61+
}
62+
}
63+
64+
export default WidgetToggleModule satisfies ModuleFactory;

0 commit comments

Comments
 (0)