mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-11 02:57:17 +00:00
- AppViewModel creates them, next to its media devices, as review asked; BackgroundEffectsProvider takes them as a prop rather than building them in an effect. - The component build, which has no AppViewModel of its own, now makes one in the effect that made its media devices, so both builds get their state the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/*
|
|
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 element defaults and custom properties 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.
|
|
//
|
|
// Compound is not in here. The component uses the host's copy of it (a peer
|
|
// dependency, left external by the build), and the host loads Compound's
|
|
// stylesheet, design tokens and fonts along with it: what Compound floats into
|
|
// the host's body — tooltips, menus — is then styled by the same stylesheet as
|
|
// everything else there, and Element Call's root takes the tokens and fonts of
|
|
// the theme class it carries, as any element on the host's page would.
|
|
//
|
|
// 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.
|
|
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 { AppViewModel } from "../src/state/AppViewModel";
|
|
import { ObservableScope } from "../src/state/ObservableScope";
|
|
import { BackgroundEffectsProvider } from "../src/livekit/BackgroundEffectsContext";
|
|
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<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
|
|
.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<ElementCallProps> = ({
|
|
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<HTMLDivElement | null>(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 [vm, setVm] = useState<AppViewModel | null>(null);
|
|
useEffect(() => {
|
|
const scope = new ObservableScope();
|
|
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
|
return (): void => {
|
|
setVm(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 && vm !== 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 (
|
|
<I18nextProvider i18n={i18n}>
|
|
<HostBridgeProvider value={hostBridge}>
|
|
<UrlParamsProvider value={params}>
|
|
<div ref={setContainer} className={styles.root}>
|
|
{ready && (
|
|
// Stands in for the standalone page's `#root`: see `.content`
|
|
// in ElementCall.module.css
|
|
<div className={styles.content}>
|
|
<RootElementProvider value={container}>
|
|
{/* Whatever goes wrong in here is shown in here. Left to
|
|
propagate, an error would unmount the host's own tree. */}
|
|
<ErrorBoundary
|
|
fallback={(error) => <ErrorPage error={error} />}
|
|
// A broken call should not hold the host on screen
|
|
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
|
>
|
|
<Decoration>
|
|
<TooltipProvider>
|
|
<ClientProvider client={client}>
|
|
<MediaDevicesContext value={vm.mediaDevices}>
|
|
<BackgroundEffectsProvider
|
|
effects={vm.backgroundEffects}
|
|
>
|
|
<CallView
|
|
client={client}
|
|
rtcSession={rtcSession}
|
|
isPasswordlessUser={false}
|
|
confineToRoom={params.confineToRoom}
|
|
preload={params.preload}
|
|
skipLobby={params.skipLobby}
|
|
/>
|
|
</BackgroundEffectsProvider>
|
|
</MediaDevicesContext>
|
|
</ClientProvider>
|
|
</TooltipProvider>
|
|
</Decoration>
|
|
</ErrorBoundary>
|
|
</RootElementProvider>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</UrlParamsProvider>
|
|
</HostBridgeProvider>
|
|
</I18nextProvider>
|
|
);
|
|
};
|