Add widget toggles module

* API: https://github.com/element-hq/element-modules/pull/219
 * Element Web API impl: https://github.com/element-hq/element-web/pull/32734
This commit is contained in:
David Baker
2026-03-06 11:02:11 +00:00
parent fee5f0633c
commit 30abf86c4b
8 changed files with 321 additions and 0 deletions
@@ -0,0 +1,28 @@
diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts
index b65e3ec..9d1ba49 100644
--- a/modules/widget-toggles/element-web/vite.config.ts
+++ b/modules/widget-toggles/element-web/vite.config.ts
@@ -28,7 +28,14 @@ export default defineConfig({
target: "esnext",
sourcemap: true,
rollupOptions: {
- external: ["react"],
+ // make sure to externalize deps that shouldn't be bundled
+ // into your library
+ external: [
+ "react",
+ "react-dom",
+ "@vector-im/compound-design-tokens",
+ "@vector-im/compound-web"
+ ],
},
},
plugins: [
@@ -41,6 +48,7 @@ export default defineConfig({
externalGlobals({
// Reuse React from the host app
react: "window.React",
+ "@vector-im/compound-web": "window.CompoundWeb",
}),
],
define: {
@@ -0,0 +1,34 @@
{
"name": "@element-hq/element-web-module-widget-toggle",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "lib/index.js",
"license": "SEE LICENSE IN README.md",
"scripts": {
"prepare": "vite build",
"lint:types": "tsc --noEmit",
"lint:codestyle": "echo 'handled by lint:eslint'",
"test": "echo no tests yet"
},
"devDependencies": {
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
"@element-hq/element-web-module-api": "^1.0.0",
"@types/node": "^22.10.7",
"@types/react": "^19",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19",
"rollup-plugin-external-globals": "^0.13.0",
"typescript": "^5.7.3",
"vite": "^7.1.11",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-svgr": "^4.3.0"
},
"dependencies": {
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.0.0",
"matrix-widget-api": "^1.17.0",
"styled-components": "^6.3.11",
"zod": "^4.3.6"
}
}
@@ -0,0 +1,27 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { z, type input } from "zod/mini";
z.config(z.locales.en());
export const WidgetTogglesConfig = z.object({
/**
* The widget types to show a toggle for.
*/
types: z.array(z.string()),
});
export type WidgetTogglesConfig = z.infer<typeof WidgetTogglesConfig>;
export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles";
declare module "@element-hq/element-web-module-api" {
export interface Config {
[CONFIG_KEY]: input<WidgetTogglesConfig>;
}
}
@@ -0,0 +1,64 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type JSX } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
import { WidgetToggle } from "./toggle";
class WidgetToggleModule implements Module {
public static readonly moduleApiVersion = "^1.0.0";
private config?: WidgetTogglesConfig;
public constructor(private api: Api) {}
public async load(): Promise<void> {
try {
this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY));
} catch (e) {
console.error("Failed to init module", e);
throw new Error(`Errors in module configuration for widget toggles module`);
}
this.api.extras.setRoomHeaderButtonCallback((roomId: string) => {
const widgets = this.api.widget.getWidgetsInRoom(roomId);
const toggleElements: JSX.Element[] = [];
for (const widget of widgets) {
if (this.config?.types.includes(widget.type)) {
toggleElements.push(
<WidgetToggle
app={widget}
roomId={roomId}
widgetApi={this.api.widget}
i18nApi={this.api.i18n}
/>,
);
}
}
if (toggleElements.length === 0) return undefined;
// XXX: We shouldn't have to add another TooltipProvider here, it should
// be using the one in MatrixChat in Element Web, but thanks to the fact
// that contexts are "magically" identified by class, it doesn't pick up the
// context because we use a different copy of compound-web. We'll probably
// need to fix this at some point, possibly by moving compound's tooltip stuff
// out to its own mini-module that can be provided at runtime by Element Web,
// unless React make contexts more sensible.
// Annoyingly this does actually cause the tooltips to behave a bit weirdly:
// there's a delay before the tooltip appears when yo move between these buttons
// and the rest of the header buttons, whereas moving between the other header
// buttons, the tooltips appear straight away once one has appeared.
return <TooltipProvider>{toggleElements}</TooltipProvider>;
});
}
}
export default WidgetToggleModule satisfies ModuleFactory;
@@ -0,0 +1,93 @@
/*
* Copyright 2024 Nordeck IT + Consulting GmbH
* Copyright 2026 Element Creations Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { type IWidget } from "matrix-widget-api";
import React, { type JSX } from "react";
import styled from "styled-components";
type Props = { $isInContainer: boolean };
const Img = styled.img<Props>`
border-color: ${(): string => "var(--cpd-color-text-action-accent)"};
border-radius: 50%;
border-style: solid;
border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")};
box-sizing: border-box;
height: 24px;
width: 24px;
`;
const Svg = styled.svg<Props>`
height: 24px;
fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")};
width: 24px;
`;
function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null {
if (app.type.match(/jitsi/i)) {
return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K";
}
return widgetApi.getAppAvatarUrl(app);
}
function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean {
return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId);
}
type WidgetToggleProps = {
app: IWidget;
roomId: string;
widgetApi: WidgetApi;
i18nApi: I18nApi;
};
export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element {
const appAvatarUrl = avatarUrl(app, widgetApi);
const appNameOrType = app.name ?? app.type;
const inContainer = isInContainer(app, widgetApi, roomId);
const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType });
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (inContainer) {
widgetApi.moveAppToContainer(app, "right", roomId);
} else {
widgetApi.moveAppToContainer(app, "top", roomId);
}
},
[app, inContainer, roomId, widgetApi],
);
return (
<Tooltip label={label} key={app.id}>
<IconButton aria-label={label} onClick={onClick}>
{appAvatarUrl ? (
<Img $isInContainer={inContainer} alt={appNameOrType} src={appAvatarUrl} />
) : (
<Svg $isInContainer={inContainer} role="presentation">
<path d="M16 2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3ZM16 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3ZM4 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z M8 2H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Z" />
</Svg>
)}
</IconButton>
</Tooltip>
);
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "lib",
"jsx": "react-jsx"
},
"include": ["src"]
}
@@ -0,0 +1,51 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import externalGlobals from "rollup-plugin-external-globals";
import svgr from "vite-plugin-svgr";
import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "src/index.tsx"),
name: "element-web-module-widget-toggles",
fileName: "index",
formats: ["es"],
},
outDir: "lib",
target: "esnext",
sourcemap: true,
rollupOptions: {
external: ["react"],
},
},
plugins: [
importCSSSheet(),
react(),
svgr(),
nodePolyfills({
include: ["events"],
}),
externalGlobals({
// Reuse React from the host app
react: "window.React",
}),
],
define: {
// Use production mode for the build as it is tested against production builds of Element Web,
// this is required for React JSX versions to be compatible.
process: { env: { NODE_ENV: "production" } },
},
});