/* 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. */ // The design tokens, fonts and element defaults every Element Call stylesheet // builds on. Written for a page, they speak of `html`, `body` and bare // elements; the component build confines them, and every other stylesheet in // this bundle, to the root element below (see build/scopeStylesToRoot.ts), so // that the host's document is left as it was. // // Where these land relative to the component stylesheets is the bundler's // choice — the standalone app puts them first, this build puts them in the // middle — so nothing in base.css may depend on winning or losing against a // component's own rules at equal specificity. It currently does not: what it // declares unlayered is custom properties on Element Call's root, which // components inherit rather than compete with, and everything from Compound // sits in a `@layer`, which loses to unlayered rules either way. import "../src/base.css"; import { type FC, type JSX, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { logger } from "matrix-js-sdk/lib/logger"; import { I18nextProvider } from "react-i18next"; import { TooltipProvider } from "@vector-im/compound-web"; import { ErrorBoundary } from "@sentry/react"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; import LanguageDetector from "i18next-browser-languagedetector"; import EN from "../locales/en/app.json"; import { CallView } from "../src/room/CallView"; import { ErrorPage } from "../src/FullScreenView"; import { ClientProvider } from "../src/ClientContext"; import { HostBridgeProvider } from "../src/HostBridge"; import { RootElementProvider, useRootElement } from "../src/RootElementContext"; import { configurationForIntent, componentProperties, type UrlParams, UrlParamsProvider, UserIntent, useUrlParams, } 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 { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; import { useComponentHostBridge } from "./host"; import { type ElementCallProps } from "./api"; import { supportedLanguages, translationsBackend } from "./localization"; // The languages Element Call can be shown in export { supportedLanguages } from "./localization"; // Everything a host needs to talk about Element Call — the props, the handle, // the host bridge, the intents and what each means — also available without // the component itself from `@element-hq/element-call-component/api` export * from "./api"; /** * 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 { const polyfills: Promise[] = []; 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 .use(translationsBackend) .use(new LanguageDetector()) .init({ fallbackLng: "en", defaultNS: "app", keySeparator: ".", nsSeparator: false, pluralSeparator: "_", contextSeparator: "|", supportedLngs: [...supportedLanguages], interpolation: { escapeValue: false }, // English is bundled in, so the fallback never has to be loaded; every // other language arrives from the backend when first asked for. partialBundledLanguages: true, resources: { en: { app: EN } }, detection: { // The browser's language, until the host says otherwise through the // `language` prop. Nothing is remembered: the choice is the host's. order: ["navigator"], caches: [], }, }); } /** Applies the theme and background to the container, before it is painted. */ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => { useTheme(); const { background } = useUrlParams(); const rootElement = useRootElement(); useLayoutEffect(() => { rootElement.setAttribute("data-background", background); }, [rootElement, background]); return children; }; export const ElementCall: FC = ({ client, roomId, intent = UserIntent.JoinExistingCall, config, hostBridge: suppliedHostBridge, ref, theme, language, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); useEffect(() => { if (language !== undefined) i18n .changeLanguage(language) .catch((e) => logger.error(`Could not switch to ${language}`, e)); }, [language]); // The container is what Element Call decorates and portals into, so nothing // inside can render until we have it. const [container, setContainer] = useState(null); // Element Call has no URL of its own to read any of this from, and the // host's URL is not Element Call's business, so the defaults come from the // intent with the host's wishes over the top. // // Everything downstream — the mute state, the call view model and with it // the media connection — is keyed on the identity of this object, so it has // to be stable for as long as its contents are. A host writing `config` // inline would otherwise tear the call down on every render. const stableConfig = useStableValue(config); const params = useMemo( (): UrlParams => ({ ...componentProperties, roomId, ...configurationForIntent(intent), ...stableConfig, }), [roomId, intent, stableConfig], ); // Created in an effect so that the scope it lives in ends when the component // is unmounted (or these options change), rather than keeping its device // observers running for the rest of the page's life. Null until then, which // is one render. const { controlledAudioDevices, callIntent } = params; const [mediaDevices, setMediaDevices] = useState(null); useEffect(() => { const scope = new ObservableScope(); setMediaDevices( new MediaDevices(scope, { controlledAudioDevices, callIntent }), ); return (): void => { setMediaDevices(null); scope.end(); }; }, [controlledAudioDevices, 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`, ); // Everything the call needs is in hand once these exist, and the first // render with them is where the call itself appears: the moment the host // is told that Element Call has loaded, as the widget tells its client once // its own initialisation is over. Once per mount, however often the pieces // are later swapped out. const ready = container !== null && rtcSession !== null && mediaDevices !== null; const announcedLoaded = useRef(false); useEffect(() => { if (!ready || announcedLoaded.current) return; announcedLoaded.current = true; hostBridge .contentLoaded() .catch((e) => logger.error("Could not tell the host we had loaded", e)); }, [ready, hostBridge]); return (
{ready && ( {/* Whatever goes wrong in here is shown in here. Left to propagate, an error would unmount the host's own tree. */} } // A broken call should not hold the host on screen onError={() => void hostBridge.setAlwaysOnScreen(false)} > )}
); };