feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,27 @@
/*
Copyright 2025 New Vector 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 { create } from "storybook/theming";
export default create({
base: "light",
// Colors
textColor: "#1b1d22",
colorSecondary: "#111111",
// UI
appBg: "#ffffff",
appContentBg: "#ffffff",
// Toolbar
barBg: "#ffffff",
brandTitle: "Web Shared Components",
brandUrl: "https://github.com/element-hq/element-web/tree/develop/packages/shared-components",
brandTarget: "_self",
});
@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector 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.
*/
/* Shared cascade order: Compound tokens, Compound Web, shared components, then app overrides. */
@layer compound-tokens, compound-web, shared-components, app-web;
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound-tokens);
@import url("@vector-im/compound-web/dist/style.css") layer(compound-web);
@@ -0,0 +1,59 @@
/*
* Copyright 2025 New Vector 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 { Addon, types, useGlobals } from "storybook/manager-api";
import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
import React from "react";
import { GlobeIcon } from "@storybook/icons";
const languages: string[] = JSON.parse(process.env.STORYBOOK_LANGUAGES!);
/**
* Returns the title of a language in the user's locale.
*/
function languageTitle(language: string): string {
const normalisedLang = language.toLowerCase().replace("_", "-");
return new Intl.DisplayNames([normalisedLang], { type: "language", style: "short" }).of(normalisedLang) || language;
}
export const languageAddon: Addon = {
title: "Language Selector",
type: types.TOOL,
render: ({ active }) => {
const [globals, updateGlobals] = useGlobals();
const selectedLanguage = globals.language || "en";
return (
<WithTooltip
placement="top"
trigger="click"
closeOnOutsideClick
tooltip={({ onHide }) => {
return (
<TooltipLinkList
links={languages.map((language) => ({
id: language,
title: languageTitle(language),
active: selectedLanguage === language,
onClick: async () => {
// Update the global state with the selected language
updateGlobals({ language });
onHide();
},
}))}
/>
);
}}
>
<IconButton title="Language">
<GlobeIcon />
{languageTitle(selectedLanguage)}
</IconButton>
</WithTooltip>
);
},
};
@@ -0,0 +1,128 @@
/*
Copyright 2025 New Vector 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 type { StorybookConfig } from "@storybook/react-vite";
import fs from "node:fs";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import { mergeConfig, normalizePath, type Plugin } from "vite";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const srcRoot = normalizePath(join(__dirname, "..", "src"));
const sharedComponentsLayer = "shared-components";
// Get a list of available languages so the language selector can display them at runtime
const languageFiles = fs.readdirSync(join(__dirname, "..", "src", "i18n", "strings")).map((f) => f.slice(0, -5));
const languages: Record<string, string> = {};
for (const lang of languageFiles) {
const normalizedLanguage = lang.toLowerCase().replace("_", "-");
const languageParts = normalizedLanguage.split("-");
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
languages[languageParts[0]] = `${lang}.json`;
} else {
languages[normalizedLanguage] = `${lang}.json`;
}
}
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
}
function layerSharedComponentCssModules(): Plugin {
return {
name: "element-web-shared-components-storybook-css-layer",
enforce: "pre",
transform(code, id) {
const cssPath = normalizePath(id.split("?")[0]);
if (!cssPath.startsWith(srcRoot) || !cssPath.endsWith(".module.css")) {
return;
}
return {
code: `@layer ${sharedComponentsLayer} {\n${code}\n}\n`,
map: null,
};
},
};
}
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-docs",
"@storybook/addon-designs",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
getAbsolutePath("storybook-addon-vis"),
],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,
},
typescript: {
reactDocgen: "react-docgen-typescript",
reactDocgenTypescriptOptions: {
// The default exclude is ["**/**.stories.tsx"] which prevents
// docgen from extracting snapshot field descriptions from wrapper
// components defined in story files.
exclude: [],
},
},
async viteFinal(config) {
return mergeConfig(config, {
plugins: [
layerSharedComponentCssModules(),
// Needed for counterpart to work
nodePolyfills({ include: ["util"], globals: { global: false } }),
{
name: "language-middleware",
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === "/i18n/languages.json") {
// Dynamically generate a languages.json file based on what files are available
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(languages));
} else if (req.url === "/usercontent/" || req.url === "/usercontent") {
// Mock usercontent endpoint used by encrypted download iframes.
res.end("This is where /usercontent/ is loaded.");
} else if (req.url?.startsWith("/i18n/")) {
// Serve the individual language files, which annoyingly can't be a simple
// static dir because the directory structure in src doesn't match what
// the app requests.
const langFile = req.url.split("/").pop();
res.setHeader("Content-Type", "application/json");
fs.createReadStream(`src/i18n/strings/${langFile}`).pipe(res);
} else {
next();
}
});
},
},
],
server: {
allowedHosts: ["localhost", ".docker.internal"],
},
});
},
refs: {
"compound-web": {
title: "Compound Web",
url: "https://element-hq.github.io/compound-web/",
},
},
env: (config) => ({
...config,
STORYBOOK_LANGUAGES: JSON.stringify(Object.keys(languages)),
}),
};
export default config;
@@ -0,0 +1,18 @@
/*
Copyright 2025 New Vector 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 React from "react";
import { addons } from "storybook/manager-api";
import ElementTheme from "./ElementTheme";
import { languageAddon } from "./languageAddon";
addons.setConfig({
theme: ElementTheme,
});
addons.register("elementhq/language", () => addons.add("language", languageAddon));
@@ -0,0 +1,36 @@
/*
Copyright 2025 New Vector 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.
*/
.docs-story {
background: var(--cpd-color-bg-canvas-default);
}
/* Username color classes - these are defined in the main app's _common.pcss
but need to be available in Storybook for components that use colorClass */
.mx_Username_color1 {
color: var(--cpd-color-text-decorative-1);
}
.mx_Username_color2 {
color: var(--cpd-color-text-decorative-2);
}
.mx_Username_color3 {
color: var(--cpd-color-text-decorative-3);
}
.mx_Username_color4 {
color: var(--cpd-color-text-decorative-4);
}
.mx_Username_color5 {
color: var(--cpd-color-text-decorative-5);
}
.mx_Username_color6 {
color: var(--cpd-color-text-decorative-6);
}
@@ -0,0 +1,160 @@
/*
* 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 type { ArgTypes, Decorator, Preview, ReactRenderer, StrictArgs } from "@storybook/react-vite";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "./compound.css";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import type { StoryContext } from "storybook/internal/csf";
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
import { setLanguage } from "../src/core/i18n/i18n";
export const globalTypes = {
theme: {
name: "Theme",
description: "Global theme for components",
toolbar: {
icon: "circlehollow",
title: "Theme",
items: [
{ title: "System", value: "system", icon: "browser" },
{ title: "Light", value: "light", icon: "sun" },
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
{ title: "Dark", value: "dark", icon: "moon" },
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
],
},
},
language: {
name: "Language",
description: "Global language for components",
},
eventLayout: {
name: "Event layout",
description: "Global event layout for timeline components",
toolbar: {
icon: "component",
title: "Event layout",
items: [
{ title: "Group", value: "group" },
{ title: "Bubble", value: "bubble" },
{ title: "IRC", value: "irc" },
],
},
},
eventDensity: {
name: "Event density",
description: "Global event density for timeline components",
toolbar: {
icon: "listunordered",
title: "Event density",
items: [
{ title: "Default", value: "default" },
{ title: "Compact", value: "compact" },
],
},
},
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
} satisfies ArgTypes;
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
const ThemeSwitcher: React.FC<{
theme: string;
}> = ({ theme }) => {
useLayoutEffect(() => {
document.documentElement.classList.remove(...allThemesClasses);
if (theme !== "system") {
document.documentElement.classList.add(`cpd-theme-${theme}`);
}
return () => document.documentElement.classList.remove(...allThemesClasses);
}, [theme]);
return null;
};
const withThemeProvider: Decorator = (Story, context) => {
return (
<>
<ThemeSwitcher theme={context.globals.theme} />
<Story />
</>
);
};
async function languageLoader(context: StoryContext<ReactRenderer, StrictArgs>): Promise<void> {
await setLanguage(context.globals.language);
}
const withTooltipProvider: Decorator = (Story) => {
return (
<TooltipProvider>
<Story />
</TooltipProvider>
);
};
const withI18nProvider: Decorator = (Story) => {
return (
<I18nContext.Provider value={new I18nApi()}>
<Story />
</I18nContext.Provider>
);
};
const withEventPresentationProvider: Decorator = (Story, context) => {
return (
<EventPresentationProvider
value={{
layout: context.globals.eventLayout as EventLayout,
density: context.globals.eventDensity as EventDensity,
}}
>
<Story />
</EventPresentationProvider>
);
};
const preview = {
tags: ["autodocs", "snapshot"],
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
parameters: {
options: {
storySort: {
method: "alphabetical",
},
},
a11y: {
/*
* Configure test behavior
* See: https://storybook.js.org/docs/next/writing-tests/accessibility-testing#test-behavior
*/
test: "error",
},
},
loaders: [languageLoader],
} satisfies Preview;
export default preview;
@@ -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 * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from "@storybook/react-vite";
import { vis, visAnnotations } from "storybook-addon-vis/vitest-setup";
import * as projectAnnotations from "./preview.tsx";
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations, visAnnotations]);
vis.setup({
async auto() {
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.appendChild(
document.createTextNode(`
/* Inhibit all animations for the screenshot to be more stable */
*, *::before, *::after {
animation: none !important;
}
/*
* Mask spinner for video overlay during screenshot generation on playwright tests.
*/
[data-video-body-mask-target] {
position: relative;
}
[data-video-body-mask-target]::after {
content: "";
position: absolute;
inset-inline-start: 50%;
inset-block-start: 50%;
width: 112px;
height: 112px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: #ff4fcf;
pointer-events: none;
}
/* Hide all storybook elements */
.sb-wrapper {
visibility: hidden !important;
}
`),
);
document.head.appendChild(style);
},
});
@@ -0,0 +1,62 @@
/*
* 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.
*/
/**
* Copies the component description and props documentation from a View's
* `__docgenInfo` (injected at build time by Storybook's react-docgen-typescript
* Vite plugin) onto the story wrapper component.
*
* This lets Storybook's default `extractComponentDescription` pick up the
* View's JSDoc and display per-field descriptions in the ArgTypes table.
*
* **Important:** the wrapper must be defined as a named variable *before*
* being passed here so that react-docgen-typescript can extract its props.
*
* @example
* ```ts
* const MyViewWrapperImpl = (props: MyViewProps) => {
* const vm = useMockedViewModel(props, {});
* return <MyView vm={vm} />;
* };
* const MyViewWrapper = withViewDocs(MyViewWrapperImpl, MyView);
* ```
*/
export function withViewDocs<T extends (...args: never[]) => unknown>(wrapper: T, view: object): T {
const viewInfo = (view as { __docgenInfo?: DocgenInfo }).__docgenInfo;
const viewDescription = viewInfo?.description;
if (!viewDescription) return wrapper;
// The wrapper must be defined as a named variable (not inline) so that
// react-docgen-typescript can extract its props. The docgen Vite plugin
// appends a `Wrapper.__docgenInfo = { … }` assignment at the *end* of the
// module, which runs **after** this function. We install a setter trap so
// that the View's description is merged into the generated info.
let stored: DocgenInfo | undefined = (wrapper as { __docgenInfo?: DocgenInfo }).__docgenInfo;
Object.defineProperty(wrapper, "__docgenInfo", {
get() {
return stored;
},
set(incoming: DocgenInfo) {
stored = {
...incoming,
description: incoming.description || viewDescription,
};
},
configurable: true,
enumerable: true,
});
// Also apply immediately for the current state.
stored = { ...stored, description: viewDescription };
return wrapper;
}
interface DocgenInfo {
description?: string;
props?: Record<string, unknown>;
}