Skip to content

Commit 46e29df

Browse files
committed
Add widget toggles module
* API: #219 * Element Web API impl: element-hq/element-web#32734
1 parent 9ac6a8d commit 46e29df

9 files changed

Lines changed: 337 additions & 3 deletions

File tree

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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts
2+
index b65e3ec..9d1ba49 100644
3+
--- a/modules/widget-toggles/element-web/vite.config.ts
4+
+++ b/modules/widget-toggles/element-web/vite.config.ts
5+
@@ -28,7 +28,14 @@ export default defineConfig({
6+
target: "esnext",
7+
sourcemap: true,
8+
rollupOptions: {
9+
- external: ["react"],
10+
+ // make sure to externalize deps that shouldn't be bundled
11+
+ // into your library
12+
+ external: [
13+
+ "react",
14+
+ "react-dom",
15+
+ "@vector-im/compound-design-tokens",
16+
+ "@vector-im/compound-web"
17+
+ ],
18+
},
19+
},
20+
plugins: [
21+
@@ -41,6 +48,7 @@ export default defineConfig({
22+
externalGlobals({
23+
// Reuse React from the host app
24+
react: "window.React",
25+
+ "@vector-im/compound-web": "window.CompoundWeb",
26+
}),
27+
],
28+
define: {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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": "echo no tests yet"
13+
},
14+
"devDependencies": {
15+
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
16+
"@element-hq/element-web-module-api": "^1.0.0",
17+
"@types/node": "^22.10.7",
18+
"@types/react": "^19",
19+
"@vitejs/plugin-react": "^5.0.0",
20+
"react": "^19",
21+
"rollup-plugin-external-globals": "^0.13.0",
22+
"typescript": "^5.7.3",
23+
"vite": "^7.1.11",
24+
"vite-plugin-node-polyfills": "^0.25.0",
25+
"vite-plugin-svgr": "^4.3.0"
26+
},
27+
"dependencies": {
28+
"@vector-im/compound-design-tokens": "^6.0.0",
29+
"@vector-im/compound-web": "^8.0.0",
30+
"matrix-widget-api": "^1.17.0",
31+
"styled-components": "^6.3.11",
32+
"zod": "^4.3.6"
33+
}
34+
}
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.0.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.setRoomHeaderButtonCallback((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;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2024 Nordeck IT + Consulting GmbH
3+
* Copyright 2026 Element Creations Ltd.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
19+
import { IconButton, Tooltip } from "@vector-im/compound-web";
20+
import { type IWidget } from "matrix-widget-api";
21+
import React, { type JSX } from "react";
22+
import styled from "styled-components";
23+
24+
type Props = { $isInContainer: boolean };
25+
26+
const Img = styled.img<Props>`
27+
border-color: ${(): string => "var(--cpd-color-text-action-accent)"};
28+
border-radius: 50%;
29+
border-style: solid;
30+
border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")};
31+
box-sizing: border-box;
32+
height: 24px;
33+
width: 24px;
34+
`;
35+
36+
const Svg = styled.svg<Props>`
37+
height: 24px;
38+
fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")};
39+
width: 24px;
40+
`;
41+
42+
function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null {
43+
if (app.type.match(/jitsi/i)) {
44+
return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K";
45+
}
46+
47+
return widgetApi.getAppAvatarUrl(app);
48+
}
49+
50+
function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean {
51+
return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId);
52+
}
53+
54+
type WidgetToggleProps = {
55+
app: IWidget;
56+
roomId: string;
57+
widgetApi: WidgetApi;
58+
i18nApi: I18nApi;
59+
};
60+
61+
export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element {
62+
const appAvatarUrl = avatarUrl(app, widgetApi);
63+
const appNameOrType = app.name ?? app.type;
64+
const inContainer = isInContainer(app, widgetApi, roomId);
65+
66+
const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType });
67+
68+
const onClick = React.useCallback(
69+
(event: React.MouseEvent<HTMLButtonElement>) => {
70+
event.stopPropagation();
71+
if (inContainer) {
72+
widgetApi.moveAppToContainer(app, "right", roomId);
73+
} else {
74+
widgetApi.moveAppToContainer(app, "top", roomId);
75+
}
76+
},
77+
[app, inContainer, roomId, widgetApi],
78+
);
79+
80+
return (
81+
<Tooltip label={label} key={app.id}>
82+
<IconButton aria-label={label} onClick={onClick}>
83+
{appAvatarUrl ? (
84+
<Img $isInContainer={inContainer} alt={appNameOrType} src={appAvatarUrl} />
85+
) : (
86+
<Svg $isInContainer={inContainer} role="presentation">
87+
<path d="M16 2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3ZM16 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3ZM4 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z M8 2H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Z" />
88+
</Svg>
89+
)}
90+
</IconButton>
91+
</Tooltip>
92+
);
93+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "lib",
5+
"jsx": "react-jsx"
6+
},
7+
"include": ["src"]
8+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 { dirname, resolve } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
import { defineConfig } from "vite";
11+
import react from "@vitejs/plugin-react";
12+
import { nodePolyfills } from "vite-plugin-node-polyfills";
13+
import externalGlobals from "rollup-plugin-external-globals";
14+
import svgr from "vite-plugin-svgr";
15+
import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
19+
export default defineConfig({
20+
build: {
21+
lib: {
22+
entry: resolve(__dirname, "src/index.tsx"),
23+
name: "element-web-module-widget-toggles",
24+
fileName: "index",
25+
formats: ["es"],
26+
},
27+
outDir: "lib",
28+
target: "esnext",
29+
sourcemap: true,
30+
rollupOptions: {
31+
external: ["react"],
32+
},
33+
},
34+
plugins: [
35+
importCSSSheet(),
36+
react(),
37+
svgr(),
38+
nodePolyfills({
39+
include: ["events"],
40+
}),
41+
externalGlobals({
42+
// Reuse React from the host app
43+
react: "window.React",
44+
}),
45+
],
46+
define: {
47+
// Use production mode for the build as it is tested against production builds of Element Web,
48+
// this is required for React JSX versions to be compatible.
49+
process: { env: { NODE_ENV: "production" } },
50+
},
51+
});

yarn.lock

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,6 +2189,11 @@
21892189
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
21902190
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
21912191

2192+
"@types/events@^3.0.0":
2193+
version "3.0.3"
2194+
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529"
2195+
integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==
2196+
21922197
"@types/json5@^0.0.29":
21932198
version "0.0.29"
21942199
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@@ -4232,7 +4237,7 @@ eventemitter3@^5.0.1:
42324237
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb"
42334238
integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==
42344239

4235-
events@^3.0.0, events@^3.3.0:
4240+
events@^3.0.0, events@^3.2.0, events@^3.3.0:
42364241
version "3.3.0"
42374242
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
42384243
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
@@ -5583,6 +5588,14 @@ matrix-web-i18n@^3.3.0:
55835588
minimist "^1.2.8"
55845589
walk "^2.3.15"
55855590

5591+
matrix-widget-api@^1.17.0:
5592+
version "1.17.0"
5593+
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz#2336de2186fe70d8bd741c1603c162f60b2099c2"
5594+
integrity sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==
5595+
dependencies:
5596+
"@types/events" "^3.0.0"
5597+
events "^3.2.0"
5598+
55865599
md5.js@^1.3.4:
55875600
version "1.3.5"
55885601
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@@ -7180,7 +7193,7 @@ strip-json-comments@^3.1.1, strip-json-comments@~3.1.1:
71807193
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
71817194
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
71827195

7183-
styled-components@^6.1.18:
7196+
styled-components@^6.1.18, styled-components@^6.3.11:
71847197
version "6.3.11"
71857198
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.3.11.tgz#6babdb151a3419b0a79a368353d1dfbe8c5dddbc"
71867199
integrity sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==
@@ -7921,7 +7934,7 @@ zod@^3.22.4:
79217934
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
79227935
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==
79237936

7924-
zod@^4.0.0, zod@^4.1.11:
7937+
zod@^4.0.0, zod@^4.1.11, zod@^4.3.6:
79257938
version "4.3.6"
79267939
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
79277940
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==

0 commit comments

Comments
 (0)