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", + ], + }, + }, +}));