Build Element Call as a component a host can import

Adds component/index.tsx as a fourth build target: <ElementCall client
roomId /> and an initializeElementCall to await once beforehand. It gives
Element Call everything it would otherwise take from the page it is on —
the parameters, the host bridge, media devices, translations, a container
to confine itself to — and hands it the host's client rather than finding
one of its own.

React, the Matrix SDK and LiveKit stay external, since the host has them
and a second copy of any would not merely be wasteful: React would hold
two sets of hooks and the client would run two sync loops. Every subpath
has to be listed by name, because the pattern and callback forms of
rollupOptions.external are silently ignored here — a lesson worth the
comment that records it.

Element Call's own navigation runs in a MemoryRouter, so being embedded
cannot disturb the host's URL. ClientContext and GroupCallView both
navigate, so some router has to be present.

The bundle is not yet a reasonable size: library mode base64-inlines
assets referenced through import.meta.url, so MediaPipe's vision runtime
lands in it whole. Left for its own change, since the fix — loading the
background blur transformer lazily — is worth doing for the standalone app
too.
This commit is contained in:
Valere
2026-09-03 15:18:03 +02:00
parent a166fbbd08
commit 979b521563
8 changed files with 296 additions and 5 deletions
+24
View File
@@ -0,0 +1,24 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/* The container a host mounts us into. It fills whatever space the host gives
it, and establishes a stacking context of its own so that our overlays and
modals cannot escape it — which is the whole reason for embedding rather than
using an iframe. */
.root {
display: flex;
flex-direction: column;
inline-size: 100%;
block-size: 100%;
isolation: isolate;
position: relative;
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
+185
View File
@@ -0,0 +1,185 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* EXPERIMENTAL
*
* Element Call as a React component, for an application that wants to show a
* call inside itself rather than in an iframe.
*
* The host supplies the client and says which room to call in; Element Call
* supplies the call. Everything it would otherwise take from the page it is on
* — the URL, the document body, a Matrix session of its own — comes from the
* host instead, or is confined to the container it is mounted in.
*/
import { type FC, type JSX, type ReactNode, useMemo, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { MemoryRouter } from "react-router-dom";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
import { ElementCallView } from "../src/ElementCallView";
import { ClientProvider } from "../src/ClientContext";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../src/HostBridge";
import { RootElementProvider } from "../src/RootElementContext";
import {
computeUrlParams,
type UrlParams,
UrlParamsProvider,
} from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { MediaDevices } from "../src/state/MediaDevices";
import { ObservableScope } from "../src/state/ObservableScope";
import { ProcessorProvider } from "../src/livekit/TrackProcessorContext";
import { Config } from "../src/config/Config";
import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useInitial } from "../src/useInitial";
import styles from "./ElementCall.module.css";
export { type HostBridge } from "../src/HostBridge";
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the same default it would in the standalone app.
*/
export type ElementCallConfiguration = Partial<UrlParams>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/** How Element Call should behave. */
config?: ElementCallConfiguration;
/**
* How to reach the host while the call is running — to be told the user has
* joined or hung up, to be asked to keep the call on screen, and so on.
* Without one, Element Call assumes it has no host to talk to.
*/
hostBridge?: HostBridge;
}
/**
* Prepares the things Element Call needs before it can be shown: translations,
* `Intl` polyfills for older browsers, and its configuration.
*
* Await this once, before rendering {@link ElementCall}.
*/
export async function initializeElementCall(
config: ConfigOptions = {},
): Promise<void> {
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter())
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
if (shouldPolyfillDurationFormat())
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
await Promise.all(polyfills);
Config.initWith(config);
await i18n.init({
fallbackLng: "en",
defaultNS: "app",
keySeparator: ".",
nsSeparator: false,
pluralSeparator: "_",
contextSeparator: "|",
lng: "en",
interpolation: { escapeValue: false },
});
}
/** Applies the theme to the container, before it is painted. */
const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
useTheme();
return children;
};
export const ElementCall: FC<ElementCallProps> = ({
client,
roomId,
config,
hostBridge = nullHostBridge,
}): ReactNode => {
// The container is what Element Call decorates and portals into, so nothing
// inside can render until we have it.
const [container, setContainer] = useState<HTMLDivElement | null>(null);
// The defaults are the standalone app's, with the host's wishes over the top
const params = useMemo(
(): UrlParams => ({ ...computeUrlParams(), ...config }),
[config],
);
const mediaDevices = useInitial(
() =>
new MediaDevices(new ObservableScope(), {
controlledAudioDevices: params.controlledAudioDevices,
callIntent: params.callIntent,
}),
);
const room = client.getRoom(roomId);
const rtcSession = useMemo(
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
[client, room],
);
if (rtcSession === null)
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
return (
<I18nextProvider i18n={i18n}>
<HostBridgeProvider value={hostBridge}>
<UrlParamsProvider value={params}>
{/* Element Call's own navigation stays in memory, so that being
embedded cannot disturb the host's URL. */}
<MemoryRouter>
<div ref={setContainer} className={styles.root}>
{container !== null && rtcSession !== null && (
<RootElementProvider value={container}>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<ElementCallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</RootElementProvider>
)}
</div>
</MemoryRouter>
</UrlParamsProvider>
</HostBridgeProvider>
</I18nextProvider>
);
};
+6 -1
View File
@@ -9,7 +9,12 @@ import { type KnipConfig } from "knip";
export default { export default {
vite: { vite: {
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"], config: [
"vite.config.ts",
"vite-embedded.config.ts",
"vite-sdk.config.ts",
"vite-component.config.ts",
],
}, },
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
ignoreBinaries: [ ignoreBinaries: [
+3
View File
@@ -16,6 +16,9 @@
"build:sdk:development": "pnpm build:sdk --mode development", "build:sdk:development": "pnpm build:sdk --mode development",
"build:sdk": "pnpm build:full --config vite-sdk.config.js", "build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk", "build:sdk:production": "pnpm build:sdk",
"build:component": "pnpm build:full --config vite-component.config.js",
"build:component:production": "pnpm build:component",
"build:component:development": "pnpm build:component --mode development",
"serve": "vite preview", "serve": "vite preview",
"format": "oxfmt", "format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc", "format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
+4
View File
@@ -1,3 +1,7 @@
supportedArchitectures:
os: [current, linux]
cpu: [current, arm64]
libc: [current, glibc]
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- "@vector-im/compound-design-tokens" - "@vector-im/compound-design-tokens"
- "@vector-im/compound-web" - "@vector-im/compound-web"
+6 -3
View File
@@ -25,11 +25,14 @@ import { createContext, use } from "react";
* `index.html` starts the body hidden with `no-theme` until the theme lands. * `index.html` starts the body hidden with `no-theme` until the theme lands.
* Neither applies when a host mounts Element Call into a container of its own. * Neither applies when a host mounts Element Call into a container of its own.
*/ */
// No provider is exported yet: nothing supplies a root element, so every
// consumer falls back to the document body. One arrives with the entry point
// that mounts Element Call into a container.
const RootElementContext = createContext<HTMLElement | null>(null); const RootElementContext = createContext<HTMLElement | null>(null);
/**
* Supplies the element Element Call should confine itself to. The standalone
* and widget builds need no provider, since for them that element is the body.
*/
export const RootElementProvider = RootElementContext.Provider;
/** /**
* The element Element Call should decorate and portal into. * The element Element Call should decorate and portal into.
* *
+3 -1
View File
@@ -53,7 +53,9 @@
"./src/**/*.ts", "./src/**/*.ts",
"./src/**/*.tsx", "./src/**/*.tsx",
"./playwright/**/*.ts", "./playwright/**/*.ts",
"./sdk/**/*.ts" "./sdk/**/*.ts",
"./component/**/*.ts",
"./component/**/*.tsx"
], ],
"exclude": ["**.test.ts"] "exclude": ["**.test.ts"]
} }
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config";
// Config for Element Call as a React component, to be imported by an
// application embedding it rather than served as a page of its own.
//
// Deliberately not built on top of the full app's config, which exists to
// produce a page and brings an HTML entry point along with it.
export default defineConfig(({ mode }) => ({
...vitePluginsConfig({ mode }),
build: {
minify: mode === "production",
sourcemap: true,
// One stylesheet rather than one per chunk, so a host has a single file to
// include
cssCodeSplit: false,
lib: {
formats: ["es" as const],
entry: "./component/index.tsx",
fileName: "element-call",
},
rollupOptions: {
// The host already has these, and a second copy of any of them does not
// merely bloat the bundle: React would hold two sets of hooks, and the
// Matrix client would run two sync loops.
//
// Every subpath has to be named. Element Call reaches most of the Matrix
// SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not
// catch those — while the pattern and callback forms of this option are
// silently ignored by the bundler, so they cannot be used to cover them.
// `pnpm lint:externals` fails if an import appears that is not listed.
external: [
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"livekit-client",
"matrix-js-sdk",
"matrix-js-sdk/lib/client",
"matrix-js-sdk/lib/crypto-api",
"matrix-js-sdk/lib/logger",
"matrix-js-sdk/lib/matrix",
"matrix-js-sdk/lib/matrixrtc",
"matrix-js-sdk/lib/matrixrtc/EncryptionManager",
"matrix-js-sdk/lib/matrixrtc/IKeyTransport",
"matrix-js-sdk/lib/matrixrtc/IMembershipManager",
"matrix-js-sdk/lib/models/relations-container",
"matrix-js-sdk/lib/models/room",
"matrix-js-sdk/lib/models/typed-event-emitter",
"matrix-js-sdk/lib/randomstring",
"matrix-js-sdk/lib/sync",
"matrix-js-sdk/lib/types",
"matrix-js-sdk/lib/utils",
],
},
},
}));