From 979b5215631b1841fd056e7ec2edf90d0d8eefaf Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 15:18:03 +0200 Subject: [PATCH] Build Element Call as a component a host can import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds component/index.tsx as a fourth build target: 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. --- component/ElementCall.module.css | 24 ++++ component/index.tsx | 185 +++++++++++++++++++++++++++++++ knip.ts | 7 +- package.json | 3 + pnpm-workspace.yaml | 4 + src/RootElementContext.ts | 9 +- tsconfig.json | 4 +- vite-component.config.ts | 65 +++++++++++ 8 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 component/ElementCall.module.css create mode 100644 component/index.tsx create mode 100644 vite-component.config.ts diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css new file mode 100644 index 000000000..399178aea --- /dev/null +++ b/component/ElementCall.module.css @@ -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; +} diff --git a/component/index.tsx b/component/index.tsx new file mode 100644 index 000000000..e904c8bed --- /dev/null +++ b/component/index.tsx @@ -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; + +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 { + 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.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 = ({ + 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(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 ( + + + + {/* Element Call's own navigation stays in memory, so that being + embedded cannot disturb the host's URL. */} + +
+ {container !== null && rtcSession !== null && ( + + + + + + + + + + + + + + )} +
+
+
+
+
+ ); +}; diff --git a/knip.ts b/knip.ts index 8412d5915..97ecc0903 100644 --- a/knip.ts +++ b/knip.ts @@ -9,7 +9,12 @@ import { type KnipConfig } from "knip"; export default { 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"], ignoreBinaries: [ diff --git a/package.json b/package.json index 2f4faa601..91ba49dea 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "build:sdk:development": "pnpm build:sdk --mode development", "build:sdk": "pnpm build:full --config vite-sdk.config.js", "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", "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", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c32736caa..9e4f0fd6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +supportedArchitectures: + os: [current, linux] + cpu: [current, arm64] + libc: [current, glibc] minimumReleaseAgeExclude: - "@vector-im/compound-design-tokens" - "@vector-im/compound-web" diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index 48dc26134..906a90d62 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -25,11 +25,14 @@ import { createContext, use } from "react"; * `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. */ -// 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(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. * diff --git a/tsconfig.json b/tsconfig.json index 74c27025a..aba6c5ec0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,9 @@ "./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts", - "./sdk/**/*.ts" + "./sdk/**/*.ts", + "./component/**/*.ts", + "./component/**/*.tsx" ], "exclude": ["**.test.ts"] } diff --git a/vite-component.config.ts b/vite-component.config.ts new file mode 100644 index 000000000..1a57abe61 --- /dev/null +++ b/vite-component.config.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", + ], + }, + }, +}));