mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user