From 7485b3d71f49fe21a9d64352f19c3d2ba33709d2 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:30:05 +0200 Subject: [PATCH 01/54] Use a dedicated i18next instance instead of the global singleton Element Call configured the global i18next singleton. When Element Call runs embedded in a host application rather than as its own page, that singleton belongs to the host, so configuring it would clobber the host's translations. Create Element Call's own instance in utils/i18n.ts, configure it in the initializer, and pass it to components via . Drop .use(initReactI18next) from the initializer: it registers the instance as react-i18next's global default, which is the global we are trying to avoid. Tests and stories keep using it, so that they do not need to wrap every render in a provider. Two modules imported `t` directly from "i18next" and so were bound to the global instance: utils/errors.ts now calls i18n.t() on the instance (reached at call time, since i18next only assigns `t` during init), and QrCode uses useTranslation() like every other component. No functional change. --- .storybook/preview.tsx | 6 ++++-- src/App.tsx | 34 +++++++++++++++++-------------- src/QrCode.tsx | 3 ++- src/initializer.tsx | 8 +++++--- src/utils/errors.ts | 45 +++++++++++++++++++++--------------------- src/utils/i18n.ts | 21 ++++++++++++++++++++ src/vitest.setup.ts | 6 ++++-- 7 files changed, 77 insertions(+), 46 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 757c1f8a7..5b841b829 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details. import type { Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; -import i18n from "i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import EN from "../locales/en/app.json"; import { initReactI18next } from "react-i18next"; +import { i18n } from "../src/utils/i18n"; import "../src/index.css"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, stories register the instance as react-i18next's default +// rather than wrapping every story in an . i18n .use(initReactI18next) .init({ diff --git a/src/App.tsx b/src/App.tsx index 8f6ef21a1..36afc4c2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; import { LoginPage } from "./auth/LoginPage"; @@ -32,6 +33,7 @@ import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; +import { i18n } from "./utils/i18n"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -98,20 +100,22 @@ export const App: FC = ({ vm }) => { ); return ( - - - - - - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} - - - - - + + + + + + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + + + + + + ); }; diff --git a/src/QrCode.tsx b/src/QrCode.tsx index 09bd92ea7..da5693957 100644 --- a/src/QrCode.tsx +++ b/src/QrCode.tsx @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { type FC, useEffect, useState } from "react"; import { toDataURL } from "qrcode"; import classNames from "classnames"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import styles from "./QrCode.module.css"; @@ -18,6 +18,7 @@ interface Props { } export const QrCode: FC = ({ data, className }) => { + const { t } = useTranslation(); const [url, setUrl] = useState(null); useEffect(() => { diff --git a/src/initializer.tsx b/src/initializer.tsx index 91436d100..c0e8407ec 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details. */ import React from "react"; -import i18n, { +import { type BackendModule, type ReadCallback, type ResourceKey, } from "i18next"; -import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -35,6 +34,7 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { @@ -148,10 +148,12 @@ export class Initializer { document.documentElement.lang = lng; }); + // Note: deliberately no `.use(initReactI18next)` — that would register this + // instance as react-i18next's global default, which is the very global we + // are avoiding. Components receive it through `` instead. await i18n .use(Backend) .use(languageDetector) - .use(initReactI18next) .init({ fallbackLng: "en", defaultNS: "app", diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0ac569278..73b904c95 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { t } from "i18next"; import { type ConnectionError } from "livekit-client"; -import { i18nKey } from "./i18n"; +import { i18n, i18nKey } from "./i18n"; export enum ErrorCode { /** @@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { */ public constructor(domain: string) { super( - t("error.call_is_not_supported"), + i18n.t("error.call_is_not_supported"), ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, ErrorCategory.CONFIGURATION_ISSUE, - t("error.matrix_rtc_transport_missing", { + i18n.t("error.matrix_rtc_transport_missing", { domain, brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, @@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { export class ConnectionLostError extends ElementCallError { public constructor() { super( - t("error.connection_lost"), + i18n.t("error.connection_lost"), ErrorCode.CONNECTION_LOST_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, - t("error.connection_lost_description"), + i18n.t("error.connection_lost_description"), ); } } @@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.membership_manager"), + i18n.t("error.membership_manager"), ErrorCode.INTERNAL_MEMBERSHIP_MANAGER, ErrorCategory.SYSTEM_FAILURE, - t("error.membership_manager_description"), + i18n.t("error.membership_manager_description"), error, ); } @@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError { export class StickyEventsRequiredError extends ElementCallError { public constructor() { super( - t("error.sticky_events_required"), + i18n.t("error.sticky_events_required"), ErrorCode.STICKY_EVENTS_NOT_SUPPORTED, ErrorCategory.CONFIGURATION_ISSUE, - t("error.sticky_events_required_description"), + i18n.t("error.sticky_events_required_description"), ); } } @@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError { export class E2EENotSupportedError extends ElementCallError { public constructor() { super( - t("error.e2ee_unsupported"), + i18n.t("error.e2ee_unsupported"), ErrorCode.E2EE_NOT_SUPPORTED, ErrorCategory.CLIENT_CONFIGURATION, - t("error.e2ee_unsupported_description"), + i18n.t("error.e2ee_unsupported_description"), ); } } @@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN, undefined, @@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.OPEN_ID_ERROR, ErrorCategory.CONFIGURATION_ISSUE, undefined, @@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE, ErrorCategory.CONFIGURATION_ISSUE, - t("error.no_matrix_2_authorization_service"), + i18n.t("error.no_matrix_2_authorization_service"), // Properly set it as a cause for a better reporting on sentry error, ); @@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError { */ public constructor(e?: string) { super( - t("error.failed_to_start_livekit"), + i18n.t("error.failed_to_start_livekit"), ErrorCode.FAILED_TO_START_LIVEKIT, ErrorCategory.NETWORK_CONNECTIVITY, e, @@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError { export class InsufficientCapacityError extends ElementCallError { public constructor() { super( - t("error.insufficient_capacity"), + i18n.t("error.insufficient_capacity"), ErrorCode.INSUFFICIENT_CAPACITY_ERROR, ErrorCategory.UNKNOWN, - t("error.insufficient_capacity_description"), + i18n.t("error.insufficient_capacity_description"), ); } } @@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError { export class SFURoomCreationRestrictedError extends ElementCallError { public constructor() { super( - t("error.room_creation_restricted"), + i18n.t("error.room_creation_restricted"), ErrorCode.SFU_ERROR, ErrorCategory.CONFIGURATION_ISSUE, - t("error.room_creation_restricted_description"), + i18n.t("error.room_creation_restricted_description"), ); } } @@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError { export class PeerConnectionTimeoutError extends ElementCallError { public constructor() { super( - t("error.peer_connection_timeout"), + i18n.t("error.peer_connection_timeout"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); @@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError { export class LivekitConnectionError extends ElementCallError { public constructor(cause: ConnectionError) { super( - t("error.livekit_connection_error"), + i18n.t("error.livekit_connection_error"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 51bf2fb63..abe28f54f 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import i18next, { type i18n as I18nInstance } from "i18next"; + // Custom marker function to allow i18next extraction export const i18nKey = (key: string): string => key; + +/** + * Element Call's own i18next instance. + * + * We deliberately do not use the global i18next singleton: when Element Call is + * embedded in a host application (rather than running as its own page), that + * singleton belongs to the host, and configuring it would clobber the host's + * translations. + * + * It is configured by `Initializer.initBeforeReact` and made available to + * components via ``; tests and stories configure it directly. + * + * Non-React code should call `i18n.t(...)` on this instance. Components should + * use `useTranslation()` instead, so that they re-render on a language change. + * Note that `t` must be reached through the instance at call time — i18next + * only assigns it during `init()`, so destructuring it at module scope would + * capture an uninitialised function. + */ +export const i18n: I18nInstance = i18next.createInstance(); diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index be7179de4..00d289b54 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import "@formatjs/intl-durationformat/polyfill.js"; import "@formatjs/intl-segmenter/polyfill"; -import i18n from "i18next"; import posthog from "posthog-js"; import { initReactI18next } from "react-i18next"; import { afterEach } from "vitest"; @@ -18,8 +17,11 @@ import "@testing-library/jest-dom/vitest"; import EN from "../locales/en/app.json"; import { Config } from "./config/Config"; +import { i18n } from "./utils/i18n"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, tests register the instance as react-i18next's default rather +// than wrapping every render in an . i18n .use(initReactI18next) .init({ From 0a8c24bca86870862c37ac1139e8d2cd13fac71d Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:40:53 +0200 Subject: [PATCH 02/54] Allow the config to be supplied by an embedder Config.init() derives the location of config.json from window.location, which only makes sense while Element Call owns the page. When it is embedded in a host application the host owns the configuration, so add Config.initWith() to accept it directly. Share the defaulting and validation between both paths via resolveConfig(), so that an injected config behaves identically to a fetched one, and mark initialization as complete so that the init() calls already on the startup path resolve immediately instead of fetching over the top of it. initDefault() becomes initWith({}), which also stops it handing out a shallow copy of DEFAULT_CONFIG whose nested objects were shared with the module-level default. --- src/config/Config.test.ts | 53 +++++++++++++++++++++++++++++++++++++-- src/config/Config.ts | 49 ++++++++++++++++++++++++++++++------ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/config/Config.test.ts b/src/config/Config.test.ts index 34dd44cb7..5f8b09a27 100644 --- a/src/config/Config.test.ts +++ b/src/config/Config.test.ts @@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it, vi, afterEach } from "vitest"; import { logger } from "matrix-js-sdk/lib/logger"; -import { validateConfig } from "./Config"; -import { MatrixRTCMode } from "./ConfigOptions"; +import { Config, validateConfig } from "./Config"; +import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions"; describe("validateConfig", () => { afterEach(() => { @@ -52,3 +52,52 @@ describe("validateConfig", () => { expect(result.ssla).toBe("https://example.invalid/ssla"); }); }); + +describe("Config.initWith", () => { + // vitest.setup.ts has already called initDefault(), so every test here is + // free to re-initialize; the last call wins. + afterEach(() => { + vi.restoreAllMocks(); + Config.initDefault(); + }); + + it("makes the supplied config readable", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("fills in defaults for keys the embedder did not supply", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality); + }); + + it("validates the supplied config just as a fetched one would be", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + Config.initWith({ + matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode, + }); + expect(Config.get().matrix_rtc_mode).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("does not share nested state with DEFAULT_CONFIG", () => { + Config.initWith({}); + expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality); + }); + + it("stops a later init() from fetching over the top of it", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + Config.initWith({ ssla: "https://example.invalid/ssla" }); + + await Config.init(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("replaces a config initialized earlier", () => { + Config.initWith({ ssla: "https://first.invalid/ssla" }); + Config.initWith({ ssla: "https://second.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://second.invalid/ssla"); + }); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts index f52b28fde..37cdfc610 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -30,6 +30,14 @@ export class Config { return this.internalInstance.config; } + /** + * Initializes the config by fetching `config.json`, locating it relative to + * the current page. + * + * Does nothing if the config has already been initialized, including by + * {@link Config.initWith}, so that the regular startup path can run unchanged + * when an embedder has already supplied the config. + */ public static async init(): Promise { if (!Config.internalInstance?.initPromise) { const internalInstance = new Config(); @@ -50,17 +58,36 @@ export class Config { Config.internalInstance.initPromise = downloadConfig(fetchTarget).then( (config) => { - internalInstance.config = merge( - {}, - DEFAULT_CONFIG, - validateConfig(config), - ); + internalInstance.config = resolveConfig(config); }, ); } return Config.internalInstance.initPromise; } + /** + * Initializes the config from an object supplied by the embedder, instead of + * fetching `config.json`. + * + * {@link Config.init} derives the location of `config.json` from + * `window.location`, which only makes sense while Element Call owns the page. + * When it is embedded in a host application the host owns the configuration + * and passes it in here. + * + * The config goes through the same validation and defaulting as a fetched + * one, so that an injected config behaves identically to a hosted one. + * + * Replaces any config initialized earlier. + */ + public static initWith(config: ConfigOptions): void { + const internalInstance = new Config(); + internalInstance.config = resolveConfig(config); + // Mark initialization as already done, so that a later init() resolves + // immediately rather than fetching config.json over the top of this. + internalInstance.initPromise = Promise.resolve(); + Config.internalInstance = internalInstance; + } + /** * This is a alternative initializer that does not load anything * from a hosted config file but instead just initializes the config using the @@ -69,8 +96,7 @@ export class Config { * It is supposed to only be used in tests. (It is executed in `vite.setup.js`) */ public static initDefault(): void { - Config.internalInstance = new Config(); - Config.internalInstance.config = { ...DEFAULT_CONFIG }; + Config.initWith({}); } // Convenience accessors @@ -94,6 +120,15 @@ export class Config { private initPromise?: Promise; } +/** + * Applies validation and the built-in defaults to a config, however it was + * obtained. Deep-merges onto a fresh object so that the result never shares + * nested state with {@link DEFAULT_CONFIG}. + */ +function resolveConfig(config: ConfigOptions): ResolvedConfigOptions { + return merge({}, DEFAULT_CONFIG, validateConfig(config)); +} + export function validateConfig(config: ConfigOptions): ConfigOptions { const mode = config.matrix_rtc_mode; if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) { From 0dca5835f6087f0b2df0ab989ac4c96adb83b759 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:54:27 +0200 Subject: [PATCH 03/54] Route Element Call's DOM decoration through an injectable root element Element Call writes its theme classes, background and layout attributes straight onto document.body, and portals its modals there too. That is only correct while it owns the page; embedded in a host application it has to confine itself to the container it was mounted into. Add a RootElementContext, defaulting to document.body so that the standalone and widget builds are unaffected, and point the theme classes, data-background, no-scroll-body and the fullscreen target at it. Give the Modal and Toast portals an explicit container as well. Radix and vaul both default to document.body, so without this every modal, drawer and toast would render outside the container and lose the theme and platform attributes set on it. No functional change: the root element is document.body until an embedder provides otherwise. --- src/App.tsx | 8 ++++---- src/Modal.tsx | 6 ++++-- src/RootElementContext.ts | 30 ++++++++++++++++++++++++++++++ src/Toast.tsx | 8 +++++++- src/room/GroupCallView.tsx | 8 +++++--- src/tile/SpotlightTile.tsx | 8 ++++---- src/useTheme.ts | 12 +++++++----- 7 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 src/RootElementContext.ts diff --git a/src/App.tsx b/src/App.tsx index 36afc4c2f..55703f4c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; +import { useRootElement } from "./RootElementContext"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -44,12 +45,11 @@ interface SimpleProviderProps { const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); + const rootElement = useRootElement(); useEffect(() => { - document - .getElementsByTagName("body")[0] - .setAttribute("data-background", background); - }, [pathname, background]); + rootElement.setAttribute("data-background", background); + }, [pathname, background, rootElement]); return children; }; diff --git a/src/Modal.tsx b/src/Modal.tsx index e6ffdf450..46316d370 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web"; import styles from "./Modal.module.css"; import overlayStyles from "./Overlay.module.css"; import { useMediaQuery } from "./useMediaQuery"; +import { useRootElement } from "./RootElementContext"; export interface Props { title: string; @@ -78,6 +79,7 @@ export const Modal: FC = ({ ...rest }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); // Empirically, Chrome on Android can end up not matching (hover: none), but // still matching (pointer: coarse) :/ const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)"); @@ -100,7 +102,7 @@ export const Modal: FC = ({ onOpenChange={onOpenChange} dismissible={onDismiss !== undefined} > - + = ({ return ( - + diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts new file mode 100644 index 000000000..b97b17796 --- /dev/null +++ b/src/RootElementContext.ts @@ -0,0 +1,30 @@ +/* +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 { createContext, use } from "react"; + +/** + * The element that Element Call treats as the root of its own interface. + * + * Element Call decorates this element with the theme, layout and background + * attributes its stylesheets key off, and portals its modals into it. When + * Element Call owns the page this is simply the document body; when it is + * embedded in a host application it is the container the host mounted it into, + * so that Element Call does not reach outside its own subtree. + */ +const RootElementContext = createContext(null); + +export const RootElementProvider = RootElementContext.Provider; + +/** + * The element Element Call should decorate and portal into. + * + * Defaults to the document body, so that the standalone and widget builds work + * without a provider. + */ +export const useRootElement = (): HTMLElement => + use(RootElementContext) ?? document.body; diff --git a/src/Toast.tsx b/src/Toast.tsx index 83e220bc1..0a2a2f1b8 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web"; import styles from "./Toast.module.css"; import overlayStyles from "./Overlay.module.css"; +import { useRootElement } from "./RootElementContext"; interface Props { /** @@ -64,6 +65,7 @@ export const Toast: FC = ({ Icon, modal = true, }) => { + const rootElement = useRootElement(); const onOpenChange = useCallback( (open: boolean) => { if (!open) onDismiss(); @@ -104,7 +106,11 @@ export const Toast: FC = ({ return ( - {modal ? {content} : content} + {modal ? ( + {content} + ) : ( + content + )} ); }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index fbd589e78..dfeb0866a 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -80,6 +80,7 @@ import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { useRootElement } from "../RootElementContext.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -123,6 +124,7 @@ export const GroupCallView: FC = ({ null, ); const memberships = useMatrixRTCSessionMemberships(rtcSession); + const rootElement = useRootElement(); const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -150,11 +152,11 @@ export const GroupCallView: FC = ({ // viewport sizes smaller than 122px width. (It is actually this exact number: 122px // tested on different devices...) useEffect(() => { - document.body.classList.add("no-scroll-body"); + rootElement.classList.add("no-scroll-body"); return (): void => { - document.body.classList.remove("no-scroll-body"); + rootElement.classList.remove("no-scroll-body"); }; - }, []); + }, [rootElement]); useEffect(() => { window.rtcSession = rtcSession; diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 036e044fe..97e1f4a62 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -54,6 +54,7 @@ import { Slider } from "../Slider"; import { platform } from "../Platform"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { RingingStatus } from "./RingingStatus"; +import { useRootElement } from "../RootElementContext"; interface SpotlightItemBaseProps { ref?: Ref; @@ -414,6 +415,7 @@ export const SpotlightTile: FC = ({ style, }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); @@ -428,24 +430,22 @@ export const SpotlightTile: FC = ({ const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const isFullscreen = useCallback((): boolean => { - const rootElement = document.body; if (rootElement && document.fullscreenElement) return true; return false; - }, []); + }, [rootElement]); const FullScreenIcon = isFullscreen() ? FullScreenMinimiseIcon : FullScreenMaximiseIcon; const onToggleFullscreen = useCallback(() => { - const rootElement = document.body; if (!rootElement) return; if (isFullscreen()) { void document?.exitFullscreen(); } else { void rootElement.requestFullscreen(); } - }, [isFullscreen]); + }, [isFullscreen, rootElement]); // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run diff --git a/src/useTheme.ts b/src/useTheme.ts index e992aee7b..5bac28982 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -11,12 +11,14 @@ import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { getUrlParams } from "./UrlParams"; import { widget } from "./widget"; +import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { + const rootElement = useRootElement(); const [requestedTheme, setRequestedTheme] = useState( () => getUrlParams().theme, ); - const previousTheme = useRef(document.body.classList.item(0)); + const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { if (widget) { @@ -47,15 +49,15 @@ export const useTheme = (): void => { : ""; const themeString = "cpd-theme-" + theme + themeHighContrast; if (themeString !== previousTheme.current) { - document.body.classList.remove( + rootElement.classList.remove( "cpd-theme-light", "cpd-theme-dark", "cpd-theme-light-hc", "cpd-theme-dark-hc", ); - document.body.classList.add(themeString); + rootElement.classList.add(themeString); previousTheme.current = themeString; } - document.body.classList.remove("no-theme"); - }, [previousTheme, requestedTheme]); + rootElement.classList.remove("no-theme"); + }, [previousTheme, requestedTheme, rootElement]); }; From bc6015eff2454b0735b78d4a3f0c1e61522a6791 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 09:58:34 +0200 Subject: [PATCH 04/54] Supply Element Call's parameters through a context Element Call's parameters come from its URL, which works while it owns the page but leaves an embedder with nowhere to put them. Add a context so they can be provided directly, falling back to parsing window.location when no provider is present. This also decouples the thirteen consumers from react-router: useUrlParams called useLocation, so each of them required a router ancestor, which the embedded build will not have. The standalone and widget builds keep their URL-derived behaviour via useUrlParamsFromLocation, provided in App. No functional change. --- src/App.tsx | 46 ++++++++++++++++++++++++++++++++-------------- src/UrlParams.ts | 33 +++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 55703f4c0..511577dee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,13 @@ import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; -import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; +import { + getUrlParams, + HeaderStyle, + UrlParamsProvider, + useUrlParams, + useUrlParamsFromLocation, +} from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; import { useRootElement } from "./RootElementContext"; @@ -42,6 +48,16 @@ interface SimpleProviderProps { children: JSX.Element; } +/** + * Supplies the URL-derived params to the rest of the app. Only the standalone + * and widget builds own the URL, so this lives here in the app shell rather + * than alongside the context itself. + */ +const LocationUrlParamsProvider: FC = ({ children }) => { + const urlParams = useUrlParamsFromLocation(); + return {children}; +}; + const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); @@ -102,19 +118,21 @@ export const App: FC = ({ vm }) => { return ( - - - - - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} - - - - + + + + + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + + + + + ); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 805cab710..dd83a4966 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { useMemo } from "react"; +import { createContext, use, useMemo } from "react"; import { useLocation } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; import { @@ -519,11 +519,36 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { }; }; +const UrlParamsContext = createContext(null); + /** - * Hook to simplify use of getUrlParams. - * @returns The app parameters for the current URL + * Supplies the parameters Element Call should run with. + * + * The standalone and widget builds derive these from the URL, but an embedder + * has no URL of its own to put them in, so it provides them directly instead. + * + * TODO: `UrlParams` is no longer an accurate name now that these need not come + * from a URL. Renaming it touches every consumer, so it is left until the rest + * of the de-globalisation work has settled. */ -export const useUrlParams = (): UrlParams => { +export const UrlParamsProvider = UrlParamsContext.Provider; + +/** + * The parameters Element Call is running with. + * + * Falls back to parsing `window.location` when no provider is present, so that + * tests and stories keep working without one. + */ +export const useUrlParams = (): UrlParams => + use(UrlParamsContext) ?? getUrlParams(); + +/** + * Derives {@link UrlParams} from the current router location. + * + * Only meaningful when Element Call owns the URL; embedders provide the params + * directly through {@link UrlParamsProvider}. + */ +export const useUrlParamsFromLocation = (): UrlParams => { const { search, hash } = useLocation(); return useMemo(() => getUrlParams(search, hash), [search, hash]); }; From 6e44332fd1ada38406a6890f0b77fe7267ea50b6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 10:31:48 +0200 Subject: [PATCH 05/54] Stop reading URL parameters from inside the call path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view models reached for getUrlParams() — and so window.location — from deep inside the call path: CallViewModel, MediaDevices, Publisher, LocalMember and the footer view model. An embedded Element Call has no URL of its own, so these values have to arrive as arguments instead. Add the relevant options to CallViewModelOptions, to the MediaDevices and Publisher constructors, to createLocalMembership$ and enterRTCSession, and to createCallFooterViewModel. The remaining React consumers read the context added in the previous commit. AppViewModel now takes its audio output options too, moving that URL read out to main.tsx, where the app shell can act as the adapter. The new CallViewModelOptions fields are optional, defaulting to what the URL parameters resolve to outside widget mode; the MediaDevices and Publisher arguments are required, so that every construction site has to be explicit. useTheme.test.ts mocked the UrlParams module with a factory, so it needed updating to mock the hook rather than getUrlParams. No functional change. --- sdk/main.ts | 13 ++++-- src/App.tsx | 25 ++++------- src/components/CallFooter.stories.tsx | 4 +- src/components/CallFooterViewModel.test.ts | 3 ++ src/components/CallFooterViewModel.tsx | 7 ++- .../MediaMuteAndSwitchButton.stories.tsx | 4 +- src/main.tsx | 11 ++++- src/room/GroupCallView.tsx | 10 ++--- src/room/InCallView.tsx | 19 +++++++- src/state/AppViewModel.ts | 12 +++-- src/state/CallViewModel/CallViewModel.test.ts | 5 ++- src/state/CallViewModel/CallViewModel.ts | 44 ++++++++++++++++--- .../localMember/LocalMember.test.ts | 1 + .../CallViewModel/localMember/LocalMember.ts | 26 ++++++++--- .../localMember/Publisher.test.ts | 3 ++ .../CallViewModel/localMember/Publisher.ts | 6 ++- src/state/MediaDevices.ts | 31 ++++++++++--- src/useTheme.test.ts | 8 ++-- src/useTheme.ts | 7 ++- src/utils/test-viewmodel.ts | 2 + 20 files changed, 176 insertions(+), 65 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index a001af65c..55aa4a022 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -116,7 +116,7 @@ export async function createMatrixRTCSdk( logger.info("client created"); // url params - const { roomId } = getUrlParams(); + const { roomId, controlledAudioDevices, callIntent } = getUrlParams(); if (roomId === null) throw Error("could not get roomId from url params"); const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); @@ -128,7 +128,10 @@ export async function createMatrixRTCSdk( const rtcSession = rtcSessionManager.getRoomSession(room); // media devices - const mediaDevices = new MediaDevices(scope); + const mediaDevices = new MediaDevices(scope, { + controlledAudioDevices, + callIntent, + }); const muteStates = new MuteStates(scope, mediaDevices, { audioEnabled: false, videoEnabled: false, @@ -141,7 +144,11 @@ export async function createMatrixRTCSdk( room, mediaDevices, muteStates, - { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } }, + { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + controlledAudioDevices, + callIntent, + }, of({}), of({}), constant({ supported: false, processor: undefined }), diff --git a/src/App.tsx b/src/App.tsx index 511577dee..238815a7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - type JSX, - Suspense, - useEffect, - useMemo, - useState, -} from "react"; +import { type FC, type JSX, Suspense, useEffect, useState } from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -32,7 +25,6 @@ import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { - getUrlParams, HeaderStyle, UrlParamsProvider, useUrlParams, @@ -75,6 +67,12 @@ const ThemeProvider: FC = ({ children }) => { return children; }; +/** Wraps the app in an {@link AppBar}, if the params ask for one. */ +const MaybeAppBar: FC = ({ children }) => { + const { header } = useUrlParams(); + return header === HeaderStyle.AppBar ? {children} : children; +}; + interface Props { vm: AppViewModel; } @@ -91,9 +89,6 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - // Since we are outside the router component, we cannot use useUrlParams here - const { header } = useMemo(getUrlParams, []); - const content = loaded ? ( @@ -123,11 +118,7 @@ export const App: FC = ({ vm }) => { - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} + {content} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 667cb6070..3c3f46074 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -29,7 +29,9 @@ const reactionData = { reactions$: new BehaviorSubject({}), }; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); /** * A wrapper component that is used for: diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 1fc9187ad..9e73393be 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types"; import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { DeviceLabel } from "../state/MediaDevices"; import { createCallFooterViewModel } from "./CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$.value).toEqual([]); @@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$?.value).toEqual([ diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 7e391b169..a2ca6c88e 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior"; import type { ObservableScope } from "../state/ObservableScope"; import { type MuteStates } from "../state/MuteStates"; import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; -import { getUrlParams, HeaderStyle } from "../UrlParams"; +import { HeaderStyle } from "../UrlParams"; import { platform } from "../Platform"; import { type FooterSnapshot } from "./CallFooter"; @@ -138,6 +138,8 @@ function buildDeviceBehaviors( * @param mediaDevices - Available and selected input devices. * @param reactionIdentifier - The local user's reaction identifier string, or * undefined when reactions are not supported (hides the reaction button). + * @param options - `showControls`: whether the call controls should be shown. + * `header`: the style of header, which decides whether to show the logo. */ export function createCallFooterViewModel( scope: ObservableScope, @@ -145,8 +147,9 @@ export function createCallFooterViewModel( muteStates: MuteStates, mediaDevices: MediaDevices, reactionIdentifier: string | undefined, + options: { showControls: boolean; header: HeaderStyle }, ): ViewModel { - const { showControls, header: headerStyle } = getUrlParams(); + const { showControls, header: headerStyle } = options; const showLogo = headerStyle === HeaderStyle.Standard; const isPip$ = scope.behavior( diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 89c123929..21def4007 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); const meta = { component: MediaMuteAndSwitchButton, diff --git a/src/main.tsx b/src/main.tsx index 8f64c680a..03c74c94c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake"; import { Initializer } from "./initializer"; import { AppViewModel } from "./state/AppViewModel"; import { globalScope } from "./state/ObservableScope"; +import { getUrlParams } from "./UrlParams"; initRageshake().catch((e) => { logger.error("Failed to initialize rageshake", e); @@ -49,9 +50,17 @@ if (fatalError !== null) { Initializer.initBeforeReact() .then(() => { + const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( - + , ); }) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfeb0866a..57e9205f1 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -54,12 +54,7 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; -import { - getUrlParams, - HeaderStyle, - type UrlParams, - useUrlParams, -} from "../UrlParams"; +import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; import { @@ -406,7 +401,7 @@ export const GroupCallView: FC = ({ } // On a normal user hangup we can shut down and close the widget. But if an // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !getUrlParams().returnToLobby) { + if (reason != "error" && !returnToLobby) { try { await widget.api.transport.send(ElementWidgetActions.Close, {}); } catch (e) { @@ -425,6 +420,7 @@ export const GroupCallView: FC = ({ rtcSession, isPasswordlessUser, confineToRoom, + returnToLobby, navigate, ], ); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a57dcce2b..e291e001b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -122,8 +122,16 @@ export const ActiveCall: FC = (props) => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); - const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = - urlParams; + const { + autoLeaveWhenOthersLeft, + waitForCallPickup, + sendNotificationType, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + callIntent, + } = urlParams; const vm = createCallViewModel$( scope, @@ -136,6 +144,12 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + sendNotificationType, + callIntent, }, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -172,6 +186,7 @@ export const ActiveCall: FC = (props) => { props.muteStates, mediaDevices, `${props.client.getUserId()}:${props.client.getDeviceId()}`, + { showControls: urlParams.showControls, header: urlParams.header }, ); setFooterVm(footerVm); setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm)); diff --git a/src/state/AppViewModel.ts b/src/state/AppViewModel.ts index 7ad91e9dc..3f69515b2 100644 --- a/src/state/AppViewModel.ts +++ b/src/state/AppViewModel.ts @@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { MediaDevices } from "./MediaDevices"; +import { type AudioOutputOptions, MediaDevices } from "./MediaDevices"; import { type ObservableScope } from "./ObservableScope"; /** * The top-level state holder for the application. */ export class AppViewModel { - public readonly mediaDevices = new MediaDevices(this.scope); + public readonly mediaDevices = new MediaDevices( + this.scope, + this.audioOutputOptions, + ); // TODO: Move more application logic here. The CallViewModel, at the very // least, ought to be accessible from this object. - public constructor(private readonly scope: ObservableScope) {} + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 181549171..7a27fd3e0 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1593,12 +1593,13 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { it.skip("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { - getUrlParams.mockReturnValue({ controlledAudioDevices: true }); vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( of([]), ); - const devices = new MediaDevices(testScope()); + const devices = new MediaDevices(testScope(), { + controlledAudioDevices: true, + }); window.controls.setAvailableAudioDevices([ { id: "speaker", name: "Speaker", isSpeaker: true }, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 09f73d1a6..e9abeae59 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -45,6 +45,8 @@ import { MembershipManagerEvent, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; @@ -85,7 +87,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { getUrlParams, HeaderStyle } from "../../UrlParams"; +import { HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { @@ -171,6 +173,23 @@ import { type GridTileViewModel } from "../TileViewModel.ts"; // callMembership -> rtcMembership export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; + /** + * Whether the app hosting Element Call controls the audio output devices, + * rather than the browser. Defaults to false. + */ + controlledAudioDevices?: boolean; + /** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */ + header?: HeaderStyle; + /** Whether the call controls should be shown. Defaults to true. */ + showControls?: boolean; + /** Whether to hide the screen-sharing button. Defaults to false. */ + hideScreensharing?: boolean; + /** + * Whether and what kind of notification to send when joining the call. + */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; autoLeaveWhenOthersLeft?: boolean; /** * If the call is started in a way where we want it to behave like a telephone usecase @@ -435,6 +454,17 @@ export function createCallViewModel$( if (!(userId && deviceId)) throw new UnknownCallError(new Error("userId and deviceId are required")); + // Defaults match what the URL parameters resolve to outside of widget mode, + // so that callers which don't care (chiefly tests) behave as they always have. + const { + controlledAudioDevices = false, + header = HeaderStyle.Standard, + showControls = true, + hideScreensharing = false, + sendNotificationType, + callIntent, + } = options; + const livekitKeyProvider = getE2eeKeyProvider( options.encryptionSystem, matrixRTCSession, @@ -523,7 +553,7 @@ export function createCallViewModel$( mediaDevices, trackProcessorState$, livekitKeyProvider, - getUrlParams().controlledAudioDevices, + controlledAudioDevices, options.livekitRoomFactory, ); @@ -563,6 +593,8 @@ export function createCallViewModel$( encryptMedia: livekitKeyProvider !== undefined, // TODO. This might need to get called again on each change of matrixRTCMode... matrixRTCMode: mode, + sendNotificationType, + callIntent, })), ), ); @@ -592,12 +624,14 @@ export function createCallViewModel$( logger.getChild( "[Publisher " + connection.transport.livekit_service_url + "]", ), + controlledAudioDevices, ); }, connectionManager, matrixRTCSession, localTransport$, roomId: matrixRoom.roomId, + hideScreensharing, logger: logger.getChild(`[${Date.now()}]`), }); @@ -1457,9 +1491,8 @@ export function createCallViewModel$( ), ); - const urlParams = getUrlParams(); const showFooterUrlParams = !( - urlParams.header === HeaderStyle.None && urlParams.showControls === false + header === HeaderStyle.None && showControls === false ); const showFooter$ = scope.behavior( naturallyShowFooter$.pipe( @@ -1778,8 +1811,7 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, ringingVm$: ringingMedia$, - ringingStatusLocation: - urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile", + ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile", leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 9ea6bb72c..2b89d50b5 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -199,6 +199,7 @@ describe("LocalMembership", () => { rtsSession$: constant(RTCMemberStatus.Connected), }, roomId: "!test-room-id:example.org", + hideScreensharing: false, }; it("throws error on missing RTC config error", () => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 2f4fde26b..844b66dee 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -20,6 +20,8 @@ import { type LivekitTransport, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, @@ -52,7 +54,7 @@ import { UnknownCallError, } from "../../../utils/errors.ts"; import { ElementWidgetActions, widget } from "../../../widget.ts"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { advancedScreenShare, @@ -141,6 +143,8 @@ interface Props { MatrixRTCSession, "updateCallIntent" | "leaveRoomSession" >; + /** Whether to hide the screen-sharing button. */ + hideScreensharing: boolean; logger: Logger; } @@ -160,6 +164,7 @@ interface Props { * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. * @param props.roomId The room ID used as the call identifier in analytics events. + * @param props.hideScreensharing Whether to hide the screen-sharing button. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -178,6 +183,7 @@ export const createLocalMembership$ = ({ muteStates, matrixRTCSession, roomId, + hideScreensharing, }: Props): { /** * This request to start audio and video tracks. @@ -709,7 +715,7 @@ export const createLocalMembership$ = ({ let toggleScreenSharing: (() => void) | null = null; if ( "getDisplayMedia" in (navigator.mediaDevices ?? {}) && - !getUrlParams().hideScreensharing + !hideScreensharing ) { toggleScreenSharing = (): void => { const screenshareSettings: ScreenShareCaptureOptions = { @@ -820,6 +826,10 @@ export function observeSharingScreen$(p: Participant): Observable { interface EnterRTCSessionOptions { encryptMedia: boolean; matrixRTCMode: MatrixRTCMode; + /** Whether and what kind of notification to send when joining. */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; } /** @@ -832,7 +842,9 @@ interface EnterRTCSessionOptions { * @param rtcSession - The MatrixRTCSession to join. * @param ownMembershipIdentity - Options for entering the RTC session. * @param transport - The LivekitTransport to use for this session. - * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use. + * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The + * Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of + * notification to send on join. `callIntent`: The kind of call being placed. * @throws If the widget could not send ElementWidgetActions.JoinCall action. */ // Exported for unit testing @@ -842,7 +854,12 @@ export function enterRTCSession( transport: LivekitTransportConfig, options: EnterRTCSessionOptions, ): void { - const { encryptMedia, matrixRTCMode } = options; + const { + encryptMedia, + matrixRTCMode, + sendNotificationType: notificationType, + callIntent, + } = options; PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); @@ -851,7 +868,6 @@ export function enterRTCSession( // groupCallOTelMembership?.onJoinCall(); const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); - const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode === MatrixRTCMode.Compatibility || matrixRTCMode === MatrixRTCMode.Matrix_2_0; diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58d..5d9f03784 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -192,6 +192,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); @@ -309,6 +310,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); afterEach(async () => { @@ -364,6 +366,7 @@ describe("Bug fix", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); audioEnabled$.next(true); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a6..5353a98d4 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -29,7 +29,7 @@ import { type ProcessorState, trackProcessorSync, } from "../../../livekit/TrackProcessorContext.tsx"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; @@ -56,6 +56,8 @@ export class Publisher { * @param muteStates - The mute states for audio and video. * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). * @param logger - The logger to use for logging :D. + * @param controlledAudioDevices - Whether the app hosting Element Call + * controls the audio output devices, rather than the browser. */ public constructor( private connection: Pick, //setE2EEEnabled, @@ -63,8 +65,8 @@ export class Publisher { private readonly muteStates: MuteStates, trackerProcessorState$: Behavior, private logger: Logger, + controlledAudioDevices: boolean, ) { - const { controlledAudioDevices } = getUrlParams(); const room = connection.livekitRoom; room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf5..4610bab66 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -16,6 +16,7 @@ import { } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import { alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, @@ -25,7 +26,6 @@ import { } from "../settings/settings"; import { type ObservableScope } from "./ObservableScope"; import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls"; -import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; @@ -338,6 +338,22 @@ class VideoInput implements MediaDevice { } } +/** + * How Element Call should manage audio output. + */ +export interface AudioOutputOptions { + /** + * Whether the list of output devices is controlled by the app hosting Element + * Call, through the global JS controls, rather than by the browser. + */ + controlledAudioDevices: boolean; + /** + * The kind of call being placed, which decides the initial output route when + * the host controls the devices. + */ + callIntent?: RTCCallIntent; +} + export class MediaDevices { private readonly deviceNamesRequest$ = new Subject(); /** @@ -368,23 +384,28 @@ export class MediaDevices { public readonly audioOutput: MediaDevice< AudioOutputDeviceLabel, SelectedAudioOutputDevice - > = getUrlParams().controlledAudioDevices + > = this.audioOutputOptions.controlledAudioDevices ? platform == "android" ? new AndroidControlledAudioOutput( controlledAvailableOutputDevices$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, window.controls, ) : new IOSControlledAudioOutput( this.usingNames$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, ) : new AudioOutput(this.usingNames$, this.scope); public readonly videoInput: MediaDevice = new VideoInput(this.usingNames$, this.scope); - public constructor(private readonly scope: ObservableScope) {} + // Note: both parameters are read by the field initializers above, which is + // safe because TypeScript assigns parameter properties before running them. + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/useTheme.test.ts b/src/useTheme.test.ts index 6e4714626..4078ed346 100644 --- a/src/useTheme.test.ts +++ b/src/useTheme.test.ts @@ -19,10 +19,10 @@ import EventEmitter from "events"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { useTheme } from "./useTheme"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; -vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() })); +vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() })); vi.mock("./widget", () => ({ widget: { api: { transport: { reply: vi.fn() } }, @@ -39,7 +39,7 @@ describe("useTheme", () => { vi.spyOn(originalClassList, "add"); vi.spyOn(originalClassList, "remove"); vi.spyOn(originalClassList, "item").mockReturnValue(null); - (getUrlParams as Mock).mockReturnValue({ theme: "dark" }); + (useUrlParams as Mock).mockReturnValue({ theme: "dark" }); }); afterEach(() => { @@ -53,7 +53,7 @@ describe("useTheme", () => { { setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] }, ])("apply procedure", ({ setTheme, add }) => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { - (getUrlParams as Mock).mockReturnValue({ theme: setTheme }); + (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); renderHook(() => useTheme()); diff --git a/src/useTheme.ts b/src/useTheme.ts index 5bac28982..85dace2aa 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -9,15 +9,14 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { type IThemeChangeActionRequest } from "matrix-widget-api"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { const rootElement = useRootElement(); - const [requestedTheme, setRequestedTheme] = useState( - () => getUrlParams().theme, - ); + const { theme } = useUrlParams(); + const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index f53910024..8e88e720c 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -40,6 +40,7 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; import { MatrixRTCMode } from "../config/ConfigOptions"; import { createCallFooterViewModel } from "../components/CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; import { type FooterSnapshot } from "../components/CallFooter"; import { type ViewModel } from "../state/ViewModel"; import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel"; @@ -187,6 +188,7 @@ export function getBasicCallViewModelEnvironment( muteStates, mediaDevices, "reactionId", + { showControls: true, header: HeaderStyle.Standard }, ); return { vm, From d8d70812b847fa54c3b1adc9e3d787d5a3270443 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 11:00:42 +0200 Subject: [PATCH 06/54] Read the shared room key from the parameters context useRoomEncryptionSystem is used from GroupCallView, inside the part of Element Call that will become the embeddable component, but it reached for getUrlParams() via getKeyForRoom(). Extract the lookup into a helper taking the room ID and password explicitly: the hook supplies them from the parameters context, while getKeyForRoom keeps reading the URL for its one remaining caller in the app shell. No functional change. --- src/e2ee/sharedKeyManagement.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/e2ee/sharedKeyManagement.ts b/src/e2ee/sharedKeyManagement.ts index 18d007e2b..b29ede319 100644 --- a/src/e2ee/sharedKeyManagement.ts +++ b/src/e2ee/sharedKeyManagement.ts @@ -12,7 +12,7 @@ import { setLocalStorageItemReactive, useLocalStorage, } from "../useLocalStorage"; -import { getUrlParams } from "../UrlParams"; +import { getUrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "./e2eeType"; import { useClient } from "../ClientContext"; @@ -57,19 +57,31 @@ const useRoomSharedKey = ( return [setInitialValue ?? roomSharedKey, setRoomSharedKey]; }; -export function getKeyForRoom(roomId: string): string | null { - const { roomId: urlRoomId, password } = getUrlParams(); - if (roomId !== urlRoomId) +/** + * The shared key for a room, preferring one supplied in the parameters Element + * Call was started with over whatever is in local storage. + */ +function keyForRoom( + roomId: string, + paramsRoomId: string | null, + password: string | null, +): string | null { + if (roomId !== paramsRoomId) logger.warn( "requested key for a roomId which is not the current call room id (from the URL)", roomId, - urlRoomId, + paramsRoomId, ); return ( password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) ); } +export function getKeyForRoom(roomId: string): string | null { + const { roomId: paramsRoomId, password } = getUrlParams(); + return keyForRoom(roomId, paramsRoomId, password); +} + export type Unencrypted = { kind: E2eeType.NONE }; export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string }; export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT }; @@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE; export function useRoomEncryptionSystem(roomId: string): EncryptionSystem { const { client } = useClient(); + const { roomId: paramsRoomId, password } = useUrlParams(); const [storedPassword] = useRoomSharedKey( + // TODO: this passes an already-prefixed key where a room ID is expected, so + // the local storage key ends up prefixed twice and never matches what + // saveKeyForRoom writes. Preserved as-is here to keep this commit a pure + // refactor; the reactive read is effectively dead until it is fixed. getRoomSharedKeyLocalStorageKey(roomId), - getKeyForRoom(roomId) ?? undefined, + keyForRoom(roomId, paramsRoomId, password) ?? undefined, ); const room = client?.getRoom(roomId); From 8e78ac76bd8df8631763e4e9af23901352c08000 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 13:03:23 +0200 Subject: [PATCH 07/54] Drop ErrorView's widget prop in favour of the host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorView needed a widget only to decide whether to offer a close button or a link home. That prop was threaded down through ErrorPage, RichError and GroupCallErrorBoundary from seven call sites, to answer the single question of whether the host can dismiss Element Call — which the host bridge now answers directly through the presence of close(). Read the bridge from context in ErrorView and remove the prop, along with the plumbing that carried it. No functional change: the rendered output is unchanged, as the existing snapshot confirms. Move GroupCallView onto the host bridge GroupCallView asked the widget API to keep it on screen, to close it, and to tell it when a preloaded call should join. Route all three through the host bridge and drop the widget prop. --- src/App.tsx | 43 ++-- src/Avatar.test.tsx | 63 ++---- src/Avatar.tsx | 34 +-- src/ClientContext.tsx | 2 +- src/ErrorView.tsx | 32 ++- src/FullScreenView.tsx | 5 +- src/HostBridge.test.ts | 101 +++++++++ src/HostBridge.ts | 214 ++++++++++++++++++ src/RichError.tsx | 7 +- src/home/HomePage.tsx | 3 +- src/room/GroupCallErrorBoundary.test.tsx | 58 ++--- src/room/GroupCallErrorBoundary.tsx | 9 +- src/room/GroupCallView.test.tsx | 132 ++++------- src/room/GroupCallView.tsx | 120 +++++----- src/room/RoomPage.tsx | 7 +- .../GroupCallErrorBoundary.test.tsx.snap | 2 +- src/useTheme.test.ts | 43 ++-- src/useTheme.ts | 32 +-- 18 files changed, 542 insertions(+), 365 deletions(-) create mode 100644 src/HostBridge.test.ts create mode 100644 src/HostBridge.ts diff --git a/src/App.tsx b/src/App.tsx index 238815a7b..1d51f9598 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,12 @@ import { import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; import { useRootElement } from "./RootElementContext"; +import { + createWidgetHostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; +import { useInitial } from "./useInitial"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -78,13 +84,18 @@ interface Props { } export const App: FC = ({ vm }) => { + // The standalone build has no host; the widget build's host is the client it + // is a widget of. + const hostBridge = useInitial(() => + widget === null ? nullHostBridge : createWidgetHostBridge(widget), + ); const [loaded, setLoaded] = useState(false); useEffect(() => { Initializer.init() ?.then(async () => { if (loaded) return; setLoaded(true); - await widget?.api.sendContentLoaded(); + await hostBridge.contentLoaded(); }) .catch(logger.error); }); @@ -94,7 +105,7 @@ export const App: FC = ({ vm }) => { } + fallback={(error) => } > } /> @@ -112,19 +123,21 @@ export const App: FC = ({ vm }) => { return ( - - - - - - - {content} - - - - - - + + + + + + + + {content} + + + + + + + ); }; diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 1e32de0e0..c5d5e25af 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC, type PropsWithChildren } from "react"; -import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext"; -import { Avatar, getAvatarFromWidgetAPI } from "./Avatar"; +import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import { widget } from "./widget"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; + hostBridge?: HostBridge; }> -> = ({ client, children }) => { +> = ({ client, hostBridge = nullHostBridge, children }) => { return ( - {children} + {children} ); }; -vi.mock("./widget", () => ({ - widget: { - api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough - }, -})); - afterEach(() => { vi.unstubAllGlobals(); }); @@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () => }); }); -test("should attempt to use widget API if running as a widget", async () => { +test("should download media through the host when it offers to", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => { getAccessToken: () => undefined, } as unknown as MatrixClient); - widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; - vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); + const downloadMedia = vi.fn().mockResolvedValue(theBlob); + const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia }; const member = mockMatrixRoomMember( mockRtcMembership("@alice:example.org", "AAAA"), { @@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => { ); const displayName = "Alice"; render( - + { document.querySelector(`img[src='${expectedObjectURL}']`), ); - expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl); -}); - -test("Supports download files as base64", async () => { - const expectedMXCUrl = "mxc://example.org/alice-avatar"; - const expectedBase64 = - "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" + - "+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" + - "ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" + - "UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" + - "Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" + - "oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" + - "A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" + - "nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" + - "ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" + - "Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" + - "2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" + - "M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" + - "kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" + - "uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" + - "XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" + - "YII="; - const mockWidgetAPI = { - downloadFile: vi.fn().mockImplementation(async (contentUri) => { - if (contentUri !== expectedMXCUrl) { - return Promise.reject(new Error("Unexpected content URI")); - } - return { file: expectedBase64 }; - }), - } as unknown as WidgetApi; - - const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl); - - expect(blob).toBeInstanceOf(Blob); + expect(downloadMedia).toBeCalledWith(expectedMXCUrl); }); diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 99940540d..185ae97b4 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,10 +14,9 @@ import { } from "react"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; -import { type WidgetApi } from "matrix-widget-api"; import { useClientState } from "./ClientContext"; -import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; export enum Size { XS = "xs", @@ -76,6 +75,7 @@ export const Avatar: FC = ({ ...props }) => { const clientState = useClientState(); + const hostBridge = useHostBridge(); const sizePx = useMemo( () => @@ -87,7 +87,8 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); - // In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs. + // In theory, a change in `clientState` or `sizePx` could run extra media + // downloads, but in practice they should be stable long before this code runs. useEffect(() => { if (!src) { setAvatarUrl(undefined); @@ -96,8 +97,8 @@ export const Avatar: FC = ({ let blob: Promise; - if (widget?.api) { - blob = getAvatarFromWidgetAPI(widget.api, src); + if (hostBridge.downloadMedia) { + blob = hostBridge.downloadMedia(src); } else if ( clientState?.state === "valid" && clientState.authenticated?.client && @@ -132,7 +133,7 @@ export const Avatar: FC = ({ URL.revokeObjectURL(objectUrl); } }; - }, [clientState, src, sizePx]); + }, [clientState, hostBridge, src, sizePx]); return ( { - const response = await api.downloadFile(src); - const file = response.file; - - // element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that - if (file instanceof Blob) { - return file; - } else if (typeof file === "string") { - // it is a base64 string - const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0)); - return new Blob([bytes]); - } - throw new Error( - "Downloaded file format is not supported: " + typeof file + "", - ); -} diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index f2ff3dd4b..288665c59 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -339,7 +339,7 @@ export const ClientProvider: FC = ({ children }) => { }, [initClientState, onSync]); if (alreadyOpenedErr) { - return ; + return ; } return {children}; diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index 1309ae046..519089831 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton"; import styles from "./ErrorView.module.css"; import { useUrlParams } from "./UrlParams"; import { LinkButton } from "./button"; -import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts"; +import { useHostBridge } from "./HostBridge.ts"; interface Props { Icon: ComponentType>; @@ -38,7 +38,6 @@ interface Props { */ fatal?: boolean; children: ReactNode; - widget: WidgetHelpers | null; } export const ErrorView: FC = ({ @@ -47,32 +46,27 @@ export const ErrorView: FC = ({ rageshake, fatal, children, - widget, }) => { const { t } = useTranslation(); const { confineToRoom } = useUrlParams(); + const hostBridge = useHostBridge(); const onReload = useCallback(() => { window.location.href = "/"; }, []); - const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({ - widget, + const CloseButton: FC<{ close: () => Promise }> = ({ + close, }): ReactElement => { - // in widget mode we don't want to show the return home button but a close button - const closeWidget = (): void => { - widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => { - // What to do here? - logger.error("Failed to send close action", e); - }) - .finally(() => { - widget.api.transport.stop(); - }); + // When the host can dismiss us, offer that instead of a link home + const onClose = (): void => { + close().catch((e) => { + // What to do here? + logger.error("Failed to ask the host to close Element Call", e); + }); }; return ( - ); @@ -108,8 +102,8 @@ export const ErrorView: FC = ({ {rageshake && ( )} - {widget ? ( - + {hostBridge.close ? ( + ) : ( !confineToRoom && )} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index eb84010e5..ea2a4a0d2 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css"; import { useUrlParams } from "./UrlParams"; import { RichError } from "./RichError"; import { ErrorView } from "./ErrorView"; -import { type WidgetHelpers } from "./widget.ts"; interface FullScreenViewProps { className?: string; @@ -48,12 +47,11 @@ export const FullScreenView: FC = ({ interface ErrorPageProps { error: unknown; - widget: WidgetHelpers | null; } // Due to this component being used as the crash fallback for Sentry, which has // weird type requirements, we can't just give this a type of FC -export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { +export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); useEffect(() => { logger.error(error); @@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { error.richMessage ) : ( ): WidgetHelpers { + return { + api: api as WidgetApi, + lazyActions: new EventEmitter(), + client: Promise.resolve(), + } as unknown as WidgetHelpers; +} + +describe("createWidgetHostBridge", () => { + describe("downloadMedia", () => { + const mxcUri = "mxc://example.org/alice-avatar"; + + test("passes a Blob through unchanged", async () => { + const file = new Blob([]); + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file); + }); + + test("decodes a base64 string into a Blob", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ + // "hello" in base64 + downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }), + }), + ); + + const blob = await bridge.downloadMedia!(mxcUri); + + expect(blob).toBeInstanceOf(Blob); + // The five decoded bytes, rather than the eight characters of base64 — + // which is what we'd get if the string were stored verbatim. + expect(blob.size).toBe(5); + }); + + test("rejects a file format it does not understand", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow( + "Downloaded file format is not supported", + ); + }); + }); + + describe("supportsReactions", () => { + const capabilities = [ + "org.matrix.msc2762.send.event:m.reaction", + "org.matrix.msc2762.send.event:m.room.redaction", + "org.matrix.msc2762.receive.event:m.reaction", + "org.matrix.msc2762.receive.event:m.room.redaction", + ]; + + test("is true when the host grants every reaction capability", () => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: () => true }), + ); + + expect(bridge.supportsReactions).toBe(true); + }); + + test.each(capabilities)("is false without %s", (missing) => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: (c) => c !== missing }), + ); + + expect(bridge.supportsReactions).toBe(false); + }); + }); +}); + +describe("nullHostBridge", () => { + test("offers no way to close, so the interface falls back to navigation", () => { + expect(nullHostBridge.close).toBeUndefined(); + }); + + test("offers no media download, so Element Call uses its own client", () => { + expect(nullHostBridge.downloadMedia).toBeUndefined(); + }); + + test("supports reactions, since nothing is mediating its homeserver access", () => { + expect(nullHostBridge.supportsReactions).toBe(true); + }); +}); diff --git a/src/HostBridge.ts b/src/HostBridge.ts new file mode 100644 index 000000000..f31b5a4af --- /dev/null +++ b/src/HostBridge.ts @@ -0,0 +1,214 @@ +/* +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 { createContext, use } from "react"; +import { fromEvent, map, NEVER, type Observable } from "rxjs"; +import { + type IWidgetApiRequest, + type IWidgetApiRequestData, + WidgetApiToWidgetAction, +} from "matrix-widget-api"; + +import { + ElementWidgetActions, + type JoinCallData, + type WidgetHelpers, +} from "./widget"; + +// Note: these are type aliases rather than interfaces so that they satisfy the +// widget API's index-signature payload types. + +/** The mute state Element Call and its host exchange. */ +export type DeviceMuteState = { + audio_enabled: boolean; + video_enabled: boolean; +}; + +/** + * A mute state change requested by the host. An absent field means "leave this + * one as it is". + */ +export type DeviceMuteRequest = { + audio_enabled?: boolean; + video_enabled?: boolean; +}; + +/** + * Something the host has asked of Element Call, which it is expected to + * acknowledge. + */ +export interface HostRequest { + data: Data; + /** Acknowledges the request. Should be called exactly once. */ + reply(reply: Reply): void; +} + +/** + * Element Call's view of the application hosting it. + * + * Element Call can run as its own page, as a widget inside a Matrix client, or + * embedded directly into one. Only the last two give it a host, and each of + * them reaches it by a different route — so everything Element Call needs from + * whatever is hosting it goes through this interface, rather than being + * expressed in terms of the widget API. + * + * This covers the interactions Element Call has with its host while running. + * The Matrix client it talks to is supplied separately, at startup. + */ +export interface HostBridge { + // What Element Call tells the host. + + /** + * Asks the host to keep Element Call on screen (or stop doing so), so that a + * call in progress is not torn down when the user navigates elsewhere. + */ + setAlwaysOnScreen(alwaysOnScreen: boolean): Promise; + /** Tells the host that Element Call has finished loading. */ + contentLoaded(): Promise; + /** Tells the host that the user has joined the call. */ + notifyJoined(): Promise; + /** Tells the host that the user has hung up. */ + notifyHungUp(): Promise; + /** Tells the host the user's current audio and video mute state. */ + notifyDeviceMute(state: DeviceMuteState): Promise; + /** + * Asks the host to close Element Call, and stops communicating with it. No + * further calls should be made on this bridge afterwards. + * + * Absent when the host has no way to dismiss Element Call — standalone, the + * user navigates away instead — so its presence is what tells the interface + * whether to offer a close affordance. + */ + close?(): Promise; + + // What the host asks of Element Call. + + /** The host has changed the theme Element Call should use. */ + themeChange$: Observable>; + /** The host wants a preloaded Element Call to join the call now. */ + join$: Observable>; + /** The host wants Element Call to leave the call. */ + hangUp$: Observable>>; + /** The host wants to change, or read back, the device mute state. */ + deviceMute$: Observable>; + + // What the host is capable of. + + /** Whether the host permits Element Call to send and receive reactions. */ + readonly supportsReactions: boolean; + /** + * Fetches media on Element Call's behalf, for hosts that do not give it + * direct access to the homeserver. Absent when Element Call should fetch + * media itself using its own client. + */ + downloadMedia?(mxcUri: string): Promise; +} + +/** + * A bridge to nowhere, for when Element Call has no host — that is, when it is + * running as its own page and talks to the homeserver directly. + */ +export const nullHostBridge: HostBridge = { + setAlwaysOnScreen: async () => {}, + contentLoaded: async () => {}, + notifyJoined: async () => {}, + notifyHungUp: async () => {}, + notifyDeviceMute: async () => {}, + themeChange$: NEVER, + join$: NEVER, + hangUp$: NEVER, + deviceMute$: NEVER, + // Standalone Element Call reaches the homeserver itself, so nothing is + // withholding these from it. + supportsReactions: true, +}; + +/** Bridges to a host that Element Call is a widget of. */ +export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { + const requests = ( + action: string, + ): Observable> => + ( + fromEvent(widget.lazyActions, action) as Observable< + CustomEvent + > + ).pipe( + map((ev) => ({ + data: ev.detail.data as Data, + // The widget API requires a reply for every request, and carries the + // payload as a plain object, so an empty reply becomes {}. + reply: (reply: Reply): void => + widget.api.transport.reply(ev.detail, reply ?? {}), + })), + ); + + const send = async ( + action: ElementWidgetActions, + data: IWidgetApiRequestData = {}, + ): Promise => { + await widget.api.transport.send(action, data); + }; + + return { + setAlwaysOnScreen: async (alwaysOnScreen) => { + await widget.api.setAlwaysOnScreen(alwaysOnScreen); + }, + contentLoaded: async () => widget.api.sendContentLoaded(), + notifyJoined: async () => send(ElementWidgetActions.JoinCall), + notifyHungUp: async () => send(ElementWidgetActions.HangupCall), + notifyDeviceMute: async (state) => + send(ElementWidgetActions.DeviceMute, state), + close: async () => { + await send(ElementWidgetActions.Close); + widget.api.transport.stop(); + }, + themeChange$: requests(WidgetApiToWidgetAction.ThemeChange), + join$: requests(ElementWidgetActions.JoinCall), + hangUp$: requests(ElementWidgetActions.HangupCall), + deviceMute$: requests(ElementWidgetActions.DeviceMute), + // Element Call needs the host's permission to send reactions on its behalf. + // Read on access rather than up front: the widget API negotiates its + // capabilities asynchronously, and the bridge is built before that settles. + get supportsReactions(): boolean { + return ( + widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") && + widget.api.hasCapability( + "org.matrix.msc2762.send.event:m.room.redaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.reaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.room.redaction", + ) + ); + }, + downloadMedia: async (mxcUri) => { + const { file } = await widget.api.downloadFile(mxcUri); + if (file instanceof Blob) return file; + if (typeof file === "string") + // it is a base64 string + return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]); + throw new Error( + `Downloaded file format is not supported: ${typeof file}`, + ); + }, + }; +} + +const HostBridgeContext = createContext(null); + +export const HostBridgeProvider = HostBridgeContext.Provider; + +/** + * The application hosting Element Call. + * + * Defaults to {@link nullHostBridge}, so that tests and stories, which have no + * host, need no provider. + */ +export const useHostBridge = (): HostBridge => + use(HostBridgeContext) ?? nullHostBridge; diff --git a/src/RichError.tsx b/src/RichError.tsx index 699486e25..abacf0b34 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import type { FC, ReactNode } from "react"; import { ErrorView } from "./ErrorView"; -import { widget } from "./widget.ts"; /** * An error consisting of a terse message to be logged to the console and a @@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => { const { t } = useTranslation(); return ( - +

{t("error.open_elsewhere_description", { brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index ca1f0ea83..e61368559 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView"; import { UnauthenticatedView } from "./UnauthenticatedView"; import { RegisteredView } from "./RegisteredView"; import { usePageTitle } from "../usePageTitle"; -import { widget } from "../widget.ts"; export const HomePage: FC = () => { const { t } = useTranslation(); @@ -24,7 +23,7 @@ export const HomePage: FC = () => { if (!clientState) { return ; } else if (clientState.state === "error") { - return ; + return ; } else { return clientState.authenticated ? ( diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index e10044ae1..a70b31805 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -36,7 +36,11 @@ import { UnknownCallError, } from "../utils/errors.ts"; import { mockConfig } from "../utils/test.ts"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge.ts"; test.each([ { @@ -79,7 +83,6 @@ test.each([ @@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => { @@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () = return ( - + @@ -199,7 +198,6 @@ describe("Rageshake button", () => { @@ -224,29 +222,27 @@ describe("Rageshake button", () => { }); }); -test("should have a close button in widget mode", async () => { +test("should have a close button when the host can dismiss us", async () => { const error = new MatrixRTCTransportMissingError("example.com"); const TestComponent = (): ReactNode => { throw error; }; - const mockWidget = { - api: { - transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() }, - }, - } as unknown as WidgetHelpers; + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { ...nullHostBridge, close }; const user = userEvent.setup(); const onErrorMock = vi.fn(); const { asFragment } = render( - - - + + + + + , ); @@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => { await user.click(screen.getByRole("button", { name: "Close" })); - expect(mockWidget.api.transport.send).toHaveBeenCalledWith( - ElementWidgetActions.Close, - expect.anything(), - ); - expect(mockWidget.api.transport.stop).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); }); test("should show technical details when error has a matrixError cause", async () => { @@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async ( render( - + , @@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a render( - + , @@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => { @@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => { diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 390a5a8c2..6d43cf78f 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -34,7 +34,6 @@ import { } from "../utils/errors.ts"; import { FullScreenView } from "../FullScreenView.tsx"; import { ErrorView } from "../ErrorView.tsx"; -import { type WidgetHelpers } from "../widget.ts"; import styles from "../ErrorView.module.css"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; @@ -47,13 +46,11 @@ interface ErrorPageProps { error: ElementCallError; recoveryActionHandler: RecoveryActionHandler; resetError: () => void; - widget: WidgetHelpers | null; } const ErrorPage: FC = ({ error, recoveryActionHandler, - widget, }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); logger.error("Error boundary caught:", error); @@ -89,7 +86,6 @@ const ErrorPage: FC = ({ Icon={icon} title={error.localisedTitle} rageshake={error.code == ErrorCode.UNKNOWN_ERROR} - widget={widget} >

{error.localisedMessageKey ? ( @@ -148,14 +144,12 @@ interface BoundaryProps { children: ReactNode | (() => ReactNode); recoveryActionHandler: RecoveryActionHandler; onError?: (error: unknown) => void; - widget: WidgetHelpers | null; } export const GroupCallErrorBoundary = ({ recoveryActionHandler, onError, children, - widget, }: BoundaryProps): ReactElement => { const fallbackRenderer: FallbackRender = useCallback( ({ error, resetError }): ReactElement => { @@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({ : new UnknownCallError(error instanceof Error ? error : new Error()); return ( { @@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({ /> ); }, - [recoveryActionHandler, widget], + [recoveryActionHandler], ); return ( diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a5c3b0d8e..a53288c2c 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -36,7 +36,6 @@ import userEvent, { import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; -import { type ITransport } from "matrix-widget-api"; import { prefetchSounds } from "../soundUtils"; import { useAudioContext } from "../useAudioContext"; @@ -52,8 +51,11 @@ import { } from "../utils/test"; import { GroupCallView } from "./GroupCallView"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget"; -import { LazyEventEmitter } from "../LazyEventEmitter"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge"; import { MatrixRTCTransportMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; @@ -134,7 +136,7 @@ beforeEach(() => { }); function createGroupCallView( - widget: WidgetHelpers | null, + hostBridge: HostBridge, joined = true, options: { withErrorBoundary?: boolean; @@ -184,7 +186,6 @@ function createGroupCallView( skipLobby={false} rtcSession={rtcSession.asMockedSession()} muteStates={muteState} - widget={widget} // TODO-MULTI-SFU: Make joined and setJoined work joined={true} setJoined={function (value: boolean): void {}} @@ -192,22 +193,21 @@ function createGroupCallView( ); const { getByText } = render( - - - - {options.withErrorBoundary ? ( - - {groupCallView} - - ) : ( - groupCallView - )} - - - + + + + + {options.withErrorBoundary ? ( + + {groupCallView} + + ) : ( + groupCallView + )} + + + + , ); return { @@ -218,7 +218,7 @@ function createGroupCallView( test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => { const user = userEvent.setup(); - const { getByText, rtcSession } = createGroupCallView(null); + const { getByText, rtcSession } = createGroupCallView(nullHostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); expect(playSound).toHaveBeenCalledWith("left"); @@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => { const user = userEvent.setup(); - const widget = { - api: { - setAlwaysOnScreen: async () => Promise.resolve(true), - } as Partial, - lazyActions: new LazyEventEmitter(), - }; + const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() }; let resolvePlaySound: () => void; playSound = vi .fn() @@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn soundDuration: {}, }); - const { getByText, rtcSession } = createGroupCallView( - widget as WidgetHelpers, - ); + const { getByText, rtcSession } = createGroupCallView(hostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); await flushPromises(); @@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn expect(leaveRTCSession).toHaveBeenCalledOnce(); }); -test("Should close widget when all other left and play a sound", async () => { +test("Should ask the host to close when all other left and play a sound", async () => { const user = userEvent.setup(); - let widgetClosedCalled = false; - const { promise: widgetClosedPromise, resolve: widgetClosedResolver } = - Promise.withResolvers(); - const widgetSendMock = vi.fn().mockImplementation((action: string) => { - if (action === ElementWidgetActions.Close) { - widgetClosedCalled = true; - widgetClosedResolver(); - } - }); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined), + close, }; const resolvePlaySound = Promise.withResolvers(); playSound = vi.fn().mockReturnValue(resolvePlaySound.promise); @@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => { soundDuration: {}, }); - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateOtherLeft"); await user.click(leaveButton); await flushPromises(); - expect(widgetClosedCalled).toBeFalsy(); + expect(close).not.toHaveBeenCalled(); resolvePlaySound.resolve(); expect(playSound).toHaveBeenCalledWith("left", 0); - await widgetClosedPromise; - await flushPromises(); - expect(widgetClosedCalled).toBeTruthy(); - expect(widgetStopMock).toHaveBeenCalledOnce(); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); }, 80000); -test("Should not close widget when auto leave due to error", async () => { +test("Should not ask the host to close when auto leave due to error", async () => { const user = userEvent.setup(); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widgetSendMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen, + close, }; - const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen"); - - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateErrorLeft"); await user.click(leaveButton); await flushPromises(); // When onLeft is called, we first set always on screen to false - await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false)); + await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false)); await flushPromises(); - // But then we do not close the widget automatically - expect(widgetStopMock).not.toHaveBeenCalledOnce(); - expect(widgetSendMock).not.toHaveBeenCalledOnce(); + // But then we do not ask to be closed automatically + expect(close).not.toHaveBeenCalled(); }); test.skip("GroupCallView leaves the session when an error occurs", async () => { @@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => { ); }); const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null); + const { rtcSession } = createGroupCallView(nullHostBridge); await user.click(screen.getByRole("button", { name: "Panic!" })); screen.getByText("Something went wrong"); expect(leaveRTCSession).toHaveBeenCalledWith( @@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => { onTestFinished(() => { enterRTCSession.mockReset(); }); - createGroupCallView(null, false); + createGroupCallView(nullHostBridge, false); await user.click(screen.getByRole("button", { name: "Join call" })); screen.getByText("Call is not supported"); }); @@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent { cause: stickyError }, ); - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent }); test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => { - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro test("user can reconnect after a membership manager error", async () => { const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null, true); + const { rtcSession } = createGroupCallView(nullHostBridge, true); await act(() => rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 57e9205f1..623b8839c 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -30,12 +30,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import type { IWidgetApiRequest } from "matrix-widget-api"; -import { - ElementWidgetActions, - type JoinCallData, - type WidgetHelpers, -} from "../widget"; +import { type JoinCallData } from "../widget"; import { LobbyView } from "./LobbyView"; import { type MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; @@ -76,6 +71,7 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { useRootElement } from "../RootElementContext.ts"; +import { useHostBridge } from "../HostBridge.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -99,7 +95,6 @@ interface Props { joined: boolean; setJoined: (value: boolean) => void; muteStates: MuteStates; - widget: WidgetHelpers | null; } export const GroupCallView: FC = ({ @@ -112,7 +107,6 @@ export const GroupCallView: FC = ({ joined, setJoined, muteStates, - widget, }) => { // Used to thread through any errors that occur outside the error boundary const [externalError, setExternalError] = useState( @@ -120,6 +114,14 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); const rootElement = useRootElement(); + const hostBridge = useHostBridge(); + // A host that can close us is a host that decides when we stop existing, so + // we neither show our own post-call screens nor assume we have time to + // finish what we are doing. + // TODO: this reads a capability as a proxy for who owns our lifetime. Worth + // finding a more direct way to express it — see the guidance in UrlParams.ts + // on naming behaviours rather than situations. + const hostControlsLifetime = hostBridge.close !== undefined; const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -294,28 +296,26 @@ export const GroupCallView: FC = ({ }; if (skipLobby) { - if (widget && preload) { + // `preload` is only ever set when we have a host to be preloaded by. + if (preload) { // In preload mode without lobby we wait for a join action before entering - const onJoin = (ev: CustomEvent): void => { + const subscription = hostBridge.join$.subscribe(({ data, reply }) => { (async (): Promise => { - await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); + await defaultDeviceSetup(data); setJoined(true); - widget.api.transport.reply(ev.detail, {}); + reply(); })().catch((e) => { logger.error("Error joining RTC session on preload", e); }); - }; - widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); - return (): void => { - widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); - }; + }); + return (): void => subscription.unsubscribe(); } else { // No lobby and no preload: we enter the rtc session right away setJoined(true); } } }, [ - widget, + hostBridge, rtcSession, preload, skipLobby, @@ -341,7 +341,7 @@ export const GroupCallView: FC = ({ // When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer // already plays the "left" sound when the remote participant's media // disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise. - // (used to destory the widget) + // (which is what delays asking the host to close us) audioPromise = leaveSoundContext.current?.playSound("left", 0); break; case "timeout": @@ -356,12 +356,12 @@ export const GroupCallView: FC = ({ setLeft(true); // We need to wait until the callEnded event is tracked on PostHog, - // otherwise the iframe may get killed first. + // otherwise we may be torn down first. const posthogRequest = new Promise((resolve) => { - // To increase the likelihood of the PostHog event being sent out in - // widget mode before the iframe is killed, we ask it to skip the - // usual queuing/batching of requests. - const sendInstantly = widget !== null; + // To increase the likelihood of the PostHog event being sent out + // before the host disposes of us, we ask it to skip the usual + // queuing/batching of requests. + const sendInstantly = hostControlsLifetime; PosthogAnalytics.instance.eventCallEnded.track( room.roomId, rtcSession.memberships.length, @@ -369,8 +369,8 @@ export const GroupCallView: FC = ({ rtcSession, ); // Unfortunately the PostHog library provides no way to await the - // tracking of an event, but we don't really want it to hold up the - // closing of the widget that long anyway, so giving it 10 ms will do. + // tracking of an event, but we don't really want it to hold up our + // disposal that long anyway, so giving it 10 ms will do. window.setTimeout(resolve, 10); }); @@ -389,25 +389,19 @@ export const GroupCallView: FC = ({ ) void navigate("/"); - if (widget) { - // After this point the iframe could die at any moment! + // After this point the host could dispose of us at any moment! + try { + await hostBridge.setAlwaysOnScreen(false); + } catch (e) { + logger.error("Failed to set `alwaysOnScreen` to false", e); + } + // On a normal user hangup we can shut down and ask to be closed. But + // if an error occurs we should stay open until the user reads it. + if (reason != "error" && !returnToLobby) { try { - await widget.api.setAlwaysOnScreen(false); + await hostBridge.close?.(); } catch (e) { - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ); - } - // On a normal user hangup we can shut down and close the widget. But if an - // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !returnToLobby) { - try { - await widget.api.transport.send(ElementWidgetActions.Close, {}); - } catch (e) { - logger.error("Failed to send close action", e); - } - widget.api.transport.stop(); + logger.error("Failed to ask the host to close Element Call", e); } } }); @@ -415,7 +409,8 @@ export const GroupCallView: FC = ({ [ setJoined, leaveSoundContext, - widget, + hostBridge, + hostControlsLifetime, room.roomId, rtcSession, isPasswordlessUser, @@ -426,12 +421,12 @@ export const GroupCallView: FC = ({ ); useEffect(() => { - if (widget && joined) - // set widget to sticky once joined. - widget.api.setAlwaysOnScreen(true).catch((e) => { + if (joined) + // ask to be kept on screen once joined. + hostBridge.setAlwaysOnScreen(true).catch((e) => { logger.error("Error calling setAlwaysOnScreen(true)", e); }); - }, [widget, joined, rtcSession]); + }, [hostBridge, joined, rtcSession]); const joinRule = useJoinRule(room); @@ -501,19 +496,16 @@ export const GroupCallView: FC = ({ /> ); - } else if (left && widget === null) { - // Left in SPA mode: + } else if (left && !hostControlsLifetime) { + // Left, and it is up to us what to show next: // The call ended view is shown for two reasons: prompting guests to create // an account, and prompting users that have opted into analytics to provide - // feedback. We don't show a feedback prompt to widget users however (at - // least for now), because we don't yet have designs that would allow widget - // users to dismiss the feedback prompt and close the call window without - // submitting anything. - if ( - isPasswordlessUser || - (PosthogAnalytics.instance.isEnabled() && widget === null) - ) { + // feedback. We don't show a feedback prompt when a host owns our lifetime + // however (at least for now), because we don't yet have designs that would + // allow those users to dismiss the feedback prompt and close the call + // window without submitting anything. + if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) { body = ( = ({ // LobbyView again which would open capture devices again. body = null; } - } else if (left && widget !== null) { - // Left in widget mode: + } else if (left && hostControlsLifetime) { + // Left, and the host decides what happens next: body = returnToLobby ? lobbyView : null; } else if (preload || skipLobby) { // The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called. @@ -541,7 +533,6 @@ export const GroupCallView: FC = ({ return ( { setExternalError(null); if (action == "reconnect") { @@ -553,9 +544,10 @@ export const GroupCallView: FC = ({ }} onError={(_error) => { if (rtcSession.isJoined()) onLeft("error"); - // If there is an error we need to be able to close the widget. This is done in `onLeft` as well - // We need it here explicitly in case rtcSession.isJoined is false. - void widget?.api.setAlwaysOnScreen(false); + // If there is an error we need to be dismissible again. This is done in + // `onLeft` as well; we need it here explicitly in case + // rtcSession.isJoined is false. + void hostBridge.setAlwaysOnScreen(false); }} > {body} diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index b1ffb3ba2..b5f943ce8 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -123,7 +123,6 @@ export const RoomPage: FC = (): ReactNode => { return ( muteStates && ( {

@@ -216,7 +214,6 @@ export const RoomPage: FC = (): ReactNode => {

{groupCallState.error.messageBody}

{groupCallState.error.reason && ( @@ -230,7 +227,7 @@ export const RoomPage: FC = (): ReactNode => { ); } else { - return ; + return ; } default: return <> ; @@ -238,7 +235,7 @@ export const RoomPage: FC = (): ReactNode => { }; if (loading || isRegistering) return ; - if (error) return ; + if (error) return ; if (!client) return ; // TODO: This doesn't belong here, the app routes need to be reworked if (!roomIdOrAlias) return ; diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 4239cee13..0194dfc4d 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh `; -exports[`should have a close button in widget mode 1`] = ` +exports[`should have a close button when the host can dismiss us 1`] = `
({ useUrlParams: vi.fn() })); -vi.mock("./widget", () => ({ - widget: { - api: { transport: { reply: vi.fn() } }, - lazyActions: new EventEmitter(), - }, -})); describe("useTheme", () => { let originalClassList: DOMTokenList; + let themeChange$: Subject>; + let wrapper: FC; + beforeEach(() => { + themeChange$ = new Subject(); + const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ }; + wrapper = ({ children }) => + createElement(HostBridgeProvider, { value: hostBridge }, children); // Save the original classList to setup spies originalClassList = document.body.classList; @@ -55,7 +61,7 @@ describe("useTheme", () => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", @@ -71,7 +77,7 @@ describe("useTheme", () => { // Simulate a previous theme originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark"); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(document.body.classList.add).not.toHaveBeenCalledWith( "cpd-theme-dark", @@ -82,18 +88,13 @@ describe("useTheme", () => { expect(originalClassList.add).not.toHaveBeenCalled(); }); - test("theme changes in response to widget actions", async () => { - renderHook(() => useTheme()); + test("theme changes in response to host requests", () => { + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark"); - await act(() => - widget!.lazyActions.emit( - WidgetApiToWidgetAction.ThemeChange, - new CustomEvent(WidgetApiToWidgetAction.ThemeChange, { - detail: { data: { name: "light" } }, - }), - ), - ); + const reply = vi.fn(); + act(() => themeChange$.next({ data: { name: "light" }, reply })); + expect(reply).toHaveBeenCalledOnce(); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", "cpd-theme-dark", diff --git a/src/useTheme.ts b/src/useTheme.ts index 85dace2aa..3c02745a6 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -6,39 +6,27 @@ Please see LICENSE in the repository root for full details. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { WidgetApiToWidgetAction } from "matrix-widget-api"; -import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { useUrlParams } from "./UrlParams"; -import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; +import { useHostBridge } from "./HostBridge"; export const useTheme = (): void => { const rootElement = useRootElement(); + const hostBridge = useHostBridge(); const { theme } = useUrlParams(); const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { - if (widget) { - const onThemeChange = ( - ev: CustomEvent, - ): void => { - ev.preventDefault(); - if ("name" in ev.detail.data && typeof ev.detail.data.name === "string") - setRequestedTheme(ev.detail.data.name); - widget!.api.transport.reply(ev.detail, {}); - }; - - widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange); - return (): void => { - widget!.lazyActions.off( - WidgetApiToWidgetAction.ThemeChange, - onThemeChange, - ); - }; - } - }, []); + const subscription = hostBridge.themeChange$.subscribe( + ({ data, reply }) => { + if (typeof data.name === "string") setRequestedTheme(data.name); + reply(); + }, + ); + return (): void => subscription.unsubscribe(); + }, [hostBridge]); useLayoutEffect(() => { // If no theme has been explicitly requested we default to dark From bc6c232ef092d721500693e95f8793945d13c5dd Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 13:23:01 +0200 Subject: [PATCH 08/54] Move the state layer onto the host bridge MuteStates, CallViewModel and LocalMember reached the host through the widget global. None of them are React components, so they take the bridge as an explicit parameter: a constructor argument for MuteStates, a field on CallViewModelOptions, and one on createLocalMembership$'s props. src/state no longer refers to the widget API. The conditionals around it mostly disappear: nullHostBridge's observables are NEVER, so there is nothing to guard, and a request carries its own reply rather than needing the transport and the original event. CallViewModelWidget.test.ts drove hangup by emitting on the mocked widget's action emitter, so it now injects a bridge instead, and checks that the request is acknowledged. --- sdk/main.ts | 13 ++- src/room/InCallView.tsx | 4 + src/room/RoomPage.tsx | 5 +- src/state/CallViewModel/CallViewModel.ts | 30 +++--- .../localMember/LocalMember.test.ts | 2 + .../CallViewModel/localMember/LocalMember.ts | 27 +++-- src/state/CallViewModelWidget.test.ts | 46 +++------ src/state/MuteStates.test.ts | 11 ++- src/state/MuteStates.ts | 99 +++++++++---------- src/utils/test.ts | 11 ++- 10 files changed, 120 insertions(+), 128 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index 55aa4a022..de15a5759 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -60,6 +60,7 @@ import { initializeWidget, } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; +import { createWidgetHostBridge } from "../src/HostBridge"; interface MatrixRTCSdk { /** @@ -113,6 +114,7 @@ export async function createMatrixRTCSdk( const widget = _widget; if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; + const hostBridge = createWidgetHostBridge(widget); logger.info("client created"); // url params @@ -132,10 +134,12 @@ export async function createMatrixRTCSdk( controlledAudioDevices, callIntent, }); - const muteStates = new MuteStates(scope, mediaDevices, { - audioEnabled: false, - videoEnabled: false, - }); + const muteStates = new MuteStates( + scope, + mediaDevices, + { audioEnabled: false, videoEnabled: false }, + hostBridge, + ); // call view model const callViewModel = createCallViewModel$( @@ -146,6 +150,7 @@ export async function createMatrixRTCSdk( muteStates, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + hostBridge, controlledAudioDevices, callIntent, }, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e291e001b..e2edde844 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge.ts"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -116,6 +117,7 @@ export const ActiveCall: FC = (props) => { useState | null>(null); const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); useEffect(() => { @@ -141,6 +143,7 @@ export const ActiveCall: FC = (props) => { props.muteStates, { encryptionSystem: props.e2eeSystem, + hostBridge, autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, @@ -171,6 +174,7 @@ export const ActiveCall: FC = (props) => { props.e2eeSystem, props.onLeft, urlParams, + hostBridge, mediaDevices, trackProcessorState$, props.client, diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index b5f943ce8..00905ac4d 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -30,6 +30,7 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; import { E2eeType } from "../e2ee/e2eeType"; @@ -44,6 +45,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts"; export const RoomPage: FC = (): ReactNode => { const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); const { confineToRoom, preload, header, displayName, skipLobby } = urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -77,10 +79,11 @@ export const RoomPage: FC = (): ReactNode => { urlParams.callIntent, widget !== null, ), + hostBridge, ), ); return (): void => scope.end(); - }, [devices, urlParams]); + }, [devices, urlParams, hostBridge]); useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e9abeae59..b40a45231 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -48,7 +48,6 @@ import { type RTCCallIntent, type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; -import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { v4 as uuidv4 } from "uuid"; import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; @@ -89,7 +88,7 @@ import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; import { HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; -import { ElementWidgetActions, widget } from "../../widget"; +import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { layoutShallowEquals, type Alignment, @@ -173,6 +172,11 @@ import { type GridTileViewModel } from "../TileViewModel.ts"; // callMembership -> rtcMembership export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; + /** + * The application hosting Element Call, which can ask it to hang up and wants + * to know when the user joins or leaves. Defaults to no host. + */ + hostBridge?: HostBridge; /** * Whether the app hosting Element Call controls the audio output devices, * rather than the browser. Defaults to false. @@ -457,6 +461,7 @@ export function createCallViewModel$( // Defaults match what the URL parameters resolve to outside of widget mode, // so that callers which don't care (chiefly tests) behave as they always have. const { + hostBridge = nullHostBridge, controlledAudioDevices = false, header = HeaderStyle.Standard, showControls = true, @@ -632,6 +637,7 @@ export function createCallViewModel$( localTransport$, roomId: matrixRoom.roomId, hideScreensharing, + hostBridge, logger: logger.getChild(`[${Date.now()}]`), }); @@ -923,24 +929,16 @@ export function createCallViewModel$( const userHangup$ = new Subject(); - const widgetHangup$ = - widget === null - ? NEVER - : ( - fromEvent( - widget.lazyActions, - ElementWidgetActions.HangupCall, - ) as Observable> - ).pipe( - tap((ev) => { - widget!.api.transport.reply(ev.detail, {}); - }), - ); + const hostHangup$ = hostBridge.hangUp$.pipe( + tap((request) => { + request.reply(); + }), + ); const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> = merge( autoLeave$, - merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), + merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)), ).pipe(scope.share); const spotlightSpeaker$ = scope.behavior( diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 2b89d50b5..4826dcdd1 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -52,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import { type Publisher } from "./Publisher"; import { initializeWidget } from "../../../widget"; +import { nullHostBridge } from "../../../HostBridge"; import { type LocalTransport, type LocalTransportWithSFUConfig, @@ -200,6 +201,7 @@ describe("LocalMembership", () => { }, roomId: "!test-room-id:example.org", hideScreensharing: false, + hostBridge: nullHostBridge, }; it("throws error on missing RTC config error", () => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 844b66dee..e28f5fe51 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -53,7 +53,7 @@ import { MembershipManagerError, UnknownCallError, } from "../../../utils/errors.ts"; -import { ElementWidgetActions, widget } from "../../../widget.ts"; +import { type HostBridge } from "../../../HostBridge.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { @@ -145,6 +145,8 @@ interface Props { >; /** Whether to hide the screen-sharing button. */ hideScreensharing: boolean; + /** The application hosting Element Call, to be kept informed of join/leave. */ + hostBridge: HostBridge; logger: Logger; } @@ -165,6 +167,7 @@ interface Props { * @param props.matrixRTCSession The matrix RTC session to join. * @param props.roomId The room ID used as the call identifier in analytics events. * @param props.hideScreensharing Whether to hide the screen-sharing button. + * @param props.hostBridge The application hosting Element Call. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -184,6 +187,7 @@ export const createLocalMembership$ = ({ matrixRTCSession, roomId, hideScreensharing, + hostBridge, }: Props): { /** * This request to start audio and video tracks. @@ -576,29 +580,24 @@ export const createLocalMembership$ = ({ } }); - // inform the widget about the connect and disconnect intent from the user. + // inform the host about the connect and disconnect intent from the user. scope .behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [ undefined, joinAndPublishRequested$.value, ]) .subscribe(([prev, current]) => { - if (!widget) return; // JOIN prev=false (was left) => current-true (now joiend) if (!prev && current) { - widget.api.transport - .send(ElementWidgetActions.JoinCall, {}) - .catch((e) => { - logger.error("Failed to send join action", e); - }); + hostBridge.notifyJoined().catch((e) => { + logger.error("Failed to notify the host that we joined", e); + }); } // LEAVE prev=false (was joined) => current-true (now left) if (prev && !current) { - widget.api.transport - .send(ElementWidgetActions.HangupCall, {}) - .catch((e) => { - logger.error("Failed to send hangup action", e); - }); + hostBridge.notifyHungUp().catch((e) => { + logger.error("Failed to notify the host that we hung up", e); + }); } }); @@ -845,7 +844,7 @@ interface EnterRTCSessionOptions { * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The * Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of * notification to send on join. `callIntent`: The kind of call being placed. - * @throws If the widget could not send ElementWidgetActions.JoinCall action. + * @throws If the host could not be told that we are joining. */ // Exported for unit testing export function enterRTCSession( diff --git a/src/state/CallViewModelWidget.test.ts b/src/state/CallViewModelWidget.test.ts index 2f331bd32..dd2e75ab8 100644 --- a/src/state/CallViewModelWidget.test.ts +++ b/src/state/CallViewModelWidget.test.ts @@ -6,39 +6,31 @@ Please see LICENSE in the repository root for full details. */ import { it, vi, expect } from "vitest"; -import EventEmitter from "events"; +import { Subject } from "rxjs"; // import * as ComponentsCore from "@livekit/components-core"; import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts"; import { type CallViewModel } from "./CallViewModel/CallViewModel.ts"; import { constant } from "./Behavior.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; -import { ElementWidgetActions, widget } from "../widget.ts"; +import { + type HostBridge, + type HostRequest, + nullHostBridge, +} from "../HostBridge.ts"; import { E2eeType } from "../e2ee/e2eeType.ts"; import { MatrixRTCMode } from "../config/ConfigOptions.ts"; vi.mock("@livekit/components-core", { spy: true }); -vi.mock("../widget", () => ({ - ElementWidgetActions: { - HangupCall: "HangupCall", - // Add other actions if needed - }, - widget: { - api: { - transport: { - send: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - }, - }, - lazyActions: new EventEmitter(), - }, -})); - it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])( - "expect leave when ElementWidgetActions.HangupCall is called (%s mode)", + "expect leave when the host asks us to hang up (%s mode)", async (mode) => { const pr = Promise.withResolvers(); + const hangUp$ = new Subject>>(); + const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ }; + const reply = vi.fn(); + withCallViewModel(mode)( { remoteParticipants$: constant([aliceParticipant]), @@ -49,25 +41,17 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])( pr.resolve(s); }); - widget!.lazyActions!.emit( - ElementWidgetActions.HangupCall, - new CustomEvent(ElementWidgetActions.HangupCall, { - detail: { - action: "im.vector.hangup", - api: "toWidget", - data: {}, - requestId: "widgetapi-1761237395918", - widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F", - }, - }), - ); + hangUp$.next({ data: {}, reply }); }, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + hostBridge, }, ); const source = await pr.promise; expect(source).toBe("user"); + // The host expects to hear back that we acted on its request + expect(reply).toHaveBeenCalledOnce(); }, ); diff --git a/src/state/MuteStates.test.ts b/src/state/MuteStates.test.ts index f594cb05c..239f0381f 100644 --- a/src/state/MuteStates.test.ts +++ b/src/state/MuteStates.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { BehaviorSubject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; +import { nullHostBridge } from "../HostBridge"; import { MuteStates, MuteState } from "./MuteStates"; import { type AudioOutputDeviceLabel, @@ -228,10 +229,12 @@ describe("MuteStates", () => { videoInput: aVideoInput(), // other devices are not relevant for this test }); - const muteStates = new MuteStates(testScope, mediaDevices, { - audioEnabled: false, - videoEnabled: false, - }); + const muteStates = new MuteStates( + testScope, + mediaDevices, + { audioEnabled: false, videoEnabled: false }, + nullHostBridge, + ); let latestSyncedState: boolean | null = null; muteStates.video.setHandler(async (enabled: boolean): Promise => { diff --git a/src/state/MuteStates.ts b/src/state/MuteStates.ts index d89cb8442..59413b036 100644 --- a/src/state/MuteStates.ts +++ b/src/state/MuteStates.ts @@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type IWidgetApiRequest } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/lib/logger"; import { BehaviorSubject, combineLatest, distinctUntilChanged, firstValueFrom, - fromEvent, map, merge, Observable, @@ -24,7 +22,7 @@ import { } from "rxjs"; import { type MediaDevices, type MediaDevice } from "../state/MediaDevices"; -import { ElementWidgetActions, widget } from "../widget"; +import { type DeviceMuteState, type HostBridge } from "../HostBridge"; import { type ObservableScope } from "./ObservableScope"; import { type Behavior, constant } from "./Behavior"; @@ -216,58 +214,51 @@ export class MuteStates { audioEnabled: boolean; videoEnabled: boolean; }, + hostBridge: HostBridge, ) { - if (widget !== null) { - // Sync our mute states with the hosting client - const widgetApiState$ = combineLatest( - [this.audio.enabled$, this.video.enabled$], - (audio, video) => ({ audio_enabled: audio, video_enabled: video }), - ); - widgetApiState$.pipe(this.scope.bind()).subscribe((state) => { - widget!.api.transport - .send(ElementWidgetActions.DeviceMute, state) - .catch((e) => - logger.warn("Could not send DeviceMute action to widget", e), - ); - }); + // Keep the host informed of our mute state + const muteState$ = combineLatest( + [this.audio.enabled$, this.video.enabled$], + (audio, video): DeviceMuteState => ({ + audio_enabled: audio, + video_enabled: video, + }), + ); + muteState$.pipe(this.scope.bind()).subscribe((state) => { + hostBridge + .notifyDeviceMute(state) + .catch((e) => logger.warn("Could not send mute state to the host", e)); + }); - // Also sync the hosting client's mute states back with ours - const muteActions$ = fromEvent( - widget.lazyActions, - ElementWidgetActions.DeviceMute, - ) as Observable>; - muteActions$ - .pipe( - withLatestFrom( - widgetApiState$, - this.audio.setEnabled$, - this.video.setEnabled$, - ), - this.scope.bind(), - ) - .subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => { - // First copy the current state into our new state - const newState = { ...state }; - // Update new state if there are any requested changes from the widget - // action in `ev.detail.data`. - if ( - ev.detail.data.audio_enabled != null && - typeof ev.detail.data.audio_enabled === "boolean" && - setAudioEnabled !== null - ) { - newState.audio_enabled = ev.detail.data.audio_enabled; - setAudioEnabled(newState.audio_enabled); - } - if ( - ev.detail.data.video_enabled != null && - typeof ev.detail.data.video_enabled === "boolean" && - setVideoEnabled !== null - ) { - newState.video_enabled = ev.detail.data.video_enabled; - setVideoEnabled(newState.video_enabled); - } - widget!.api.transport.reply(ev.detail, newState); - }); - } + // And apply the changes the host asks for + hostBridge.deviceMute$ + .pipe( + withLatestFrom( + muteState$, + this.audio.setEnabled$, + this.video.setEnabled$, + ), + this.scope.bind(), + ) + .subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => { + // First copy the current state into our new state + const newState = { ...state }; + // Then apply whichever changes the host asked for + if ( + typeof request.data.audio_enabled === "boolean" && + setAudioEnabled !== null + ) { + newState.audio_enabled = request.data.audio_enabled; + setAudioEnabled(newState.audio_enabled); + } + if ( + typeof request.data.video_enabled === "boolean" && + setVideoEnabled !== null + ) { + newState.video_enabled = request.data.video_enabled; + setVideoEnabled(newState.video_enabled); + } + request.reply(newState); + }); } } diff --git a/src/utils/test.ts b/src/utils/test.ts index de74ac53e..49ae0a3f1 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -63,6 +63,7 @@ import { type MediaDevices } from "../state/MediaDevices"; import { type Behavior, constant } from "../state/Behavior"; import { ObservableScope } from "../state/ObservableScope"; import { MuteStates } from "../state/MuteStates"; +import { nullHostBridge } from "../HostBridge"; import { createLocalUserMedia, type LocalUserMediaViewModel, @@ -577,10 +578,12 @@ export function mockMuteStates( joined$: Observable = of(true), ): MuteStates { const observableScope = new ObservableScope(); - return new MuteStates(observableScope, mockMediaDevices({}), { - audioEnabled: false, - videoEnabled: false, - }); + return new MuteStates( + observableScope, + mockMediaDevices({}), + { audioEnabled: false, videoEnabled: false }, + nullHostBridge, + ); } export class MockConnection extends Connection { From f28d9e9b06693276b7fe1fc0b5bffe53e5afb4f6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:04:24 +0200 Subject: [PATCH 09/54] Stop asking whether Element Call is a widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining reads of the widget global were all asking one of two different questions, so they get two different answers. The app shell — the auth hooks, automatic guest registration, the group call loader's diagnostic and the initial mute state — wants to know whether Element Call was launched as a widget. That is a property of the URL it was launched with, so expose the isWidget that computeUrlParams already computed internally, documented as being for shell use only. The call interface — whether to offer the profile settings tab — wants to know something about its host, so it asks the bridge. A host that can dismiss Element Call owns the user's account, so their profile is not ours to edit; this reuses the close capability as a proxy, with a TODO alongside the others. ClientContext also takes supportsReactions from the bridge rather than checking four widget capabilities itself, which removes widgetApi from InitResult — a field that was always null outside widget mode. Note this changes behaviour for a malformed widget URL: one carrying a widget ID and parent URL but missing the room, user, device or base URL would previously have fallen back to registering a guest user, and will now not. --- src/ClientContext.tsx | 34 +++++-------------------- src/UrlParams.ts | 12 +++++++++ src/auth/useInteractiveRegistration.ts | 7 ++--- src/auth/useRegisterPasswordlessUser.ts | 7 ++--- src/room/InCallView.tsx | 11 +++++--- src/room/RoomPage.tsx | 6 ++--- src/room/useLoadGroupCall.ts | 6 +++-- src/settings/SettingsModal.tsx | 7 +++-- src/utils/spa.ts | 1 - 9 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 288665c59..a0d65e64f 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; -import type { WidgetApi } from "matrix-widget-api"; import { ErrorPage } from "./FullScreenView"; import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, RegistrationType, @@ -138,6 +138,7 @@ interface Props { export const ClientProvider: FC = ({ children }) => { const navigate = useNavigate(); + const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< @@ -201,7 +202,6 @@ export const ClientProvider: FC = ({ children }) => { saveSession(session); setInitClientState({ - widgetApi: null, client, passwordlessUser: session.passwordlessUser, }); @@ -307,36 +307,16 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } - if (initClientState.widgetApi) { - const reactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.reaction", - ); - const redactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.room.redaction", - ); - const reactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.reaction", - ); - const redactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.room.redaction", - ); - - if (!reactSend || !reactRcv || !redactSend || !redactRcv) { - logger.warn("Widget does not support reactions"); - setSupportsReactions(false); - } else { - setSupportsReactions(true); - } - } else { - setSupportsReactions(true); - } + if (!hostBridge.supportsReactions) + logger.warn("The host does not permit reactions"); + setSupportsReactions(hostBridge.supportsReactions); return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync]); + }, [initClientState, onSync, hostBridge]); if (alreadyOpenedErr) { return ; @@ -346,7 +326,6 @@ export const ClientProvider: FC = ({ children }) => { }; export type InitResult = { - widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -357,7 +336,6 @@ async function loadClient(): Promise { logger.log("Using a matryoshka client"); const client = await widget.client; return { - widgetApi: widget.api, client, passwordlessUser: false, }; diff --git a/src/UrlParams.ts b/src/UrlParams.ts index dd83a4966..422163fc4 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -59,6 +59,17 @@ export interface UrlProperties { // Widget api related params widgetId: string | null; parentUrl: string | null; + /** + * Whether Element Call was started as a widget of a Matrix client, which is + * to say whether it was given a widget ID and a parent to talk to. + * + * Only meaningful to the standalone and widget builds, which own the URL — + * so use it for decisions that belong to the app shell, such as whether + * Element Call is responsible for authenticating the user. Anything the call + * interface itself needs to know about its host should come from the host + * bridge instead. + */ + isWidget: boolean; /** * Anything about what room we're pointed to should be from useRoomIdentifier which * parses the path and resolves alias with respect to the default server name, however @@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { const properties: UrlProperties = { widgetId, parentUrl, + isWidget, // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // what would we do if it were invalid? If the widget API says that's what // the room ID is, then that's what it is. diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index 4972c0312..7314c9e4a 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { initClient } from "../utils/matrix"; import { type Session } from "../ClientContext"; import { Config } from "../config/Config"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export const useInteractiveRegistration = ( oldClient?: MatrixClient, @@ -32,6 +32,7 @@ export const useInteractiveRegistration = ( passwordlessUser: boolean, ) => Promise<[MatrixClient, Session]>; } => { + const { isWidget } = useUrlParams(); const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState( undefined, ); @@ -47,7 +48,7 @@ export const useInteractiveRegistration = ( } useEffect(() => { - if (widget) return; + if (isWidget) return; // An empty registerRequest is used to get the privacy policy and recaptcha key. authClient.current!.registerRequest({}).catch((error) => { setPrivacyPolicyUrl( @@ -55,7 +56,7 @@ export const useInteractiveRegistration = ( ); setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); }); - }, []); + }, [isWidget]); const register = useCallback( async ( diff --git a/src/auth/useRegisterPasswordlessUser.ts b/src/auth/useRegisterPasswordlessUser.ts index c2cbe2d37..27674c623 100644 --- a/src/auth/useRegisterPasswordlessUser.ts +++ b/src/auth/useRegisterPasswordlessUser.ts @@ -12,7 +12,7 @@ import { useClient } from "../ClientContext"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { generateRandomName } from "../auth/generateRandomName"; import { useRecaptcha } from "../auth/useRecaptcha"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; interface UseRegisterPasswordlessUserType { privacyPolicyUrl?: string; @@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType { export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { const { setClient } = useClient(); + const { isWidget } = useUrlParams(); const { privacyPolicyUrl, recaptchaKey, register } = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); @@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { if (!setClient) { throw new Error("No client context"); } - if (widget) { + if (isWidget) { throw new Error( "Registration was skipped: We should never try to register password-less user in embedded mode.", ); @@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { throw e; } }, - [execute, reset, register, setClient], + [execute, reset, register, setClient, isWidget], ); return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e2edde844..ef1e1f988 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -28,7 +28,6 @@ import { useTranslation } from "react-i18next"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; @@ -251,6 +250,7 @@ export const InCallView: FC = ({ }) => { const logger = rootLogger.getChild("[InCallView]"); const { t } = useTranslation(); + const hostBridge = useHostBridge(); const { sendReaction, toggleRaisedHand } = useReactionsSender(); useWakeLock(); @@ -334,14 +334,17 @@ export const InCallView: FC = ({ const openProfile = useMemo( () => - // Profile settings are unavailable in widget mode - widget === null + // A host that can dismiss us is a host that owns the user's account, so + // their profile is not ours to edit. + // TODO: another use of the close capability as a proxy — see the note in + // GroupCallView. + hostBridge.close === undefined ? (): void => { setSettingsTab("profile"); setSettingsOpen(true); } : null, - [setSettingsTab, setSettingsOpen], + [setSettingsTab, setSettingsOpen, hostBridge], ); const [headerRef, headerBounds] = useMeasure(); diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 00905ac4d..33eb80ddb 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -29,7 +29,6 @@ import { GroupCallView } from "./GroupCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; @@ -77,7 +76,7 @@ export const RoomPage: FC = (): ReactNode => { calculateInitialMuteState( urlParams.skipLobby, urlParams.callIntent, - widget !== null, + urlParams.isWidget, ), hostBridge, ), @@ -88,7 +87,7 @@ export const RoomPage: FC = (): ReactNode => { useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user - if (!loading && !authenticated && displayName && !widget) { + if (!loading && !authenticated && displayName && !urlParams.isWidget) { setIsRegistering(true); registerPasswordlessUser(displayName) .catch((e) => { @@ -102,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => { loading, authenticated, displayName, + urlParams.isWidget, setIsRegistering, registerPasswordlessUser, ]); diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 8a7617d85..42464bb24 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -34,7 +34,7 @@ import { EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export type GroupCallLoaded = { kind: "loaded"; @@ -132,6 +132,7 @@ export const useLoadGroupCall = ( const [state, setState] = useState({ kind: "loading" }); const activeRoom = useRef(undefined); const { t } = useTranslation(); + const { isWidget } = useUrlParams(); const bannedError = useCallback( (): CallTerminatedMessage => @@ -249,7 +250,7 @@ export const useLoadGroupCall = ( // room already joined so we are done here already. return room!; } - if (widget) + if (isWidget) // in widget mode we never should reach this point. (getRoom should return the room.) throw new Error( "Room not found. The widget-api did not pass over the relevant room events/information.", @@ -373,6 +374,7 @@ export const useLoadGroupCall = ( }, [ bannedError, client, + isWidget, knockRejectError, removeNoticeError, roomIdOrAlias, diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b2ffef4ab..35604d983 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { iosDeviceMenu$ } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; -import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge"; import { useSetting, soundEffectVolume as soundEffectVolumeSetting, @@ -123,6 +123,7 @@ export const SettingsModal: FC = ({ // On EC, we decided that it is less confusing for the user if they see those options in the output section // rather than the input section. const { controlledAudioDevices } = useUrlParams(); + const hostBridge = useHostBridge(); // If we are on iOS we will show a button to open the native audio device picker. const iosDeviceMenu = useBehavior(iosDeviceMenu$); @@ -234,7 +235,9 @@ export const SettingsModal: FC = ({ }; const tabs = [audioTab, videoTab]; - if (widget === null) tabs.push(profileTab); + // A host that can dismiss us is a host that owns the user's account, so their + // profile is not ours to edit. + if (hostBridge.close === undefined) tabs.push(profileTab); tabs.push(preferencesTab); if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { // for full package we want to show the analytics consent checkbox diff --git a/src/utils/spa.ts b/src/utils/spa.ts index e97d78101..b8e959f8b 100644 --- a/src/utils/spa.ts +++ b/src/utils/spa.ts @@ -40,7 +40,6 @@ export async function initSPA( try { const client = await initClient(initClientParams, true); return { - widgetApi: null, client, passwordlessUser, }; From eb117249d18fb5f087e7d4ef338d8857c409c1dd Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:05:12 +0200 Subject: [PATCH 10/54] Configure analytics explicitly instead of discovering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PosthogAnalytics read its own configuration out of the environment on first use: the URL parameters, config.json, and the widget global. An embedded Element Call has none of those to offer, and analytics that configure themselves cannot be switched off by a host that does its own reporting. Take an AnalyticsConfig through PosthogAnalytics.configure() instead, called from the initializer once the config has loaded. Unconfigured analytics stay off. Note the two halves of that config are decided differently, and have to be: where the credentials come from depends on the package, but who owns the user's analytics identity depends on how Element Call is running, since the full package can be used as a widget too. Drop the widget check around cryptoVersion, which never did anything — widget mode never initialises crypto, so getCrypto() is already undefined there. Move the tests covering which package reads which credential source onto analyticsConfigFromEnvironment, where that decision now lives. --- src/analytics/PosthogAnalytics.test.ts | 147 ++++++++++++++++--------- src/analytics/PosthogAnalytics.ts | 85 +++++++++----- src/initializer.tsx | 35 ++++++ 3 files changed, 185 insertions(+), 82 deletions(-) diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index 7c1128ad4..ba51ba276 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -22,75 +22,121 @@ import { PosthogAnalytics, } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; +import { analyticsConfigFromEnvironment } from "../initializer"; describe("PosthogAnalytics", () => { - describe("embedded package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "embedded"); - }); - + describe("enablement", () => { beforeEach(() => { - mockConfig({}); - window.location.hash = "#"; PosthogAnalytics.resetInstance(); }); - afterAll(() => { - vi.unstubAllEnvs(); - }); - - it("does not create instance without config value or URL params", () => { + it("stays off until it is configured", () => { expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("ignores config value and does not create instance", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + it("stays off when configured without credentials", () => { + PosthogAnalytics.configure({ matrixBackend: "jssdk" }); + expect(PosthogAnalytics.instance.isEnabled()).toBe(false); + }); + + it("stays off when given only a key", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", }); expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("uses URL params if both set", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; + it("turns on when given both a key and a host", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); expect(PosthogAnalytics.instance.isEnabled()).toBe(true); }); }); - describe("full package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - + // Which of the URL and config.json the credentials come from is a deliberate + // policy: an embedder is responsible for its own users' telemetry, so it must + // not pick up the deployment's, and vice versa. + describe("analyticsConfigFromEnvironment", () => { beforeEach(() => { mockConfig({}); window.location.hash = "#"; - PosthogAnalytics.resetInstance(); }); afterAll(() => { vi.unstubAllEnvs(); }); - it("does not create instance without config value", () => { - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); + const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`; + const configCredentials = { + posthog: { + api_host: "https://config.example.com.localhost", + api_key: "config_key", + }, + }; - it("ignores URL params and does not create instance", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); - - it("creates instance with config value", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + describe("embedded package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "embedded"); }); - expect(PosthogAnalytics.instance.isEnabled()).toBe(true); + + it("has no credentials without URL params", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the URL", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "url_key", + apiHost: "https://url.example.com.localhost", + }); + }); + + it("ignores the deployment's config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + describe("full package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "full"); + }); + + it("has no credentials without config", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "config_key", + apiHost: "https://config.example.com.localhost", + }); + }); + + it("ignores the URL params", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + // Who owns the user's analytics identity depends on how Element Call is + // running, not on which package it was built as. + it("reports the embedded backend when running as a widget", () => { + vi.stubEnv("VITE_PACKAGE", "full"); + window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + matrixBackend: "embedded", + hostAnalyticsId: "given_id", + }); + }); + + it("reports the jssdk backend when running standalone", () => { + expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk"); }); }); @@ -204,22 +250,13 @@ describe("PosthogAnalytics", () => { // posthog-js bumps renaming/removing the hook. The filter logic itself is // covered by the applyPrivacyFilters block above. describe("posthog.init wiring", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - beforeEach(() => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, - }); PosthogAnalytics.resetInstance(); - }); - - afterAll(() => { - vi.unstubAllEnvs(); + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); }); it("passes events through the privacy filter via before_send", () => { diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 01a146e0d..01a05bfb9 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type MatrixClient } from "matrix-js-sdk"; import { type Subscription } from "rxjs"; -import { widget } from "../widget"; import { CallEndedTracker, CallStartedTracker, @@ -29,8 +28,6 @@ import { CallConnectDurationTracker, CallReconnectingTracker, } from "./PosthogEvents"; -import { Config } from "../config/Config"; -import { getUrlParams } from "../UrlParams"; import { optInAnalytics } from "../settings/settings"; /* Posthog analytics tracking. @@ -140,6 +137,27 @@ interface PlatformProperties { cryptoVersion?: string; } +/** + * How analytics reporting should be set up, supplied by whoever is starting + * Element Call rather than discovered from the page it happens to be on. + */ +export interface AnalyticsConfig { + /** The PostHog project key. Without one, analytics stay switched off. */ + apiKey?: string; + apiHost?: string; + /** + * How Element Call reaches Matrix. When `embedded`, the host owns the user's + * identity: it supplies the analytics ID, and Element Call must not store one + * in the user's account data. + */ + matrixBackend: "embedded" | "jssdk"; + /** The analytics ID the host has assigned to this user, when embedded. */ + hostAnalyticsId?: string | null; +} + +/** Analytics are off until someone asks for them. */ +const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" }; + export class PosthogAnalytics { /* Wrapper for Posthog analytics. * 3 modes of anonymity are supported, governed by this.anonymity @@ -167,13 +185,32 @@ export class PosthogAnalytics { private registrationType: RegistrationType = RegistrationType.Guest; private optInListener: Subscription | null = null; + private static analyticsConfig: AnalyticsConfig = analyticsDisabled; + + /** + * Sets up analytics reporting. Must be called before the instance is first + * used; without it, analytics stay switched off. + */ + public static configure(config: AnalyticsConfig): void { + if (this.internalInstance) + // Configuration is read once, when the instance is built, so arriving + // late means analytics are already running unconfigured. + logger.warn( + "Analytics were configured after they had already been started; the new configuration will not take effect", + ); + this.analyticsConfig = config; + } + public static hasInstance(): boolean { return Boolean(this.internalInstance); } public static get instance(): PosthogAnalytics { if (!this.internalInstance) { - this.internalInstance = new PosthogAnalytics(posthog); + this.internalInstance = new PosthogAnalytics( + posthog, + PosthogAnalytics.analyticsConfig, + ); } return this.internalInstance; } @@ -181,20 +218,14 @@ export class PosthogAnalytics { public static resetInstance(): void { // Reset the singleton instance this.internalInstance = null; + this.analyticsConfig = analyticsDisabled; } - private constructor(private readonly posthog: PostHog) { - let apiKey: string | undefined; - let apiHost: string | undefined; - if (import.meta.env.VITE_PACKAGE === "embedded") { - // for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration - apiKey = getUrlParams().posthogApiKey ?? undefined; - apiHost = getUrlParams().posthogApiHost ?? undefined; - } else if (import.meta.env.VITE_PACKAGE === "full") { - // in full package it is the server responsible for the analytics - apiKey = Config.get().posthog?.api_key; - apiHost = Config.get().posthog?.api_host; - } + private constructor( + private readonly posthog: PostHog, + private readonly config: AnalyticsConfig, + ) { + const { apiKey, apiHost } = config; if (apiKey && apiHost) { const beforeSend = (event: CaptureResult | null): CaptureResult | null => @@ -225,15 +256,15 @@ export class PosthogAnalytics { } } - private static getPlatformProperties(): PlatformProperties { + private getPlatformProperties(): PlatformProperties { const appVersion = import.meta.env.VITE_APP_VERSION || "dev"; return { appVersion, - matrixBackend: widget ? "embedded" : "jssdk", + matrixBackend: this.config.matrixBackend, callBackend: "livekit", - cryptoVersion: widget - ? undefined - : window.matrixclient?.getCrypto()?.getVersion(), + // Undefined when Element Call has no crypto of its own, which is the case + // whenever a host is doing the encrypting for it. + cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(), }; } @@ -283,8 +314,8 @@ export class PosthogAnalytics { // different devices to send the same ID. let analyticsID = await this.getAnalyticsId(); try { - if (!analyticsID && !widget) { - // only try setting up a new analytics ID in the standalone app. + if (!analyticsID && this.config.matrixBackend !== "embedded") { + // only mint an analytics ID when we are the ones storing it. // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. // Note there's a race condition here - if two devices do these steps at the same time, last write @@ -313,8 +344,8 @@ export class PosthogAnalytics { private async getAnalyticsId(): Promise { const client: MatrixClient = window.matrixclient; - if (widget) { - return getUrlParams().posthogUserId; + if (this.config.matrixBackend === "embedded") { + return this.config.hostAnalyticsId ?? null; } else { const accountData = await client.getAccountDataFromServer( PosthogAnalytics.ANALYTICS_EVENT_TYPE, @@ -324,7 +355,7 @@ export class PosthogAnalytics { } private async setAccountAnalyticsId(analyticsID: string): Promise { - if (!widget) { + if (this.config.matrixBackend !== "embedded") { const client = window.matrixclient; // the analytics ID only needs to be set in the standalone version. @@ -362,7 +393,7 @@ export class PosthogAnalytics { // These properties will be subsequently passed in every event. // // This only needs to be done once per page lifetime. Note that getPlatformProperties - this.platformSuperProperties = PosthogAnalytics.getPlatformProperties(); + this.platformSuperProperties = this.getPlatformProperties(); this.registerSuperProperties({ ...this.platformSuperProperties, registrationType: diff --git a/src/initializer.tsx b/src/initializer.tsx index c0e8407ec..0927d7aba 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -34,6 +34,10 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { + type AnalyticsConfig, + PosthogAnalytics, +} from "./analytics/PosthogAnalytics.ts"; import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: @@ -98,6 +102,36 @@ const Backend = { }, } satisfies BackendModule; +/** + * Where analytics reporting is configured from. + * + * Note the two halves are decided differently, and deliberately so. *Where the + * PostHog credentials come from* depends on the package: an embedder passes + * them in through the URL because it is responsible for its own users' + * telemetry, whereas a standalone deployment is configured by whoever operates + * it. *Who owns the user's analytics identity*, on the other hand, depends on + * how Element Call is actually running right now — the full package can be used + * as a widget too. + */ +// Exported for testing +export function analyticsConfigFromEnvironment(): AnalyticsConfig { + const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } = + getUrlParams(); + return { + matrixBackend: isWidget ? "embedded" : "jssdk", + hostAnalyticsId: posthogUserId, + ...(import.meta.env.VITE_PACKAGE === "embedded" + ? { + apiKey: posthogApiKey ?? undefined, + apiHost: posthogApiHost ?? undefined, + } + : { + apiKey: Config.get().posthog?.api_key, + apiHost: Config.get().posthog?.api_host, + }), + }; +} + enum LoadState { None, Loading, @@ -241,6 +275,7 @@ export class Initializer { Config.init().then( () => { seedSettingsFromConfig(Config.get().media_quality); + PosthogAnalytics.configure(analyticsConfigFromEnvironment()); this.loadStates.config = LoadState.Loaded; this.initStep(resolve); }, From f6f47ede62bda74609ff96860760c1223a6e2b75 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:14:10 +0200 Subject: [PATCH 11/54] Let a host supply Element Call's client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientProvider found its own client: from the widget API, or by restoring or creating a session. A host that embeds Element Call already has one, and owns the user's session, so accept it as a prop and skip all of that. A supplied client seeds the state synchronously, since there is no session of ours to restore and so nothing to wait for. Also guard the broadcast that shuts down other instances of the app. It protects Element Call's own session and crypto stores, which is why it was already skipped in widget mode — a host's client has the same property, so without this an embedded Element Call would close down the user's other tabs on mount. Adds the first tests for ClientContext, covering both --- src/ClientContext.test.tsx | 69 ++++++++++++++++++++++++++++++++++++++ src/ClientContext.tsx | 33 ++++++++++++++---- 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 src/ClientContext.test.tsx diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx new file mode 100644 index 000000000..909635d89 --- /dev/null +++ b/src/ClientContext.test.tsx @@ -0,0 +1,69 @@ +/* +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 { expect, test, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type FC } from "react"; + +import { ClientProvider, useClientState } from "./ClientContext"; + +const mockClient = (): MatrixClient => + ({ + on: vi.fn(), + removeListener: vi.fn(), + getUserId: () => "@alice:example.org", + getDeviceId: () => "AAAA", + stopClient: vi.fn(), + }) as Partial as MatrixClient; + +/** Reports what the context says, so a test can assert on it. */ +const ShowClientState: FC = () => { + const state = useClientState(); + if (state === undefined) return loading; + if (state.state === "error") return error; + return ( + {state.authenticated?.client.getUserId() ?? "unauthenticated"} + ); +}; + +test("uses a client supplied by the host without waiting", () => { + const client = mockClient(); + + const { container } = render( + + + + + , + ); + + // Available on the very first render: a supplied client needs no session + // restoring, so there is no loading state to pass through. + expect(container.textContent).toBe("@alice:example.org"); +}); + +test("does not claim exclusive use of storage when given a client", () => { + // The channel is created when the module loads, so spy on the prototype + // rather than trying to replace the global. + const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage"); + + render( + + + + + , + ); + + // The broadcast shuts down other instances to protect Element Call's own + // stores. A host's client brings its own, so there is nothing to protect. + expect(postMessage).not.toHaveBeenCalled(); + + postMessage.mockRestore(); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index a0d65e64f..6ad017f15 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -134,19 +134,36 @@ const loadChannel = interface Props { children: JSX.Element; + /** + * The client Element Call should use. + * + * When a host embeds Element Call it already has a client, and owns the + * user's session; supplying it here means Element Call neither authenticates + * anyone nor manages their session. Left out, Element Call finds a client + * itself — from the widget API, or by restoring or creating a session of its + * own. + */ + client?: MatrixClient; } -export const ClientProvider: FC = ({ children }) => { +export const ClientProvider: FC = ({ children, client }) => { const navigate = useNavigate(); const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< InitResult | null | undefined - >(undefined); + >( + client === undefined + ? undefined + : // A supplied client belongs to the host, so there is no session of ours + // to restore and nothing to wait for. + { client, passwordlessUser: false }, + ); const initializing = useRef(false); useEffect(() => { + if (client !== undefined) return; // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -161,7 +178,7 @@ export const ClientProvider: FC = ({ children }) => { }) .catch((err) => logger.error(err)) .finally(() => (initializing.current = false)); - }, []); + }, [client]); const changePassword = useCallback( async (password: string) => { @@ -228,11 +245,13 @@ export const ClientProvider: FC = ({ children }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other - // running instances of the app. This isn't necessary if the app is running in - // a widget though, since then it'll be mostly stateless. + // running instances of the app. Element Call only has storage of its own to + // protect when it created the session itself; given a client, or running as a + // widget, it is mostly stateless. + const ownsSession = client === undefined && widget === null; useEffect(() => { - if (!widget) loadChannel?.postMessage({}); - }, []); + if (ownsSession) loadChannel?.postMessage({}); + }, [ownsSession]); const [alreadyOpenedErr, setAlreadyOpenedErr] = useState( undefined, From 182be8676d06df99256efda7414542d9efbca490 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 16:56:09 +0200 Subject: [PATCH 12/54] Return the widget from initializeWidget instead of assigning a global Element Call reached the widget API through a mutable module-level binding, which every consumer imported directly. Nothing outside the app shell needs it any more, so hand it back from initializeWidget and thread it through: the initializer returns it, main passes it to App, and App uses it to build the host bridge and to await the client the host is lending us. ClientContext's loadClient is now only about restoring or creating a session of Element Call's own, since a widget's client arrives as a prop like any other host's would. Also fixes an early return added in the previous commit, which skipped starting the analytics settings listener when a client was supplied. That was harmless until now, but would have stopped analytics following the user's choices in widget mode. sdk/main.ts asked the host to close by hand; it now uses the bridge, which also stops the transport as the app does. --- sdk/main.ts | 26 ++++++++----------- src/App.tsx | 59 ++++++++++++++++++++++++++++--------------- src/ClientContext.tsx | 32 +++++++++++------------ src/initializer.tsx | 8 +++--- src/main.tsx | 3 ++- src/widget.test.ts | 4 +-- src/widget.ts | 24 +++++++++--------- 7 files changed, 84 insertions(+), 72 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index de15a5759..4a7926a16 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -54,11 +54,7 @@ import { MediaDevices } from "../src/state/MediaDevices"; import { E2eeType } from "../src/e2ee/e2eeType"; import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { - ElementWidgetActions, - widget as _widget, - initializeWidget, -} from "../src/widget"; +import { initializeWidget } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { createWidgetHostBridge } from "../src/HostBridge"; @@ -110,8 +106,7 @@ export async function createMatrixRTCSdk( const scope = new ObservableScope(); // widget client - initializeWidget(application, true); - const widget = _widget; + const widget = initializeWidget(application, true); if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; const hostBridge = createWidgetHostBridge(widget); @@ -294,18 +289,17 @@ export async function createMatrixRTCSdk( }); await leaveResolver.promise; logger.info("send Unstick"); - await widget.api + await hostBridge .setAlwaysOnScreen(false) - .catch((e) => - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ), + .catch((e: unknown) => + logger.error("Failed to set `alwaysOnScreen` to false", e), ); logger.info("send Close"); - await widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => logger.error("Failed to send close action", e)); + await hostBridge + .close?.() + .catch((e: unknown) => + logger.error("Failed to ask the host to close", e), + ); }; // schedule close first and then leave (scope.end) diff --git a/src/App.tsx b/src/App.tsx index 1d51f9598..8d272989e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { type MatrixClient } from "matrix-js-sdk"; import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; @@ -19,7 +20,7 @@ import { RoomPage } from "./room/RoomPage"; import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; import { Initializer } from "./initializer"; -import { widget } from "./widget"; +import { type WidgetHelpers } from "./widget"; import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; @@ -81,9 +82,11 @@ const MaybeAppBar: FC = ({ children }) => { interface Props { vm: AppViewModel; + /** A point of access to the widget API, if running as a widget. */ + widget: WidgetHelpers | null; } -export const App: FC = ({ vm }) => { +export const App: FC = ({ vm, widget }) => { // The standalone build has no host; the widget build's host is the client it // is a widget of. const hostBridge = useInitial(() => @@ -100,26 +103,40 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - const content = loaded ? ( - - - - } - > - - } /> - } /> - } /> - } /> - - - - - - ) : ( - + // As a widget, the client comes from the host over the widget API. Standalone, + // Element Call finds one itself, so there is nothing to wait for here. + const [widgetClient, setWidgetClient] = useState( + undefined, ); + useEffect(() => { + if (widget === null) return; + widget.client + .then(setWidgetClient) + .catch((e) => logger.error("Failed to obtain the host's client", e)); + }, [widget]); + const clientReady = widget === null || widgetClient !== undefined; + + const content = + loaded && clientReady ? ( + + + + } + > + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); return ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 6ad017f15..388beb8b1 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -22,7 +22,6 @@ import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; import { ErrorPage } from "./FullScreenView"; -import { widget } from "./widget"; import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, @@ -163,7 +162,12 @@ export const ClientProvider: FC = ({ children, client }) => { const initializing = useRef(false); useEffect(() => { - if (client !== undefined) return; + if (client !== undefined) { + // Nothing to load, but analytics still need to follow the user's choices. + if (PosthogAnalytics.instance.isEnabled()) + PosthogAnalytics.instance.startListeningToSettingsChanges(); + return; + } // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -246,9 +250,9 @@ export const ClientProvider: FC = ({ children, client }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other // running instances of the app. Element Call only has storage of its own to - // protect when it created the session itself; given a client, or running as a - // widget, it is mostly stateless. - const ownsSession = client === undefined && widget === null; + // protect when it created the session itself; given a client — by a host, or + // over the widget API — it is mostly stateless. + const ownsSession = client === undefined; useEffect(() => { if (ownsSession) loadChannel?.postMessage({}); }, [ownsSession]); @@ -349,19 +353,13 @@ export type InitResult = { passwordlessUser: boolean; }; +/** + * Restores or creates a session of Element Call's own. Only reached when no + * client was supplied for it to use. + */ async function loadClient(): Promise { - if (widget) { - // We're inside a widget, so let's engage *matryoshka mode* - logger.log("Using a matryoshka client"); - const client = await widget.client; - return { - client, - passwordlessUser: false, - }; - } else { - const { initSPA } = await import("./utils/spa"); - return initSPA(loadSession, clearSession); - } + const { initSPA } = await import("./utils/spa"); + return initSPA(loadSession, clearSession); } export interface Session { diff --git a/src/initializer.tsx b/src/initializer.tsx index 0927d7aba..ee3ee9bd4 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -32,7 +32,7 @@ import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; -import { initializeWidget } from "./widget"; +import { initializeWidget, type WidgetHelpers } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; import { type AnalyticsConfig, @@ -155,8 +155,8 @@ export class Initializer { return !!Initializer.internalInstance?.isInitialized; } - public static async initBeforeReact(): Promise { - initializeWidget(); + public static async initBeforeReact(): Promise { + const widget = initializeWidget(); const polyfills: Promise[] = []; if (shouldPolyfillSegmenter()) { @@ -243,6 +243,8 @@ export class Initializer { }); window.setLKLogLevel = setLKLogLevel; + + return widget; } public static init(): Promise | null { diff --git a/src/main.tsx b/src/main.tsx index 03c74c94c..b2f79f34e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,7 +49,7 @@ if (fatalError !== null) { } Initializer.initBeforeReact() - .then(() => { + .then((widget) => { const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( @@ -60,6 +60,7 @@ Initializer.initBeforeReact() callIntent, }) } + widget={widget} /> , ); diff --git a/src/widget.test.ts b/src/widget.test.ts index 3618fb505..c645c3b34 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -9,7 +9,7 @@ import { describe, expect, vi, it, beforeEach } from "vitest"; import { createRoomWidgetClient, EventType } from "matrix-js-sdk"; import { getUrlParams } from "./UrlParams"; -import { initializeWidget, widget } from "./widget"; +import { initializeWidget } from "./widget"; import { Config } from "./config/Config"; import { ElementCallReactionEventType } from "./reactions"; @@ -42,7 +42,7 @@ beforeEach(() => { describe("widget", () => { it("should create an embedded client with the correct params", () => { - initializeWidget("ANYRTCAPP"); + const widget = initializeWidget("ANYRTCAPP"); expect(getUrlParams()).toStrictEqual({ widgetId: "id", diff --git a/src/widget.ts b/src/widget.ts index af6f28b6d..b35e335bc 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -58,21 +58,21 @@ export interface WidgetHelpers { } /** - * A point of access to the widget API, if the app is running as a widget. This - * is initialized with `initializeWidget`. This should happen at the top level because the widget messaging - * needs to be set up ASAP on load to ensure it doesn't miss any requests. - */ -export let widget: WidgetHelpers | null = null; - -/** - * Should be called as soon as possible on app start. (In the initilizer before react) + * Connects to the widget API, if Element Call is running as a widget. + * + * Should be called as soon as possible on app start (in the initializer, before + * React), because the widget messaging needs to be set up ASAP on load to + * ensure it doesn't miss any requests. + * + * @returns A point of access to the widget API, or null if Element Call is not + * running as a widget. */ // this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before // execution. export const initializeWidget = ( rtcApplication: string = "m.call", sendRoomEvents = false, -): void => { +): WidgetHelpers | null => { try { const { widgetId, @@ -202,14 +202,14 @@ export const initializeWidget = ( return client; }; - widget = { api, lazyActions, client: clientPromise() }; + return { api, lazyActions, client: clientPromise() }; } else { if (import.meta.env.MODE !== "test") logger.info("No widget API available"); - widget = null; + return null; } } catch (e) { logger.warn("Continuing without the widget API", e); - widget = null; + return null; } }; From 6b5f396e1ebb3a6476bc182c090aa5af7c8a4cf9 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:26:33 +0200 Subject: [PATCH 13/54] Share the mapping from parameters to call view model options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK stopped sending join notifications: it threaded callIntent into createCallViewModel$ but not its pair sendNotificationType, which enterRTCSession used to read for itself, so an explicit ?sendNotificationType=ring — or an intent that implies one — no longer reached joinRTCSession. The mechanism is worth fixing rather than the instance. The defaults on CallViewModelOptions describe a standalone Element Call, so a widget caller that misses a field gets standalone behaviour rather than an error, and the SDK is only ever a widget. Give both callers one shared mapping so they cannot drift, and cover the whole chain from URL to options in tests. autoLeaveWhenOthersLeft and waitForCallPickup stay out of it: the view model never read those from the parameters, so enabling them for the SDK would be a change in its behaviour rather than a fix. --- sdk/main.ts | 11 +++-- src/room/InCallView.tsx | 20 ++------- src/state/CallViewModel/CallViewModel.test.ts | 44 +++++++++++++++++-- src/state/CallViewModel/CallViewModel.ts | 36 ++++++++++++++- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index 4a7926a16..6347d1421 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -46,7 +46,10 @@ import { // Can this be done in the tsconfig.json import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types"; import { type Behavior, constant } from "../src/state/Behavior"; -import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel"; +import { + callViewModelOptionsFromParams, + createCallViewModel$, +} from "../src/state/CallViewModel/CallViewModel"; import { ObservableScope } from "../src/state/ObservableScope"; import { getUrlParams } from "../src/UrlParams"; import { MuteStates } from "../src/state/MuteStates"; @@ -113,7 +116,8 @@ export async function createMatrixRTCSdk( logger.info("client created"); // url params - const { roomId, controlledAudioDevices, callIntent } = getUrlParams(); + const urlParams = getUrlParams(); + const { roomId, controlledAudioDevices, callIntent } = urlParams; if (roomId === null) throw Error("could not get roomId from url params"); const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); @@ -144,10 +148,9 @@ export async function createMatrixRTCSdk( mediaDevices, muteStates, { + ...callViewModelOptionsFromParams(urlParams), encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, hostBridge, - controlledAudioDevices, - callIntent, }, of({}), of({}), diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index ef1e1f988..97fb75438 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -41,6 +41,7 @@ import { type MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; import { type CallViewModel, + callViewModelOptionsFromParams, createCallViewModel$, } from "../state/CallViewModel/CallViewModel.ts"; import { Grid, type TileProps } from "../grid/Grid"; @@ -123,16 +124,8 @@ export const ActiveCall: FC = (props) => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); - const { - autoLeaveWhenOthersLeft, - waitForCallPickup, - sendNotificationType, - controlledAudioDevices, - header, - showControls, - hideScreensharing, - callIntent, - } = urlParams; + const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = + urlParams; const vm = createCallViewModel$( scope, @@ -141,17 +134,12 @@ export const ActiveCall: FC = (props) => { mediaDevices, props.muteStates, { + ...callViewModelOptionsFromParams(urlParams), encryptionSystem: props.e2eeSystem, hostBridge, autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, - controlledAudioDevices, - header, - showControls, - hideScreensharing, - sendNotificationType, - callIntent, }, reactionsReader.raisedHands$, reactionsReader.reactions$, diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 7a27fd3e0..c7783d152 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -68,6 +68,8 @@ import { } from "./CallViewModelTestUtils.ts"; import { MatrixRTCMode } from "../../config/ConfigOptions.ts"; import { initializeWidget } from "../../widget.ts"; +import { computeUrlParams } from "../../UrlParams.ts"; +import { callViewModelOptionsFromParams } from "./CallViewModel.ts"; initializeWidget(); @@ -83,9 +85,6 @@ vi.mock("livekit-client/e2ee-worker?worker"); vi.mock("../e2ee/matrixKeyProvider"); -const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); -vi.mock("../UrlParams", () => ({ getUrlParams })); - const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../../Platform", () => ({ get platform(): string { @@ -1701,3 +1700,42 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { }); }); }); + +describe("callViewModelOptionsFromParams", () => { + // The defaults on CallViewModelOptions describe a standalone Element Call, so + // a widget caller that drops one of these gets standalone behaviour rather + // than an error. These check the whole chain from URL to options, which is + // where that went wrong for the SDK. + const widgetUrl = (extra: string): string => + `#?widgetId=id&parentUrl=${encodeURIComponent("http://parent")}&${extra}`; + + it("carries an explicitly requested notification type", () => { + const params = computeUrlParams("", widgetUrl("sendNotificationType=ring")); + expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe( + "ring", + ); + }); + + it("carries the notification type an intent implies", () => { + const params = computeUrlParams("", widgetUrl("intent=start_call_dm")); + expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe( + "ring", + ); + }); + + it("carries hideScreensharing", () => { + const params = computeUrlParams("", widgetUrl("hideScreensharing=true")); + expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true); + }); + + it("carries controlledAudioDevices and the call intent", () => { + const params = computeUrlParams( + "", + widgetUrl("controlledAudioDevices=true&intent=start_call_voice"), + ); + expect(callViewModelOptionsFromParams(params)).toMatchObject({ + controlledAudioDevices: true, + callIntent: "audio", + }); + }); +}); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index b40a45231..85ba27573 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -86,7 +86,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { HeaderStyle } from "../../UrlParams"; +import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { @@ -216,6 +216,40 @@ export interface CallViewModelOptions { toggleScreensharing?: () => void; } +/** + * The options {@link createCallViewModel$} takes from the parameters Element + * Call was started with. + * + * Callers share this rather than picking the fields out themselves. The + * defaults on {@link CallViewModelOptions} describe a standalone Element Call, + * so a widget or embedded caller that misses one does not get an error — it + * quietly gets standalone behaviour instead. + * + * Note `autoLeaveWhenOthersLeft` and `waitForCallPickup` are deliberately not + * here: unlike these, the view model never read them from the parameters + * itself, so they remain the caller's decision. + */ +export function callViewModelOptionsFromParams( + params: UrlParams, +): Pick< + CallViewModelOptions, + | "controlledAudioDevices" + | "header" + | "showControls" + | "hideScreensharing" + | "sendNotificationType" + | "callIntent" +> { + return { + controlledAudioDevices: params.controlledAudioDevices, + header: params.header, + showControls: params.showControls, + hideScreensharing: params.hideScreensharing, + sendNotificationType: params.sendNotificationType, + callIntent: params.callIntent, + }; +} + // Do not play any sounds if the participant count has exceeded this // number. export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; From c807b8ef7d20f37aa86eb1aa1c110d73eef12ad0 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:26:59 +0200 Subject: [PATCH 14/54] Always stop the widget transport when closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() sent io.element.close and then stopped the transport, so a rejected send skipped the stop. Both call sites this replaced stopped it unconditionally — ErrorView in a finally, GroupCallView outside its try/catch — because a host that never acknowledges the request would otherwise leave the messaging live and the close button doing nothing. Restore that with a finally. --- src/HostBridge.test.ts | 32 +++++++++++++++++++++++++++++++- src/HostBridge.ts | 10 ++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index 2ddee9890..e93fcfb1e 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -10,7 +10,7 @@ import { type WidgetApi } from "matrix-widget-api"; import EventEmitter from "events"; import { createWidgetHostBridge, nullHostBridge } from "./HostBridge"; -import { type WidgetHelpers } from "./widget"; +import { ElementWidgetActions, type WidgetHelpers } from "./widget"; function mockWidget(api: Partial): WidgetHelpers { return { @@ -60,6 +60,36 @@ describe("createWidgetHostBridge", () => { }); }); + describe("close", () => { + test("asks the host to close, then stops the transport", async () => { + const transport = { + send: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + }; + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge.close!(); + + expect(transport.send).toHaveBeenCalledWith( + ElementWidgetActions.Close, + {}, + ); + expect(transport.stop).toHaveBeenCalledOnce(); + }); + + test("stops the transport even when the host refuses to close", async () => { + const transport = { + send: vi.fn().mockRejectedValue(new Error("no")), + stop: vi.fn(), + }; + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + // Leaving the messaging live would leave the close affordance dead + await expect(bridge.close!()).rejects.toThrow("no"); + expect(transport.stop).toHaveBeenCalledOnce(); + }); + }); + describe("supportsReactions", () => { const capabilities = [ "org.matrix.msc2762.send.event:m.reaction", diff --git a/src/HostBridge.ts b/src/HostBridge.ts index f31b5a4af..bb078672d 100644 --- a/src/HostBridge.ts +++ b/src/HostBridge.ts @@ -163,8 +163,14 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { notifyDeviceMute: async (state) => send(ElementWidgetActions.DeviceMute, state), close: async () => { - await send(ElementWidgetActions.Close); - widget.api.transport.stop(); + try { + await send(ElementWidgetActions.Close); + } finally { + // Stop regardless of whether the host acknowledged the request. A host + // that rejects or never answers would otherwise leave the messaging + // live, and the close affordance doing nothing at all. + widget.api.transport.stop(); + } }, themeChange$: requests(WidgetApiToWidgetAction.ThemeChange), join$: requests(ElementWidgetActions.JoinCall), From b7efa02adcc3bc9de04716842050435b6d3bb5a8 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:27:44 +0200 Subject: [PATCH 15/54] review: Follow a swapped client --- src/ClientContext.test.tsx | 29 +++++++++++++++++++++++++++-- src/ClientContext.tsx | 9 ++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx index 909635d89..d279a87ee 100644 --- a/src/ClientContext.test.tsx +++ b/src/ClientContext.test.tsx @@ -13,11 +13,11 @@ import { type FC } from "react"; import { ClientProvider, useClientState } from "./ClientContext"; -const mockClient = (): MatrixClient => +const mockClient = (userId = "@alice:example.org"): MatrixClient => ({ on: vi.fn(), removeListener: vi.fn(), - getUserId: () => "@alice:example.org", + getUserId: () => userId, getDeviceId: () => "AAAA", stopClient: vi.fn(), }) as Partial as MatrixClient; @@ -67,3 +67,28 @@ test("does not claim exclusive use of storage when given a client", () => { postMessage.mockRestore(); }); + +test("follows the client when the host swaps it", () => { + const first = mockClient(); + const second = mockClient("@bob:example.org"); + + const { container, rerender } = render( + + + + + , + ); + expect(container.textContent).toBe("@alice:example.org"); + + // A host that re-authenticates hands us a new client on a mounted component + rerender( + + + + + , + ); + + expect(container.textContent).toBe("@bob:example.org"); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 388beb8b1..ced48d446 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -163,7 +163,14 @@ export const ClientProvider: FC = ({ children, client }) => { const initializing = useRef(false); useEffect(() => { if (client !== undefined) { - // Nothing to load, but analytics still need to follow the user's choices. + // Nothing to load, but a host may hand us a different client later — on + // re-authenticating, say — so follow whichever one it has given us. + setInitClientState((current) => + current?.client === client + ? current + : { client, passwordlessUser: false }, + ); + // Analytics still need to follow the user's choices. if (PosthogAnalytics.instance.isEnabled()) PosthogAnalytics.instance.startListeningToSettingsChanges(); return; From e68d1d1b5fe819b96b3db1452bfe74f107881d36 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:28:14 +0200 Subject: [PATCH 16/54] Say what the root element seam cannot do yet RootElementContext's documentation promised that Element Call confines its decoration to the given element, but several selectors still name body directly and the initializer writes data-platform onto it. A non-body root is decorated correctly and styled incorrectly, with no error to show for it, so the documentation should say so until the stylesheets are scoped. --- src/RootElementContext.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index b97b17796..b46573d38 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -12,9 +12,17 @@ import { createContext, use } from "react"; * * Element Call decorates this element with the theme, layout and background * attributes its stylesheets key off, and portals its modals into it. When - * Element Call owns the page this is simply the document body; when it is - * embedded in a host application it is the container the host mounted it into, - * so that Element Call does not reach outside its own subtree. + * Element Call owns the page this is simply the document body; the intent is + * that when embedded in a host application it becomes the container the host + * mounted it into, so that Element Call does not reach outside its own subtree. + * + * That intent is not yet achievable: several selectors still name `body` + * directly — `body[data-background="gradient"]` and `body[data-platform=…]` in + * `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and + * `Modal.module.css` — and `Initializer.initBeforeReact` writes + * `data-platform` straight onto the body. So anything other than the body will + * be decorated correctly and styled incorrectly, silently. Until those are + * scoped, treat this as preparation rather than a working seam. */ const RootElementContext = createContext(null); From 873dfdbfbd77e0d80dfba7d29e35223ee251d336 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:46:44 +0200 Subject: [PATCH 17/54] Drop the unused root element provider RootElementProvider was exported but never used: nothing supplies a root element, so every consumer falls back to the document body. Knip reports it, which is what is failing CI. M1 adds a provider back along with the component that mounts Element Call into a container. Until then there is nothing to provide. --- src/RootElementContext.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index b46573d38..d8211c74b 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -24,10 +24,11 @@ import { createContext, use } from "react"; * be decorated correctly and styled incorrectly, silently. Until those are * scoped, treat this as preparation rather than a working seam. */ +// No provider is exported yet: nothing supplies a root element, so every +// consumer falls back to the document body. M1 adds one along with the +// component that mounts Element Call into a container. const RootElementContext = createContext(null); -export const RootElementProvider = RootElementContext.Provider; - /** * The element Element Call should decorate and portal into. * From ebb7fe07b749a935aa5498657dc5e278d0ba96d9 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 11:47:35 +0200 Subject: [PATCH 18/54] add more test --- src/ClientContext.test.tsx | 23 +++- src/HostBridge.test.ts | 161 ++++++++++++++++++++++++- src/analytics/PosthogAnalytics.test.ts | 85 +++++++++++++ src/e2ee/sharedKeyManagement.test.ts | 36 ++++++ src/state/MediaDevices.test.ts | 59 +++++++++ src/utils/errors.test.ts | 62 ++++++++++ 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 src/e2ee/sharedKeyManagement.test.ts create mode 100644 src/state/MediaDevices.test.ts create mode 100644 src/utils/errors.test.ts diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx index d279a87ee..65ff03d03 100644 --- a/src/ClientContext.test.tsx +++ b/src/ClientContext.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { expect, test, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC } from "react"; @@ -92,3 +92,24 @@ test("follows the client when the host swaps it", () => { expect(container.textContent).toBe("@bob:example.org"); }); + +test("finds a client of its own when the host supplies none", async () => { + const client = mockClient(); + vi.doMock("./utils/spa", () => ({ + initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }), + })); + + const { container } = render( + + + + + , + ); + + // Nothing to show until a session has been restored or created + expect(container.textContent).toBe("loading"); + await waitFor(() => expect(container.textContent).toBe("@alice:example.org")); + + vi.doUnmock("./utils/spa"); +}); diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index e93fcfb1e..4ab42a135 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -6,10 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { describe, expect, test, vi } from "vitest"; -import { type WidgetApi } from "matrix-widget-api"; +import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api"; import EventEmitter from "events"; -import { createWidgetHostBridge, nullHostBridge } from "./HostBridge"; +import { type Observable } from "rxjs"; + +import { + createWidgetHostBridge, + type HostBridge, + nullHostBridge, +} from "./HostBridge"; import { ElementWidgetActions, type WidgetHelpers } from "./widget"; function mockWidget(api: Partial): WidgetHelpers { @@ -20,7 +26,158 @@ function mockWidget(api: Partial): WidgetHelpers { } as unknown as WidgetHelpers; } +/** A widget whose transport records what Element Call sends it. */ +function mockTransport(): { + send: ReturnType; + reply: ReturnType; + stop: ReturnType; +} { + return { + send: vi.fn().mockResolvedValue(undefined), + reply: vi.fn(), + stop: vi.fn(), + }; +} + describe("createWidgetHostBridge", () => { + describe("telling the host what Element Call is doing", () => { + test("asks to be kept on screen, and to stop being", async () => { + const setAlwaysOnScreen = vi.fn().mockResolvedValue(true); + const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen })); + + await bridge.setAlwaysOnScreen(true); + await bridge.setAlwaysOnScreen(false); + + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true); + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false); + }); + + test("reports that it has loaded", async () => { + const sendContentLoaded = vi.fn().mockResolvedValue(undefined); + const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded })); + + await bridge.contentLoaded(); + + expect(sendContentLoaded).toHaveBeenCalledOnce(); + }); + + test.each([ + ["notifyJoined", ElementWidgetActions.JoinCall, {}], + ["notifyHungUp", ElementWidgetActions.HangupCall, {}], + ] as const)("sends %s as %s", async (method, action, payload) => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge[method](); + + expect(transport.send).toHaveBeenCalledWith(action, payload); + }); + + test("sends the mute state the host needs to mirror", async () => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge.notifyDeviceMute({ + audio_enabled: true, + video_enabled: false, + }); + + expect(transport.send).toHaveBeenCalledWith( + ElementWidgetActions.DeviceMute, + { audio_enabled: true, video_enabled: false }, + ); + }); + }); + + describe("relaying what the host asks for", () => { + /** Emits a widget action the way widget.ts does, and returns the event. */ + function askHost( + widget: WidgetHelpers, + action: string, + data: unknown, + ): CustomEvent { + const ev = new CustomEvent(action, { detail: { action, data } }); + widget.lazyActions.emit(action, ev); + return ev; + } + + // Selectors rather than keys, so each stream keeps its own request type + const inboundStreams: [ + name: string, + select: (bridge: HostBridge) => Observable<{ data: unknown }>, + action: string, + ][] = [ + [ + "themeChange$", + (bridge) => bridge.themeChange$, + WidgetApiToWidgetAction.ThemeChange, + ], + ["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall], + ["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall], + [ + "deviceMute$", + (bridge) => bridge.deviceMute$, + ElementWidgetActions.DeviceMute, + ], + ]; + + test.each(inboundStreams)( + "surfaces %s with the host's data", + (_name, select, action) => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + select(bridge).subscribe((request) => seen.push(request.data)); + + askHost(widget, action, { some: "payload" }); + + expect(seen).toEqual([{ some: "payload" }]); + }, + ); + + test("replies to the host against the request it made", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.deviceMute$.subscribe((request) => + request.reply({ audio_enabled: false, video_enabled: true }), + ); + + const ev = askHost(widget, ElementWidgetActions.DeviceMute, { + audio_enabled: false, + }); + + expect(transport.reply).toHaveBeenCalledWith(ev.detail, { + audio_enabled: false, + video_enabled: true, + }); + }); + + test("still replies when there is nothing to say", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.hangUp$.subscribe((request) => request.reply()); + + const ev = askHost(widget, ElementWidgetActions.HangupCall, {}); + + // The widget API requires an answer, so an empty reply becomes {} + expect(transport.reply).toHaveBeenCalledWith(ev.detail, {}); + }); + + test("stops listening once unsubscribed", () => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data)); + + subscription.unsubscribe(); + askHost(widget, ElementWidgetActions.HangupCall, {}); + + expect(seen).toEqual([]); + }); + }); + describe("downloadMedia", () => { const mxcUri = "mxc://example.org/alice-avatar"; diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index ba51ba276..cd821c175 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -15,6 +15,7 @@ import { afterAll, } from "vitest"; import posthog, { type CaptureResult } from "posthog-js"; +import { type MatrixClient } from "matrix-js-sdk"; import { Anonymity, @@ -23,6 +24,7 @@ import { } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; import { analyticsConfigFromEnvironment } from "../initializer"; +import { optInAnalytics } from "../settings/settings"; describe("PosthogAnalytics", () => { describe("enablement", () => { @@ -281,3 +283,86 @@ describe("PosthogAnalytics", () => { }); }); }); + +describe("identifying the user", () => { + const credentials = { + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }; + + function mockClient(accountDataId: string | null): MatrixClient { + return { + isGuest: () => false, + getCrypto: () => undefined, + getAccountDataFromServer: vi + .fn() + .mockResolvedValue( + accountDataId === null ? null : { id: accountDataId }, + ), + setAccountData: vi.fn().mockResolvedValue({}), + } as Partial as MatrixClient; + } + + beforeEach(() => { + PosthogAnalytics.resetInstance(); + optInAnalytics.setValue(true); + }); + + it("reports under the ID its host assigned, and stores nothing", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ + ...credentials, + matrixBackend: "embedded", + hostAnalyticsId: "assigned-by-host", + }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("assigned-by-host"), + ); + + // The host owns the user's account, so Element Call must not write to it + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("keeps its own ID in account data when it owns the session", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + // No ID on the server yet, so one is minted and stored for other devices + await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled()); + }); + + it("reuses the ID already in account data", async () => { + const client = mockClient("stored-earlier"); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("stored-earlier"), + ); + + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("records how it reaches Matrix as a super property", async () => { + window.matrixclient = mockClient("stored-earlier"); + PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" }); + const register = vi.spyOn(posthog, "register"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + await vi.waitFor(() => + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ matrixBackend: "embedded" }), + ), + ); + }); +}); diff --git a/src/e2ee/sharedKeyManagement.test.ts b/src/e2ee/sharedKeyManagement.test.ts new file mode 100644 index 000000000..9d0375309 --- /dev/null +++ b/src/e2ee/sharedKeyManagement.test.ts @@ -0,0 +1,36 @@ +/* +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 { afterEach, describe, expect, test } from "vitest"; + +import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement"; + +const roomId = "!room:example.org"; + +describe("getKeyForRoom", () => { + afterEach(() => { + window.location.hash = "#"; + localStorage.clear(); + }); + + test("prefers a key given in the parameters over the stored one", () => { + saveKeyForRoom(roomId, "stored"); + window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`; + + expect(getKeyForRoom(roomId)).toBe("from-the-link"); + }); + + test("falls back to the stored key", () => { + saveKeyForRoom(roomId, "stored"); + + expect(getKeyForRoom(roomId)).toBe("stored"); + }); + + test("has no key to offer for a room it has never seen", () => { + expect(getKeyForRoom(roomId)).toBeNull(); + }); +}); diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts new file mode 100644 index 000000000..f65b024e2 --- /dev/null +++ b/src/state/MediaDevices.test.ts @@ -0,0 +1,59 @@ +/* +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 { describe, expect, test, vi } from "vitest"; +import { of } from "rxjs"; + +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); +vi.mock("@livekit/components-core", () => ({ + createMediaDeviceObserver: () => of([]), +})); + +import { AudioOutput, MediaDevices } from "./MediaDevices"; +import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; +import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput"; +import { ObservableScope } from "./ObservableScope"; + +// Which audio output implementation is used is decided by what the app hosting +// Element Call told it, rather than being discovered from the environment. +describe("MediaDevices audio output", () => { + test("uses the browser's own output when nobody else is controlling it", () => { + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: false, + }); + + expect(devices.audioOutput).toBeInstanceOf(AudioOutput); + }); + + test("hands control to the host on Android", () => { + getPlatform.mockReturnValue("android"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "audio", + }); + + expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput); + }); + + test("hands control to the host elsewhere too", () => { + getPlatform.mockReturnValue("ios"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "video", + }); + + expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); + }); +}); diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts new file mode 100644 index 000000000..306aa3cf8 --- /dev/null +++ b/src/utils/errors.test.ts @@ -0,0 +1,62 @@ +/* +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 { describe, expect, test } from "vitest"; + +import { + ErrorCategory, + ErrorCode, + FailToStartLivekitConnection, + MembershipManagerError, + NoMatrix2AuthorizationService, + SFURoomCreationRestrictedError, +} from "./errors"; + +// These errors take their wording from Element Call's own i18next instance +// rather than the global one, so each needs to come out translated rather than +// as a raw key. +describe("localised errors", () => { + test("MembershipManagerError describes the failure and keeps its cause", () => { + const cause = new Error("the underlying problem"); + const error = new MembershipManagerError(cause); + + expect(error.code).toBe(ErrorCode.INTERNAL_MEMBERSHIP_MANAGER); + expect(error.category).toBe(ErrorCategory.SYSTEM_FAILURE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("NoMatrix2AuthorizationService is a configuration problem", () => { + const cause = new Error("404"); + const error = new NoMatrix2AuthorizationService(cause); + + expect(error.code).toBe(ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("FailToStartLivekitConnection passes its detail through", () => { + const error = new FailToStartLivekitConnection("could not publish"); + + expect(error.code).toBe(ErrorCode.FAILED_TO_START_LIVEKIT); + expect(error.category).toBe(ErrorCategory.NETWORK_CONNECTIVITY); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).toBe("could not publish"); + }); + + test("SFURoomCreationRestrictedError explains the restriction", () => { + const error = new SFURoomCreationRestrictedError(); + + expect(error.code).toBe(ErrorCode.SFU_ERROR); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + }); +}); From 1bfb018d907e2630eead5226ee5b9613552ad696 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 12:10:04 +0200 Subject: [PATCH 19/54] Introduce ElementCallView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Being in a call and deciding to show one are different jobs, but RoomPage did both: routing, authentication, resolving room aliases and knocking, and then the call itself. Only the first set belongs to whatever is hosting Element Call. Add ElementCallView as the seam between them. It takes a client and a call to join, and owns whether the user has joined — which is the call's own business rather than its host's. RoomPage keeps everything about arriving at a call and renders this for the call itself. Nothing else moves yet. muteStates is still passed in, because the standalone shell shares one with the lobby it shows while waiting to be let into a room, and two instances would both report the user's mute state to the host. --- src/ElementCallView.tsx | 73 +++++++++++++++++++++++++++++++++++++++++ src/room/RoomPage.tsx | 7 ++-- 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 src/ElementCallView.tsx diff --git a/src/ElementCallView.tsx b/src/ElementCallView.tsx new file mode 100644 index 000000000..72cf9ddaf --- /dev/null +++ b/src/ElementCallView.tsx @@ -0,0 +1,73 @@ +/* +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 { type FC, type ReactNode, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; + +import { GroupCallView } from "./room/GroupCallView"; +import { type MuteStates } from "./state/MuteStates"; +import { type UrlParams } from "./UrlParams"; + +interface Props { + /** The client to place the call with. */ + client: MatrixClient; + /** The call to join. */ + rtcSession: MatrixRTCSession; + /** The audio and video mute state to start from, and keep in step with. */ + muteStates: MuteStates; + /** + * Whether the user is signed in as a guest, and so should be offered the + * chance to create an account when the call ends. + */ + isPasswordlessUser: boolean; + /** Whether to keep the user in this call rather than letting them navigate. */ + confineToRoom: boolean; + /** Whether to wait for the host to ask us to join. */ + preload: UrlParams["preload"]; + /** Whether to enter the call directly, without showing the lobby first. */ + skipLobby: UrlParams["skipLobby"]; +} + +/** + * A call, as a component. + * + * This owns being in a call, and nothing about how Element Call came to be + * showing one: no routing, no authentication, no resolving of room aliases. + * Those belong to whatever is hosting it — the standalone app's own shell, or + * an application embedding Element Call directly. + * + * TODO: `muteStates` is still passed in, because the standalone shell shares + * one with the lobby it shows while waiting to be let into a room. Ownership + * moves here once that lobby has its own. + */ +export const ElementCallView: FC = ({ + client, + rtcSession, + muteStates, + isPasswordlessUser, + confineToRoom, + preload, + skipLobby, +}): ReactNode => { + // Whether the user is in the call is the call's own business, not its host's. + const [joined, setJoined] = useState(false); + + return ( + + ); +}; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 33eb80ddb..604840e5f 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -25,7 +25,7 @@ import { import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; import { RoomAuthView } from "./RoomAuthView"; -import { GroupCallView } from "./GroupCallView"; +import { ElementCallView } from "../ElementCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; @@ -62,7 +62,6 @@ export const RoomPage: FC = (): ReactNode => { const { avatarUrl, displayName: userDisplayName } = useProfile(client); const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); - const [joined, setJoined] = useState(false); const devices = useMediaDevices(); const [muteStates, setMuteStates] = useState(null); @@ -125,11 +124,9 @@ export const RoomPage: FC = (): ReactNode => { case "loaded": return ( muteStates && ( - Date: Thu, 3 Sep 2026 12:55:52 +0200 Subject: [PATCH 20/54] Find Element Call's root by attribute, not by being the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six selectors named `body` directly — the gradient backdrop and the platform font overrides in index.css, and the iOS adjustments in AppBar.module.css and Modal.module.css — so they only applied when Element Call owned the page. A host mounting it into a container would have got an interface decorated correctly and styled incorrectly, with nothing to show that anything was wrong. Mark the root element with data-element-call-root and match on that instead. Scoping this way keeps the selectors more specific than they were, rather than less: widening them to a bare [data-platform=…] would have dropped specificity from (0,1,1) to (0,1,0) and changed which rules win. The platform attribute moves with them, from the initializer's write onto document.body to a layout effect on the root, alongside the theme — so it still lands before anything is painted. No visual change while Element Call owns the page: the root is the body, which now carries the attribute, so every rewritten selector matches the element it always did. --- src/AppBar.module.css | 2 +- src/Modal.module.css | 2 +- src/RootElementContext.ts | 19 ++++++++++--------- src/index.css | 8 ++++---- src/initializer.tsx | 4 ---- src/useTheme.test.ts | 15 +++++++++++++++ src/useTheme.ts | 9 +++++++++ 7 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/AppBar.module.css b/src/AppBar.module.css index cc63854e7..faf2b0bf1 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -131,7 +131,7 @@ } } -body[data-platform="ios"] { +[data-element-call-root][data-platform="ios"] { .bar > header { grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x); grid-template-areas: "primaryButton title secondaryButton"; diff --git a/src/Modal.module.css b/src/Modal.module.css index ae8006a53..303272091 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -48,7 +48,7 @@ Please see LICENSE in the repository root for full details. --handle-inset-block-end: var(--cpd-space-4x); } -body[data-platform="ios"] .drawer { +[data-element-call-root][data-platform="ios"] .drawer { --border-radius: 10px; --handle-block-size: 5px; --handle-inline-size: 36px; diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index d8211c74b..48dc26134 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -16,17 +16,18 @@ import { createContext, use } from "react"; * that when embedded in a host application it becomes the container the host * mounted it into, so that Element Call does not reach outside its own subtree. * - * That intent is not yet achievable: several selectors still name `body` - * directly — `body[data-background="gradient"]` and `body[data-platform=…]` in - * `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and - * `Modal.module.css` — and `Initializer.initBeforeReact` writes - * `data-platform` straight onto the body. So anything other than the body will - * be decorated correctly and styled incorrectly, silently. Until those are - * scoped, treat this as preparation rather than a working seam. + * The stylesheets find this element by its `data-element-call-root` attribute, + * which {@link useTheme} sets along with the platform and theme, so they no + * longer depend on it being the body. + * + * What remains body-specific is the standalone page's own furniture: the + * `body` rule in `index.css` still sets the page background and margin, and + * `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. M1 adds one along with the -// component that mounts Element Call into a container. +// consumer falls back to the document body. One arrives with the entry point +// that mounts Element Call into a container. const RootElementContext = createContext(null); /** diff --git a/src/index.css b/src/index.css index 77db3394c..12f3951da 100644 --- a/src/index.css +++ b/src/index.css @@ -75,7 +75,7 @@ body { } @media (min-height: 330px) { - body[data-background="gradient"]::before { + [data-element-call-root][data-background="gradient"]::before { content: ""; position: fixed; /* Chromium abruptly fades our images to fully transparent at the edge of @@ -88,7 +88,7 @@ body { background-repeat: no-repeat; } - body[data-background="gradient"][data-platform="desktop"]::before { + [data-element-call-root][data-background="gradient"][data-platform="desktop"]::before { background-image: url("graphics/desktop-gradient.png"); background-size: max(1440px, 100vw) max(1440px, 100vh); background-position: center; @@ -126,11 +126,11 @@ body, /* On Android and iOS, prefer native system fonts. The global.css file of Compound Web is where these variables ultimately get consumed to set the page's font-family. */ -body[data-platform="android"] { +[data-element-call-root][data-platform="android"] { --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; } -body[data-platform="ios"] { +[data-element-call-root][data-platform="ios"] { --cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif; } diff --git a/src/initializer.tsx b/src/initializer.tsx index ee3ee9bd4..253dcbc41 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -30,7 +30,6 @@ import { import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; -import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget, type WidgetHelpers } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; @@ -229,9 +228,6 @@ export class Initializer { ); } - // Add the platform to the DOM, so CSS can query it - document.body.setAttribute("data-platform", platform); - // livekit logging configuration setLKLogExtension((level, msg, context) => { // we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read diff --git a/src/useTheme.test.ts b/src/useTheme.test.ts index e5accd15d..fe0cbffb3 100644 --- a/src/useTheme.test.ts +++ b/src/useTheme.test.ts @@ -19,6 +19,7 @@ import { } from "vitest"; import { useTheme } from "./useTheme"; +import { platform } from "./Platform"; import { useUrlParams } from "./UrlParams"; import { type HostBridge, @@ -88,6 +89,20 @@ describe("useTheme", () => { expect(originalClassList.add).not.toHaveBeenCalled(); }); + test("marks the element as Element Call's root, for the stylesheets", () => { + renderHook(() => useTheme(), { wrapper }); + + // The stylesheets find the root by this rather than by naming `body`, so + // that they still apply when Element Call is mounted into a container + expect(document.body.hasAttribute("data-element-call-root")).toBe(true); + }); + + test("records the platform on the root, for the stylesheets", () => { + renderHook(() => useTheme(), { wrapper }); + + expect(document.body.getAttribute("data-platform")).toBe(platform); + }); + test("theme changes in response to host requests", () => { renderHook(() => useTheme(), { wrapper }); diff --git a/src/useTheme.ts b/src/useTheme.ts index 3c02745a6..86a6f63fd 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -9,6 +9,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useUrlParams } from "./UrlParams"; import { useRootElement } from "./RootElementContext"; +import { platform } from "./Platform"; import { useHostBridge } from "./HostBridge"; export const useTheme = (): void => { @@ -28,6 +29,14 @@ export const useTheme = (): void => { return (): void => subscription.unsubscribe(); }, [hostBridge]); + // Mark the element as Element Call's root and record the platform on it, so + // that the stylesheets can find both without naming `body`. A layout effect, + // like the theme below, so that it lands before anything is painted. + useLayoutEffect(() => { + rootElement.setAttribute("data-element-call-root", ""); + rootElement.setAttribute("data-platform", platform); + }, [rootElement]); + useLayoutEffect(() => { // If no theme has been explicitly requested we default to dark const theme = requestedTheme?.includes("light") ? "light" : "dark"; From a166fbbd08384b241e54bc470df8cedc55fe5a59 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 15:15:51 +0200 Subject: [PATCH 21/54] Let the component own its mute state ElementCallView took muteStates as a prop, which would have meant an embedder building one before it could show a call. It could not simply make its own: RoomPage created one on mount whichever branch it went on to render, and two would both report the user's mute state to the host, talking over each other. Move the construction into a useMuteStates hook, and give the lobby shown while waiting to be let into a room its own component. Each lobby now holds mute state only while it is on screen, so there is never a second one, and the component can own the call's. KnockLobbyView also takes the room summary and label handling that RoomPage was assembling on its behalf, leaving the page with arriving at a call rather than being in one. --- src/ElementCallView.tsx | 12 ++-- src/room/KnockLobbyView.tsx | 92 +++++++++++++++++++++++++++++ src/room/RoomPage.tsx | 115 ++++++++---------------------------- src/state/useMuteStates.ts | 51 ++++++++++++++++ 4 files changed, 171 insertions(+), 99 deletions(-) create mode 100644 src/room/KnockLobbyView.tsx create mode 100644 src/state/useMuteStates.ts diff --git a/src/ElementCallView.tsx b/src/ElementCallView.tsx index 72cf9ddaf..d0b3e4f34 100644 --- a/src/ElementCallView.tsx +++ b/src/ElementCallView.tsx @@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { GroupCallView } from "./room/GroupCallView"; -import { type MuteStates } from "./state/MuteStates"; +import { useMuteStates } from "./state/useMuteStates"; import { type UrlParams } from "./UrlParams"; interface Props { @@ -18,8 +18,6 @@ interface Props { client: MatrixClient; /** The call to join. */ rtcSession: MatrixRTCSession; - /** The audio and video mute state to start from, and keep in step with. */ - muteStates: MuteStates; /** * Whether the user is signed in as a guest, and so should be offered the * chance to create an account when the call ends. @@ -40,15 +38,10 @@ interface Props { * showing one: no routing, no authentication, no resolving of room aliases. * Those belong to whatever is hosting it — the standalone app's own shell, or * an application embedding Element Call directly. - * - * TODO: `muteStates` is still passed in, because the standalone shell shares - * one with the lobby it shows while waiting to be let into a room. Ownership - * moves here once that lobby has its own. */ export const ElementCallView: FC = ({ client, rtcSession, - muteStates, isPasswordlessUser, confineToRoom, preload, @@ -56,6 +49,9 @@ export const ElementCallView: FC = ({ }): ReactNode => { // Whether the user is in the call is the call's own business, not its host's. const [joined, setJoined] = useState(false); + const muteStates = useMuteStates(); + + if (muteStates === null) return null; return ( void) | null; + confineToRoom: boolean; + hideHeader: boolean; +} + +/** + * The lobby shown while the user is outside a room they want to call in — + * either able to ask to join, or waiting for someone to answer. + * + * This belongs to the app shell rather than to the call: it exists precisely + * because there is no call to be in yet. It keeps its own mute state, which is + * why it is a component rather than part of the page — so that the call's mute + * state and this one are never alive at the same time, reporting over each + * other to the host. + */ +export const KnockLobbyView: FC = ({ + client, + roomSummary, + profile, + knock, + confineToRoom, + hideHeader, +}): ReactNode => { + const { t } = useTranslation(); + const muteStates = useMuteStates(); + + if (muteStates === null) return null; + + const waitingForInvite = knock === null; + const enterLabel: string | JSX.Element = waitingForInvite ? ( + <> + {t("lobby.waiting_for_invite")} + + + ) : ( + t("lobby.ask_to_join") + ); + + return ( + knock?.()} + enterLabel={enterLabel} + waitingForInvite={waitingForInvite} + confineToRoom={confineToRoom} + hideHeader={hideHeader} + participantCount={null} + muteStates={muteStates} + onShareClick={null} + /> + ); +}; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 604840e5f..f49509e30 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -6,21 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - useEffect, - useState, - type ReactNode, - useRef, - type JSX, -} from "react"; +import { type FC, useEffect, useState, type ReactNode, useRef } from "react"; import { type MatrixError } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { Trans, useTranslation } from "react-i18next"; -import { - CheckIcon, - UnknownSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; @@ -29,22 +19,15 @@ import { ElementCallView } from "../ElementCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; -import { LobbyView } from "./LobbyView"; -import { E2eeType } from "../e2ee/e2eeType"; +import { KnockLobbyView } from "./KnockLobbyView"; import { useProfile } from "../profile/useProfile"; import { useOptInAnalytics } from "../settings/settings"; import { Link } from "../button/Link"; import { ErrorView } from "../ErrorView"; -import { useMediaDevices } from "../MediaDevicesContext"; -import { MuteStates } from "../state/MuteStates"; -import { ObservableScope } from "../state/ObservableScope"; -import { calculateInitialMuteState } from "../state/initialMuteState.ts"; export const RoomPage: FC = (): ReactNode => { const urlParams = useUrlParams(); - const hostBridge = useHostBridge(); const { confineToRoom, preload, header, displayName, skipLobby } = urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -63,26 +46,6 @@ export const RoomPage: FC = (): ReactNode => { const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); - const devices = useMediaDevices(); - const [muteStates, setMuteStates] = useState(null); - - useEffect(() => { - const scope = new ObservableScope(); - setMuteStates( - new MuteStates( - scope, - devices, - calculateInitialMuteState( - urlParams.skipLobby, - urlParams.callIntent, - urlParams.isWidget, - ), - hostBridge, - ), - ); - return (): void => scope.end(); - }, [devices, urlParams, hostBridge]); - useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user @@ -123,64 +86,34 @@ export const RoomPage: FC = (): ReactNode => { switch (groupCallState.kind) { case "loaded": return ( - muteStates && ( - - ) + ); case "waitForInvite": case "canKnock": { wasInWaitForInviteState.current = wasInWaitForInviteState.current || groupCallState.kind === "waitForInvite"; - const knock = - groupCallState.kind === "canKnock" ? groupCallState.knock : null; - const label: string | JSX.Element = - groupCallState.kind === "canKnock" ? ( - t("lobby.ask_to_join") - ) : ( - <> - {t("lobby.waiting_for_invite")} - - - ); return ( - muteStates && ( - knock?.()} - enterLabel={label} - waitingForInvite={groupCallState.kind === "waitForInvite"} - confineToRoom={confineToRoom} - hideHeader={header !== "standard"} - participantCount={null} - muteStates={muteStates} - onShareClick={null} - /> - ) + ); } case "loading": diff --git a/src/state/useMuteStates.ts b/src/state/useMuteStates.ts new file mode 100644 index 000000000..ab35169f0 --- /dev/null +++ b/src/state/useMuteStates.ts @@ -0,0 +1,51 @@ +/* +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 { useEffect, useState } from "react"; + +import { MuteStates } from "./MuteStates"; +import { ObservableScope } from "./ObservableScope"; +import { calculateInitialMuteState } from "./initialMuteState"; +import { useMediaDevices } from "../MediaDevicesContext"; +import { useHostBridge } from "../HostBridge"; +import { useUrlParams } from "../UrlParams"; + +/** + * Audio and video mute state, kept in step with the host. + * + * `null` until the media devices have been looked at, since what the user + * starts muted depends on what they have. + * + * Whoever shows the user their own camera owns one of these. Note there should + * only ever be one alive at a time: each reports the user's mute state to the + * host, so two would have them talking over each other. + */ +export function useMuteStates(): MuteStates | null { + const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); + const devices = useMediaDevices(); + const [muteStates, setMuteStates] = useState(null); + + useEffect(() => { + const scope = new ObservableScope(); + setMuteStates( + new MuteStates( + scope, + devices, + calculateInitialMuteState( + urlParams.skipLobby, + urlParams.callIntent, + urlParams.isWidget, + ), + hostBridge, + ), + ); + return (): void => scope.end(); + }, [devices, urlParams, hostBridge]); + + return muteStates; +} From 979b5215631b1841fd056e7ec2edf90d0d8eefaf Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 15:18:03 +0200 Subject: [PATCH 22/54] 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", + ], + }, + }, +})); From db0f6837ce4fd8d0b3b5d627b2b430c466bf4740 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 16:10:26 +0200 Subject: [PATCH 23/54] Give the component what the app shell was providing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component built and typechecked in the previous commit, but only because nothing had rendered it. Everything Element Call needs that `src/main.tsx` side-loads was missing from it. Its stylesheet: only main.tsx imported index.css, so the library build emitted CSS-module styles with every `--cpd-*` and `--font-size-*` unresolved. Split into base.css, which both the app and the component import, and the rules that are about owning a page, which only the app does. The split is a straight move — comment-stripped and sorted, the old file and the two new ones differ by exactly one line — and that line is the deliberate part: `.no-scroll-body` becomes `body.no-scroll-body`. Element Call adds that class to whatever it treats as its root, and since the root can now be a container, `position: fixed` would have taken that container out of the host's layout. Pinning the page is what it always meant. Its translations: `initializeElementCall` called `i18n.init` with neither resources nor a backend, so every key would have rendered as itself. English is bundled in. The app fetches locale files from URLs its own build emits, which a host serving the library from somewhere else could not resolve, so how a host picks a language is left open. And the types a host needs: `HostBridge` alone is not enough to implement `HostBridge` — `HostRequest`, `DeviceMuteState`, `DeviceMuteRequest` and `JoinCallData` all appear in its signatures, and `ConfigOptions` in `initializeElementCall`'s. --- component/ElementCall.module.css | 8 ++ component/index.tsx | 31 ++++- src/base.css | 203 +++++++++++++++++++++++++++++++ src/index.css | 191 ++--------------------------- 4 files changed, 250 insertions(+), 183 deletions(-) create mode 100644 src/base.css diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css index 399178aea..461ad9b0c 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -22,3 +22,11 @@ using an iframe. */ -moz-osx-font-smoothing: grayscale; -webkit-tap-highlight-color: transparent; } + +/* Compound's overlay container, which holds tooltips and popovers, has to fill +the container for the elements inside it to be positioned against it. The +standalone page does the same for the container under `#root`. */ +.root > [data-overlay-container] { + position: relative; + block-size: 100%; +} diff --git a/component/index.tsx b/component/index.tsx index e904c8bed..1d3155d30 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -17,6 +17,18 @@ Please see LICENSE in the repository root for full details. * 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. +// +// 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, useMemo, useState } from "react"; import { type MatrixClient } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -26,6 +38,7 @@ 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 EN from "../locales/en/app.json"; import { ElementCallView } from "../src/ElementCallView"; import { ClientProvider } from "../src/ClientContext"; import { @@ -50,7 +63,17 @@ import { useTheme } from "../src/useTheme"; import { useInitial } from "../src/useInitial"; import styles from "./ElementCall.module.css"; -export { type HostBridge } from "../src/HostBridge"; +// Everything needed to implement a HostBridge, not just the interface itself +export { + type DeviceMuteRequest, + type DeviceMuteState, + type HostBridge, + type HostRequest, +} from "../src/HostBridge"; +export { type JoinCallData } from "../src/widget"; +// The deployment-wide configuration, as distinct from ElementCallConfiguration +// above, which is per call +export { type ConfigOptions } from "../src/config/ConfigOptions"; /** * How Element Call should behave. Everything is optional; anything left out @@ -102,6 +125,12 @@ export async function initializeElementCall( contextSeparator: "|", lng: "en", interpolation: { escapeValue: false }, + // English only, bundled in. The standalone app fetches its locale files at + // runtime from URLs its own build emits, which a host serving the library + // from elsewhere could not resolve; bundling one language at least keeps + // the component self-contained. Letting a host supply the rest, or its own + // translations, is still to do. + resources: { en: { app: EN } }, }); } diff --git a/src/base.css b/src/base.css new file mode 100644 index 000000000..0d424e64a --- /dev/null +++ b/src/base.css @@ -0,0 +1,203 @@ +/* +Copyright 2021-2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/* The styles Element Call needs wherever it is shown: the design tokens, fonts +and element defaults its own stylesheets build on top of. + +Split out from index.css so that Element Call embedded in a host application +can have these without also being given the standalone page's layout, which +would style the host's own document. What remains here does still reach outside +Element Call's container — normalize.css and the typography below use bare +element selectors, and the custom properties are declared on `:root` — so a +host gets those too. Narrowing them needs a real host to check against, so it +waits for the Element Web integration rather than being guessed at here. + +Nothing here should depend on where it lands relative to Element Call's +component stylesheets: the bundler decides that, and it decides differently for +the app and for the component build. */ + +@layer normalize, compound-legacy, compound; + +@import url("@fontsource/inter/400.css"); +@import url("@fontsource/inter/500.css"); +@import url("@fontsource/inter/600.css"); +@import url("@fontsource/inter/700.css"); +@import url("@fontsource/inconsolata/400.css"); +@import url("@fontsource/inconsolata/700.css"); + +@import url("normalize.css/normalize.css") layer(normalize); +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css") layer(compound.components); + +:root { + --font-scale: 1; + --font-size-micro: calc(10px * var(--font-scale)); + --font-size-caption: calc(12px * var(--font-scale)); + --font-size-body: calc(15px * var(--font-scale)); + --font-size-subtitle: calc(18px * var(--font-scale)); + --font-size-title: calc(24px * var(--font-scale)); + --font-size-headline: calc(32px * var(--font-scale)); + + --cpd-color-border-accent: var(--cpd-color-green-800); + /* The distance to inset non-full-width content from the edge of the window + along the inline axis. This ramps up from 16px for typical mobile windows, to + 96px for typical desktop windows, and accounts for the safe area. */ + --content-inset-left: calc( + env(safe-area-inset-left) + + min( + var(--cpd-space-24x), + max(var(--cpd-space-4x), calc((100vw - 900px) / 3)) + ) + ); + --content-inset-right: calc( + env(safe-area-inset-right) + + min( + var(--cpd-space-24x), + max(var(--cpd-space-4x), calc((100vw - 900px) / 3)) + ) + ); + --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); + --big-drop-shadow: 0px 0px 24px 0px #1b1d221a; + --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); + + --call-view-overlay-layer: 1; + --call-view-header-footer-layer: 2; +} + +:root, +[class*="cpd-theme-"] { + --video-tile-background: var(--cpd-color-bg-subtle-secondary); +} + +.cpd-theme-dark { + --cpd-color-border-accent: var(--cpd-color-green-1100); + --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); + --stopgap-background-85: rgba(16, 19, 23, 0.85); +} + +@media (min-height: 330px) { + [data-element-call-root][data-background="gradient"]::before { + content: ""; + position: fixed; + /* Chromium abruptly fades our images to fully transparent at the edge of + the element. If we just make the element a little bigger than the viewport, + this is no longer visible. */ + inset: -20px; + background-image: url("graphics/mobile-gradient.png"); + background-size: 1400px 305px; + background-position: bottom; + background-repeat: no-repeat; + } + + [data-element-call-root][data-background="gradient"][data-platform="desktop"]::before { + background-image: url("graphics/desktop-gradient.png"); + background-size: max(1440px, 100vw) max(1440px, 100vh); + background-position: center; + } +} + +/* We use this to not render the page at all until we know the theme.*/ +.no-theme { + opacity: 0; +} + +/* On Android and iOS, prefer native system fonts. The global.css file of +Compound Web is where these variables ultimately get consumed to set the page's +font-family. */ +[data-element-call-root][data-platform="android"] { + --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; +} + +[data-element-call-root][data-platform="ios"] { + --cpd-font-family-sans: + -apple-system, BlinkMacSystemFont, "Inter", sans-serif; +} + +@layer compound-legacy { + h1, + h2, + h3, + h4, + h5, + h6, + p, + a { + margin-top: 0; + } + + /* Headline Semi Bold */ + h1 { + font-weight: 600; + font-size: var(--font-size-headline); + } + + /* Title */ + h2 { + font-weight: 600; + font-size: var(--font-size-title); + } + + /* Subtitle */ + h3 { + font-weight: 600; + font-size: var(--font-size-subtitle); + } + + /* Body Semi Bold */ + h4 { + font-weight: 600; + font-size: var(--font-size-body); + } + + h1, + h2, + h3 { + line-height: 1.2; + } + + /* Body */ + p { + font-size: var(--font-size-body); + line-height: var(--font-size-title); + } + + hr { + width: calc(100% - 24px); + border: none; + border-top: 1px solid var(--cpd-color-border-interactive-secondary); + color: var(--cpd-color-border-interactive-secondary); + overflow: visible; + text-align: center; + height: 5px; + font-weight: 600; + font-size: var(--font-size-body); + line-height: 24px; + margin: 0 12px; + } + + summary { + font-size: var(--font-size-body); + } + + details > :not(summary) { + margin-left: var(--font-size-body); + } + + details[open] > summary { + margin-bottom: var(--font-size-body); + } +} + +/* normalize.css sets the focus rings on buttons in Firefox to an unusual custom +outline, which is inconsistent with our other components and is not sufficiently +visible to be accessible. This resets it back to 'auto'. */ +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: auto; +} diff --git a/src/index.css b/src/index.css index 12f3951da..7188f5e79 100644 --- a/src/index.css +++ b/src/index.css @@ -5,64 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -@layer normalize, compound-legacy, compound; +/* Styles for Element Call as a page of its own. The parts that apply wherever +Element Call is shown live in base.css; these are about owning the document, +and are not loaded when a host embeds Element Call as a component. */ -@import url("@fontsource/inter/400.css"); -@import url("@fontsource/inter/500.css"); -@import url("@fontsource/inter/600.css"); -@import url("@fontsource/inter/700.css"); -@import url("@fontsource/inconsolata/400.css"); -@import url("@fontsource/inconsolata/700.css"); - -@import url("normalize.css/normalize.css") layer(normalize); -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); -@import url("@vector-im/compound-web/dist/style.css") layer(compound.components); - -:root { - --font-scale: 1; - --font-size-micro: calc(10px * var(--font-scale)); - --font-size-caption: calc(12px * var(--font-scale)); - --font-size-body: calc(15px * var(--font-scale)); - --font-size-subtitle: calc(18px * var(--font-scale)); - --font-size-title: calc(24px * var(--font-scale)); - --font-size-headline: calc(32px * var(--font-scale)); - - --cpd-color-border-accent: var(--cpd-color-green-800); - /* The distance to inset non-full-width content from the edge of the window - along the inline axis. This ramps up from 16px for typical mobile windows, to - 96px for typical desktop windows, and accounts for the safe area. */ - --content-inset-left: calc( - env(safe-area-inset-left) + - min( - var(--cpd-space-24x), - max(var(--cpd-space-4x), calc((100vw - 900px) / 3)) - ) - ); - --content-inset-right: calc( - env(safe-area-inset-right) + - min( - var(--cpd-space-24x), - max(var(--cpd-space-4x), calc((100vw - 900px) / 3)) - ) - ); - --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); - --big-drop-shadow: 0px 0px 24px 0px #1b1d221a; - --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); - - --call-view-overlay-layer: 1; - --call-view-header-footer-layer: 2; -} - -:root, -[class*="cpd-theme-"] { - --video-tile-background: var(--cpd-color-bg-subtle-secondary); -} - -.cpd-theme-dark { - --cpd-color-border-accent: var(--cpd-color-green-1100); - --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); - --stopgap-background-85: rgba(16, 19, 23, 0.85); -} +@import url("./base.css"); body { background-color: var(--cpd-color-bg-canvas-default); @@ -74,39 +21,16 @@ body { -webkit-tap-highlight-color: transparent; } -@media (min-height: 330px) { - [data-element-call-root][data-background="gradient"]::before { - content: ""; - position: fixed; - /* Chromium abruptly fades our images to fully transparent at the edge of - the element. If we just make the element a little bigger than the viewport, - this is no longer visible. */ - inset: -20px; - background-image: url("graphics/mobile-gradient.png"); - background-size: 1400px 305px; - background-position: bottom; - background-repeat: no-repeat; - } - - [data-element-call-root][data-background="gradient"][data-platform="desktop"]::before { - background-image: url("graphics/desktop-gradient.png"); - background-size: max(1440px, 100vw) max(1440px, 100vh); - background-position: center; - } -} - /* This prohibits the view to scroll for pages smaller than 122px in width -we use this for mobile pip webviews */ -.no-scroll-body { +we use this for mobile pip webviews. Element Call adds this class to whatever +it treats as its root, but it is only ever the page that should be pinned like +this — done to a container inside a host application it would take that +container out of the host's layout — so the selector says so. */ +body.no-scroll-body { position: fixed; width: 100%; } -/* We use this to not render the page at all until we know the theme.*/ -.no-theme { - opacity: 0; -} - html, body, #root { @@ -123,104 +47,7 @@ body, isolation: isolate; } -/* On Android and iOS, prefer native system fonts. The global.css file of -Compound Web is where these variables ultimately get consumed to set the page's -font-family. */ -[data-element-call-root][data-platform="android"] { - --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; -} - -[data-element-call-root][data-platform="ios"] { - --cpd-font-family-sans: - -apple-system, BlinkMacSystemFont, "Inter", sans-serif; -} - -@layer compound-legacy { - h1, - h2, - h3, - h4, - h5, - h6, - p, - a { - margin-top: 0; - } - - /* Headline Semi Bold */ - h1 { - font-weight: 600; - font-size: var(--font-size-headline); - } - - /* Title */ - h2 { - font-weight: 600; - font-size: var(--font-size-title); - } - - /* Subtitle */ - h3 { - font-weight: 600; - font-size: var(--font-size-subtitle); - } - - /* Body Semi Bold */ - h4 { - font-weight: 600; - font-size: var(--font-size-body); - } - - h1, - h2, - h3 { - line-height: 1.2; - } - - /* Body */ - p { - font-size: var(--font-size-body); - line-height: var(--font-size-title); - } - - hr { - width: calc(100% - 24px); - border: none; - border-top: 1px solid var(--cpd-color-border-interactive-secondary); - color: var(--cpd-color-border-interactive-secondary); - overflow: visible; - text-align: center; - height: 5px; - font-weight: 600; - font-size: var(--font-size-body); - line-height: 24px; - margin: 0 12px; - } - - summary { - font-size: var(--font-size-body); - } - - details > :not(summary) { - margin-left: var(--font-size-body); - } - - details[open] > summary { - margin-bottom: var(--font-size-body); - } -} - #root > [data-overlay-container] { position: relative; height: 100%; } - -/* normalize.css sets the focus rings on buttons in Firefox to an unusual custom -outline, which is inconsistent with our other components and is not sufficiently -visible to be accessible. This resets it back to 'auto'. */ -button:-moz-focusring, -[type="button"]:-moz-focusring, -[type="reset"]:-moz-focusring, -[type="submit"]:-moz-focusring { - outline: auto; -} From 9f5fa6049fbce218bcd65df9fdf77970b6e3f212 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 16:11:39 +0200 Subject: [PATCH 24/54] Add a harness for Element Call embedded as a component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm dev:component` serves a page that stands in for a host application: it signs in twice against the development backend and shows two calls side by side, in resizable boxes, with furniture of its own around them. Two devices of one account, so a real call happens between the two components and anything Element Call keeps once per process rather than once per call shows itself. The host bridge is driven by hand and reports both directions in a log along the bottom, which is the first exercise the theme, hang-up and device-mute requests have had outside widget mode. Each pane can be unmounted and remounted to see what Element Call leaves behind, and there is a `position: fixed` dialog belonging to the host to see whether it covers the calls. The page uses none of Element Call's design tokens, so anything that looks styled outside a pane came from Element Call reaching out of its container. It reaches Element Call only through the component's public interface, which is how the exports missing from that interface came to light. Three things about the component build the harness turned up on the way, all too small to be worth their own commits: - It copied `public/` into `dist/`, including the developer's own gitignored config.json, into output we would publish. `publicDir: false`, as the embedded build already does. The sdk build has the same leak; untouched. - `pnpm lint:externals` now exists, which the build config already claimed it did. It reads the external list out of that config and fails if the source imports React, the Matrix SDK or LiveKit by a path the list does not name. Since the bundler silently ignores the pattern form of that option, an unnamed subpath is bundled with no warning at all — which is how a host would end up with a second React. - `lint:oxlint` ran over `src playwright`, so nothing in `component/` had ever been linted. Serving a page also meant the shared plugin list could no longer inject the app's HTML entry point unconditionally, so that is now optional — and off for the library build too, which never had an HTML page to inject it into. --- README.md | 17 ++ component/dev/DevHostBridge.ts | 103 +++++++++ component/dev/Harness.module.css | 140 ++++++++++++ component/dev/Harness.tsx | 298 ++++++++++++++++++++++++++ component/dev/host.css | 23 ++ component/dev/index.html | 21 ++ component/dev/main.tsx | 42 ++++ component/dev/session.ts | 79 +++++++ knip.ts | 1 + package.json | 8 +- scripts/check-component-externals.mjs | 140 ++++++++++++ vite-component-dev.config.ts | 77 +++++++ vite-component.config.ts | 13 +- vite.config.ts | 12 +- 14 files changed, 967 insertions(+), 7 deletions(-) create mode 100644 component/dev/DevHostBridge.ts create mode 100644 component/dev/Harness.module.css create mode 100644 component/dev/Harness.tsx create mode 100644 component/dev/host.css create mode 100644 component/dev/index.html create mode 100644 component/dev/main.tsx create mode 100644 component/dev/session.ts create mode 100644 scripts/check-component-externals.mjs create mode 100644 vite-component-dev.config.ts diff --git a/README.md b/README.md index ecbabcf2c..792665f9f 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,23 @@ See also: - [Developing with linked packages](./docs/linking.md) +#### Element Call as a component (experimental) + +Element Call can also be embedded directly into another React application +rather than being loaded in an iframe as a widget. `pnpm build:component` +builds it as a library, and + +```sh +pnpm dev:component +``` + +serves a harness on port 3001 that stands in for such an application: it signs +in twice against the development backend and shows two calls side by side, in +resizable boxes, with page furniture of its own around them. Use it to see how +Element Call behaves when it does not own the page — the size it is given, +whether it stays inside its container, and what it says to its host, which is +logged along the bottom. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/dev/DevHostBridge.ts b/component/dev/DevHostBridge.ts new file mode 100644 index 000000000..417e4833a --- /dev/null +++ b/component/dev/DevHostBridge.ts @@ -0,0 +1,103 @@ +/* +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 { NEVER, Subject } from "rxjs"; + +import { + type DeviceMuteRequest, + type DeviceMuteState, + type HostBridge, + type HostRequest, +} from "../index"; + +/** + * A host bridge that reports everything it is told and can be driven by hand, + * so that the harness can watch both directions of the conversation between + * Element Call and its host. + */ +export interface DevHostBridge extends HostBridge { + /** Tells Element Call the host has changed theme. */ + requestTheme(name: string): void; + /** Tells Element Call to leave the call. */ + requestHangUp(): void; + /** Asks Element Call to change, and report back, its mute state. */ + requestDeviceMute(request: DeviceMuteRequest): void; +} + +export function createDevHostBridge( + log: (message: string) => void, + /** What the host does when Element Call asks to be closed. */ + onClose: () => void, +): DevHostBridge { + const themeChange$ = new Subject>(); + const hangUp$ = new Subject>>(); + const deviceMute$ = new Subject< + HostRequest + >(); + + const ask = ( + subject: Subject>, + name: string, + data: Data, + ): void => { + // Worth saying out loud: a request nobody is subscribed to is silently + // dropped, and that is exactly the sort of thing the harness is for. + if (!subject.observed) { + log(`← ${name}: nothing is listening`); + return; + } + log(`← ${name}`); + subject.next({ + data, + reply: (reply): void => + log( + `→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`, + ), + }); + }; + + /** + * Records something Element Call told the host. Nothing is sent anywhere, so + * this is only asynchronous because a real host's answer would have to be. + */ + const told = async (message: string): Promise => { + log(`→ ${message}`); + await Promise.resolve(); + }; + + return { + setAlwaysOnScreen: async (alwaysOnScreen): Promise => + await told(`setAlwaysOnScreen(${alwaysOnScreen})`), + contentLoaded: async (): Promise => await told("contentLoaded"), + notifyJoined: async (): Promise => await told("notifyJoined"), + notifyHungUp: async (): Promise => await told("notifyHungUp"), + notifyDeviceMute: async (state): Promise => + await told( + `notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`, + ), + // Present because this host really can dismiss Element Call, which is what + // makes it offer a close affordance at all + close: async (): Promise => { + await told("close"); + onClose(); + }, + + themeChange$, + // The harness does not preload a call, so this is never asked for + join$: NEVER, + hangUp$, + deviceMute$, + + supportsReactions: true, + + requestTheme: (name): void => + ask(themeChange$, `themeChange(${name})`, { name }), + requestHangUp: (): void => ask(hangUp$, "hangUp", {}), + requestDeviceMute: (request): void => + ask(deviceMute$, `deviceMute(${JSON.stringify(request)})`, request), + }; +} diff --git a/component/dev/Harness.module.css b/component/dev/Harness.module.css new file mode 100644 index 000000000..0b116a242 --- /dev/null +++ b/component/dev/Harness.module.css @@ -0,0 +1,140 @@ +/* +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. +*/ + +.credentials { + display: flex; + flex-direction: column; + gap: 8px; + max-inline-size: 420px; + margin: 48px auto; + padding: 24px; + background-color: #ffffff; + border: 1px solid #d4d4d8; + border-radius: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.error { + color: #b91c1c; +} + +.harness { + display: grid; + grid-template-rows: auto 1fr auto; + block-size: 100%; +} + +.header, +.paneBar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-block-end: 1px solid #d4d4d8; + background-color: #ffffff; +} + +.header h1 { + font-size: 16px; + margin: 0; +} + +.middle { + display: flex; + min-block-size: 0; +} + +.sidebar { + flex: 0 0 220px; + padding: 12px; + border-inline-end: 1px solid #d4d4d8; + background-color: #ffffff; + overflow-y: auto; +} + +.panes { + display: flex; + flex-wrap: wrap; + /* So that a pane is the size it was dragged to, rather than being stretched + to fill the row */ + align-items: flex-start; + align-content: flex-start; + gap: 16px; + padding: 16px; + flex: 1; + min-inline-size: 0; + overflow: auto; +} + +.pane { + display: flex; + flex-direction: column; + border: 1px solid #d4d4d8; + border-radius: 8px; + overflow: hidden; + background-color: #ffffff; +} + +.paneBar { + flex-wrap: wrap; + gap: 6px; + border-block-end: none; +} + +/* The space the host gives Element Call. Resizable so that the sizes it has to +cope with can be found by dragging rather than by rebuilding, and `overflow: +hidden` both to enable the resize handle and to show up anything inside Element +Call that does not fit the box it was given. */ +.paneCall { + inline-size: 560px; + block-size: 420px; + min-inline-size: 180px; + min-block-size: 180px; + resize: both; + overflow: hidden; +} + +.log { + max-block-size: 180px; + overflow-y: auto; + padding: 8px 12px; + border-block-start: 1px solid #d4d4d8; + background-color: #ffffff; + font-size: 12px; +} + +.log h2 { + font-size: 13px; + margin: 0 0 4px; +} + +.log ol { + margin: 0; + padding: 0; + list-style: none; +} + +/* A host overlay, which Element Call must not be able to draw over */ +.dialogScrim { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background-color: rgb(0 0 0 / 50%); + z-index: 10; +} + +.dialog { + padding: 24px; + border-radius: 8px; + background-color: #ffffff; +} diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx new file mode 100644 index 000000000..ddb69d327 --- /dev/null +++ b/component/dev/Harness.tsx @@ -0,0 +1,298 @@ +/* +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 { + type FC, + type FormEvent, + type ReactNode, + useCallback, + useMemo, + useState, +} from "react"; +import { type MatrixClient } from "matrix-js-sdk"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { ElementCall } from "../index"; +import { createDevHostBridge } from "./DevHostBridge"; +import { createSession, joinRoom } from "./session"; +import styles from "./Harness.module.css"; + +interface Credentials { + homeserver: string; + username: string; + password: string; + room: string; +} + +const CREDENTIALS_KEY = "element-call-component-harness"; + +const DEFAULT_CREDENTIALS: Credentials = { + homeserver: "https://synapse.m.localhost", + username: "", + password: "", + room: "", +}; + +/** The last credentials used, so that a reload does not mean typing them again. */ +function loadCredentials(): Credentials { + try { + const stored = localStorage.getItem(CREDENTIALS_KEY); + if (stored !== null) + return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) }; + } catch (e) { + logger.warn("Could not read the stored harness credentials", e); + } + return DEFAULT_CREDENTIALS; +} + +interface Session { + label: string; + client: MatrixClient; +} + +type State = + | { phase: "credentials" } + | { phase: "starting"; progress: string } + | { phase: "started"; roomId: string; sessions: Session[] } + | { phase: "failed"; error: string }; + +interface LogEntry { + pane: string; + message: string; + at: string; +} + +/** + * One embedded Element Call, with the controls a host would have over it: the + * requests it can make of Element Call, and the ability to take it off screen + * altogether. + */ +const Pane: FC<{ + session: Session; + roomId: string; + log: (pane: string, message: string) => void; +}> = ({ session, roomId, log }): ReactNode => { + const [mounted, setMounted] = useState(true); + + const bridge = useMemo( + () => + createDevHostBridge( + (message) => log(session.label, message), + () => setMounted(false), + ), + [log, session.label], + ); + + return ( +
+
+ {session.label} + {session.client.getDeviceId()} + + + + + +
+ {/* Resizable, because how Element Call copes with the size it is given is + one of the things we cannot find out from the standalone app */} +
+ {mounted && ( + + )} +
+
+ ); +}; + +/** Host furniture, to make it visible if Element Call styles anything but itself. */ +const HostChrome: FC = (): ReactNode => ( + +); + +/** + * A dialog of the host's own, over the top of the calls. Element Call embedded + * in a host has to sit underneath this — being unable to is one of the reasons + * for embedding it rather than putting it in an iframe. + */ +const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => ( +
+
+

A dialog belonging to the host

+

This should cover the calls completely.

+ +
+
+); + +/** + * Stands in for a host application embedding Element Call: it owns the Matrix + * clients, the page and the space each call is given, and reaches Element Call + * only through the component's public interface. + * + * Two calls at once, from two devices of the same account, so that a real call + * happens between them and anything Element Call keeps once per process rather + * than once per call shows itself. + */ +export const Harness: FC = (): ReactNode => { + const [credentials, setCredentials] = useState(loadCredentials); + const [state, setState] = useState({ phase: "credentials" }); + const [entries, setEntries] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + + const log = useCallback((pane: string, message: string): void => { + setEntries((entries) => + [ + ...entries, + { pane, message, at: new Date().toLocaleTimeString() }, + ].slice(-100), + ); + }, []); + + const start = useCallback( + (event: FormEvent): void => { + event.preventDefault(); + localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials)); + const { homeserver, username, password, room } = credentials; + + const progress = (message: string): void => + setState({ phase: "starting", progress: message }); + progress("Starting"); + + void (async (): Promise => { + try { + // One at a time: two logins at once from the same account is the + // shape of request homeservers rate limit + const sessions: Session[] = []; + for (const label of ["Call A", "Call B"]) + sessions.push({ + label, + client: await createSession( + homeserver, + username, + password, + (message) => progress(`${label}: ${message}`), + ), + }); + + progress("Joining the room"); + let roomId = room; + for (const { client } of sessions) + roomId = await joinRoom(client, roomId); + + setState({ phase: "started", roomId, sessions }); + } catch (e) { + logger.error("The harness could not start", e); + setState({ phase: "failed", error: `${e}` }); + } + })(); + }, + [credentials], + ); + + const field = ( + name: keyof Credentials, + label: string, + type = "text", + ): ReactNode => ( + + ); + + if (state.phase !== "started") + return ( +
+

Element Call component harness

+

+ Signs in twice and shows Element Call embedded twice, in a page that + is not Element Call's own. +

+ {field("homeserver", "Homeserver")} + {field("username", "Username")} + {field("password", "Password", "password")} + {field("room", "Room ID or alias")} + + {state.phase === "starting" &&

{state.progress}

} + {state.phase === "failed" && ( +

{state.error}

+ )} +
+ ); + + return ( +
+
+

Element Call component harness

+ {state.roomId} + +
+
+ +
+ {state.sessions.map((session) => ( + + ))} +
+
+
+

Host bridge

+
    + {entries.map((entry, i) => ( +
  1. + {entry.at} {entry.pane}{" "} + {entry.message} +
  2. + ))} +
+
+ {dialogOpen && setDialogOpen(false)} />} +
+ ); +}; diff --git a/component/dev/host.css b/component/dev/host.css new file mode 100644 index 000000000..76e70f609 --- /dev/null +++ b/component/dev/host.css @@ -0,0 +1,23 @@ +/* +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 host page's own styles. Deliberately plain, and deliberately not using +Element Call's design tokens: the harness should look like it does because of +this file, not because Element Call styled it. */ + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background-color: #f4f4f5; + color: #18181b; +} diff --git a/component/dev/index.html b/component/dev/index.html new file mode 100644 index 000000000..7174e935a --- /dev/null +++ b/component/dev/index.html @@ -0,0 +1,21 @@ + + + + + + + Element Call component harness + + + +
+ + + diff --git a/component/dev/main.tsx b/component/dev/main.tsx new file mode 100644 index 000000000..9150678d5 --- /dev/null +++ b/component/dev/main.tsx @@ -0,0 +1,42 @@ +/* +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 { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type ConfigOptions, initializeElementCall } from "../index"; +import { Harness } from "./Harness"; +// After Element Call's, so that the host has the last word on its own page +import "./host.css"; + +/** + * The development app's own `config.json`, so that the harness runs Element + * Call the way `pnpm dev` does. It is not in the repository — developers copy + * it from `config/config.devenv.json` — so its absence is expected rather than + * an error. + */ +async function loadConfig(): Promise { + try { + const response = await fetch("/config.json"); + if (response.ok) return (await response.json()) as ConfigOptions; + logger.warn( + `No config.json (${response.status}); running with Element Call's defaults`, + ); + } catch (e) { + logger.warn("Could not read config.json", e); + } + return {}; +} + +await initializeElementCall(await loadConfig()); + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/component/dev/session.ts b/component/dev/session.ts new file mode 100644 index 000000000..5a424a409 --- /dev/null +++ b/component/dev/session.ts @@ -0,0 +1,79 @@ +/* +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 { + ClientEvent, + createClient, + type MatrixClient, + MemoryStore, + SyncState, +} from "matrix-js-sdk"; + +/** + * Logs in and brings up a client the way a host application would, so that the + * component is handed a real one rather than something Element Call built for + * itself. + * + * Everything is kept in memory and a fresh login happens on every reload. That + * costs a device on the development homeserver each time, which is harmless, + * and buys the harness two clients that cannot tread on each other's storage. + * Persisting the login to make reloads quicker would mean persisting the + * crypto store too: reusing a device ID with a fresh crypto store generates new + * device keys, and uploading them conflicts with the ones the server already + * holds. + */ +export async function createSession( + homeserver: string, + username: string, + password: string, + onProgress: (message: string) => void, +): Promise { + onProgress("Logging in"); + const login = await createClient({ baseUrl: homeserver }).login( + "m.login.password", + { identifier: { type: "m.id.user", user: username }, password }, + ); + + const client = createClient({ + baseUrl: homeserver, + accessToken: login.access_token, + userId: login.user_id, + deviceId: login.device_id, + store: new MemoryStore(), + useAuthorizationHeader: true, + fallbackICEServerAllowed: true, + }); + + onProgress(`Setting up crypto for ${login.device_id}`); + await client.initRustCrypto({ useIndexedDB: false }); + + onProgress(`Syncing ${login.device_id}`); + await client.startClient(); + await new Promise((resolve) => { + const onSync = (state: SyncState): void => { + if (state !== SyncState.Prepared && state !== SyncState.Syncing) return; + client.off(ClientEvent.Sync, onSync); + resolve(); + }; + client.on(ClientEvent.Sync, onSync); + }); + + return client; +} + +/** + * The room to call in, joining it if this session is not in it yet — a host + * hands Element Call a room it already knows about, so the harness has to get + * itself into that position first. + */ +export async function joinRoom( + client: MatrixClient, + roomIdOrAlias: string, +): Promise { + const room = await client.joinRoom(roomIdOrAlias); + return room.roomId; +} diff --git a/knip.ts b/knip.ts index 97ecc0903..d9de80c8c 100644 --- a/knip.ts +++ b/knip.ts @@ -14,6 +14,7 @@ export default { "vite-embedded.config.ts", "vite-sdk.config.ts", "vite-component.config.ts", + "vite-component-dev.config.ts", ], }, entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], diff --git a/package.json b/package.json index 91ba49dea..cf1e1f717 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "pnpm dev:full", "dev:full": "vite", "dev:embedded": "vite --config vite-embedded.config.js", + "dev:component": "vite --config vite-component-dev.config.ts", "build": "pnpm build:full", "build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build", "build:full:production": "pnpm build:full", @@ -22,10 +23,11 @@ "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", - "lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip", - "lint:oxlint": "oxlint src playwright", - "lint:oxlint-fix": "oxlint --fix src playwright", + "lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals", + "lint:oxlint": "oxlint src component playwright", + "lint:oxlint-fix": "oxlint --fix src component playwright", "lint:knip": "knip", + "lint:externals": "node scripts/check-component-externals.mjs", "lint:types": "tsc", "i18n": "npx i18next-cli extract", "i18n:check": "npx i18next-cli extract --ci", diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs new file mode 100644 index 000000000..ee7d57da0 --- /dev/null +++ b/scripts/check-component-externals.mjs @@ -0,0 +1,140 @@ +/* +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. +*/ + +/** + * Checks that the component build leaves the packages a host must supply to + * the host. + * + * A host application already has React, the Matrix SDK and LiveKit, and a + * second copy of any of them is worse than dead weight: React would hold two + * sets of hooks, and the Matrix client would run two sync loops. So the + * component build lists them as external — but that list has to name every + * subpath, since the bundler silently ignores the pattern and callback forms + * of the option, and an import it does not cover is bundled with no warning at + * all. That is the failure this guards against. + * + * It reads the list from the build config itself, so there is one copy of it, + * and compares it against every import of those packages in the source. + * + * The comparison is deliberately over-approximate: it looks at all of `src` + * rather than only the modules the component actually pulls in, so it will + * sometimes ask for a subpath that only the standalone app imports. Listing + * one the component never imports costs nothing — the bundler ignores it — + * whereas missing one costs a duplicate package. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { loadConfigFromFile } from "vite"; + +const CONFIG = "vite-component.config.ts"; +const SOURCES = ["src", "component"]; + +/** The packages whose duplication would break a host, rather than merely enlarge it. */ +const MUST_BE_EXTERNAL = [ + "react", + "react-dom", + "matrix-js-sdk", + "livekit-client", +]; + +const isTestFile = (name) => + name.includes(".test.") || name.includes(".stories."); + +/** Every source file under the given directories, recursively. */ +async function* sourceFiles(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* sourceFiles(path); + else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name)) + yield path; + } +} + +/** + * The module specifiers a source file imports. Covers `from "…"` (which is + * both static imports and re-exports), bare `import "…"` for side effects, and + * dynamic `import("…")`. + */ +function imports(source) { + const specifiers = []; + for (const pattern of [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /^\s*import\s+["']([^"']+)["']/gm, + ]) + for (const [, specifier] of source.matchAll(pattern)) + specifiers.push(specifier); + return specifiers; +} + +/** + * Whether a specifier is an import of one of the packages we care about. + * + * Imports carrying a resource query — `?worker`, `?inline` and friends — are + * not, whatever package they name. Those ask the bundler for a script to run + * in a context of its own, which has to be self-contained and shares no state + * with the host's copy of anything. Worker sub-builds do not inherit this + * option anyway. + */ +const mustBeExternal = (specifier) => + !specifier.includes("?") && + MUST_BE_EXTERNAL.some( + (pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`), + ); + +const loaded = await loadConfigFromFile( + { command: "build", mode: "production" }, + CONFIG, +); +if (loaded === null) { + console.error(`Could not load ${CONFIG}`); + process.exit(1); +} +const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []); +if (declared.size === 0) { + console.error( + `${CONFIG} declares nothing external. Either the option moved, or the ` + + `list is empty; either way this check is not looking at what it thinks.`, + ); + process.exit(1); +} + +// Where each missing specifier is imported, so the message can point at it +const missing = new Map(); +const seen = new Set(); +for (const dir of SOURCES) + for await (const file of sourceFiles(dir)) { + const source = await readFile(file, "utf8"); + for (const specifier of imports(source)) { + if (!mustBeExternal(specifier)) continue; + seen.add(specifier); + if (declared.has(specifier)) continue; + const files = missing.get(specifier) ?? []; + files.push(file); + missing.set(specifier, files); + } + } + +if (missing.size > 0) { + console.error( + `${CONFIG} does not declare these imports external, so the component ` + + `build would bundle its own copy of them:\n`, + ); + for (const [specifier, files] of [...missing].sort()) + console.error(` ${specifier}\n imported by ${files.join(", ")}`); + console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`); + process.exit(1); +} + +// Deliberately no complaint about declarations nothing imports. Some of them +// cannot be seen from the source at all — `react/jsx-runtime` is injected by +// the JSX transform — and an extra declaration is inert, so there is nothing +// to warn about. +console.log( + `${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`, +); diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts new file mode 100644 index 000000000..61cb5e512 --- /dev/null +++ b/vite-component-dev.config.ts @@ -0,0 +1,77 @@ +/* +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, searchForWorkspaceRoot } from "vite"; +import { realpathSync } from "node:fs"; +import * as fs from "node:fs"; + +import { vitePluginsConfig } from "./vite.config"; + +// Serves the harness under `component/dev`, which embeds Element Call as a +// component the way a host application would. Development only: this is not +// something we build or ship. +// +// It shares the plugins with the standalone app but not its HTML entry point, +// since the harness has a page of its own — and it deliberately does not build +// on the app's config, which would also bring the app's build output along. +export default defineConfig(({ mode }) => { + // The crypto WASM module is imported dynamically, so Vite has to be told + // that reading it is legitimate — including from a linked copy, which is why + // the paths are resolved rather than assumed. Same as the standalone app. + const allow = [searchForWorkspaceRoot(process.cwd())]; + for (const path of [ + "node_modules/matrix-js-sdk/node_modules/@matrix-org/matrix-sdk-crypto-wasm", + "node_modules/@matrix-org/matrix-sdk-crypto-wasm", + ]) { + try { + allow.push(realpathSync(path)); + } catch {} + } + + return { + ...vitePluginsConfig({ mode, html: false }), + root: "component/dev", + // So that the harness can read the same config.json the standalone app + // does, if the developer has written one + publicDir: "../../public", + server: { + host: true, + // One up from the standalone app's, so both can run at once — the point + // of the harness is to compare them + port: 3001, + fs: { allow }, + // The same certificate the app uses, so that the harness is served from + // a `m.localhost` name the development homeserver's certificate covers + https: { + key: fs.readFileSync("./backend/dev_tls_m.localhost.key"), + cert: fs.readFileSync("./backend/dev_tls_m.localhost.crt"), + }, + }, + worker: { + format: "es", + }, + resolve: { + alias: { + // matrix-widget-api has its transpiled lib/index.js as its entry point, + // which Vite for some reason refuses to work with, so we point it to + // src/index.ts instead + "matrix-widget-api": "matrix-widget-api/src/index.ts", + }, + dedupe: [ + "react", + "react-dom", + "matrix-js-sdk", + "react-use-measure", + // These packages modify the document based on some module-level global + // state, and don't play nicely with duplicate copies of themselves + // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1847837850 + "@radix-ui/react-focus-guards", + "@radix-ui/react-dismissable-layer", + ], + }, + }; +}); diff --git a/vite-component.config.ts b/vite-component.config.ts index 1a57abe61..17f625628 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -15,7 +15,11 @@ import { vitePluginsConfig } from "./vite.config"; // 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 }), + ...vitePluginsConfig({ mode, html: false }), + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, build: { minify: mode === "production", sourcemap: true, @@ -36,7 +40,10 @@ export default defineConfig(({ mode }) => ({ // 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. + // `pnpm lint:externals` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. external: [ "react", "react/jsx-runtime", @@ -44,8 +51,10 @@ export default defineConfig(({ mode }) => ({ "react-dom/client", "livekit-client", "matrix-js-sdk", + "matrix-js-sdk/lib/browser-index", "matrix-js-sdk/lib/client", "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/indexeddb-worker", "matrix-js-sdk/lib/logger", "matrix-js-sdk/lib/matrix", "matrix-js-sdk/lib/matrixrtc", diff --git a/vite.config.ts b/vite.config.ts index 61936cfdf..678a86fab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,15 @@ import * as fs from "node:fs"; export const vitePluginsConfig = ({ mode, -}: Pick): UserConfig => { + html = true, +}: Pick & { + /** + * Whether to inject Element Call's entry point into the HTML page. Builds + * that produce a library, or serve a page of their own, must not have this: + * it would pull the standalone app in alongside whatever they are building. + */ + html?: boolean; +}): UserConfig => { const env = loadEnv(mode, process.cwd()); const plugins: PluginOption[] = [ babel({ @@ -67,7 +75,7 @@ export const vitePluginsConfig = ({ ); } - if (!process.env.STORYBOOK && !process.env.VITEST) { + if (html && !process.env.STORYBOOK && !process.env.VITEST) { plugins.push( createHtmlPlugin({ entry: "src/main.tsx", From c0a54dcaf938c4e28dbe231ebc8e743d0caed32a Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 16:39:14 +0200 Subject: [PATCH 25/54] Derive the component's defaults from an intent, not the host's URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component seeded its parameters with `computeUrlParams()`, which reads `window.location`. Element Call is not the page any more, so what it found there was the host's URL: no `widgetId`, therefore not a widget, therefore no intent, therefore the standalone app's preset. The visible symptom was the Element Call logo in the footer of a call embedded in someone else's application, since `showLogo` follows `header === HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so unencrypted, and willing to take the user out of the room a host had put them in — and a host could not correct either without naming every parameter itself, nor even name `header`, since the enums were not exported. So the intent presets move out of `computeUrlParams` into `configurationForIntent`, and the component builds its parameters from an intent plus the properties a host has no URL to supply. `intent` becomes a prop, defaulting to joining an existing group call: the lobby first, confined to the room, encrypted per participant, and no Element Call branding in someone else's interface. A host that knows which button the user pressed should say which. One deliberate difference from the widget presets: the background defaults to solid rather than the gradient, which is drawn by a `position: fixed` pseudo-element and would escape the container to cover the host. `UserIntent.Unknown` still means the standalone preset, so the app and widget are unchanged — including a widget that names no intent, which has always been given those defaults. --- component/index.tsx | 43 +++++++-- src/UrlParams.test.ts | 48 ++++++++++ src/UrlParams.ts | 202 +++++++++++++++++++++++++++--------------- 3 files changed, 215 insertions(+), 78 deletions(-) diff --git a/component/index.tsx b/component/index.tsx index 1d3155d30..55e2f996f 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -48,9 +48,11 @@ import { } from "../src/HostBridge"; import { RootElementProvider } from "../src/RootElementContext"; import { - computeUrlParams, + configurationForIntent, + hostedProperties, type UrlParams, UrlParamsProvider, + UserIntent, } from "../src/UrlParams"; import { MediaDevicesContext } from "../src/MediaDevicesContext"; import { MediaDevices } from "../src/state/MediaDevices"; @@ -74,10 +76,17 @@ export { type JoinCallData } from "../src/widget"; // The deployment-wide configuration, as distinct from ElementCallConfiguration // above, which is per call export { type ConfigOptions } from "../src/config/ConfigOptions"; +// The values that appear in ElementCallConfiguration and in the intent +export { + BackgroundStyle, + HeaderStyle, + UserIntent, + type UrlConfiguration, +} from "../src/UrlParams"; /** * How Element Call should behave. Everything is optional; anything left out - * takes the same default it would in the standalone app. + * takes the default that {@link ElementCallProps.intent} implies. */ export type ElementCallConfiguration = Partial; @@ -89,7 +98,21 @@ export interface ElementCallProps { client: MatrixClient; /** The room to call in. The host's client must already know about it. */ roomId: string; - /** How Element Call should behave. */ + /** + * What the user asked for — whether they started the call or joined one that + * was already running, and whether it is a call in a group or a DM. Element + * Call decides what each of those means: whether to show the lobby first, + * whether to ring, and so on. + * + * Defaults to joining an existing group call, which is the most conservative + * reading, but a host that knows which button the user pressed should say so. + */ + intent?: UserIntent; + /** + * How Element Call should behave, overriding whatever {@link intent} implies. + * A host that finds itself setting a lot of these probably wants a different + * intent instead. + */ config?: ElementCallConfiguration; /** * How to reach the host while the call is running — to be told the user has @@ -143,6 +166,7 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => { export const ElementCall: FC = ({ client, roomId, + intent = UserIntent.JoinExistingCall, config, hostBridge = nullHostBridge, }): ReactNode => { @@ -150,10 +174,17 @@ export const ElementCall: FC = ({ // 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 + // 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. const params = useMemo( - (): UrlParams => ({ ...computeUrlParams(), ...config }), - [config], + (): UrlParams => ({ + ...hostedProperties, + roomId, + ...configurationForIntent(intent), + ...config, + }), + [roomId, intent, config], ); const mediaDevices = useInitial( diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 3a61a76b9..63c4e2c86 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -11,10 +11,14 @@ import { logger } from "matrix-js-sdk/lib/logger"; import * as PlatformMod from "../src/Platform"; import { + BackgroundStyle, + configurationForIntent, getRoomIdentifierFromUrl, computeUrlParams, HeaderStyle, getUrlParams, + hostedProperties, + UserIntent, } from "../src/UrlParams"; import { mockConfig } from "./utils/test"; @@ -424,4 +428,48 @@ describe("UrlParams", () => { ); }); }); + + // What Element Call runs with when a host embeds it as a component, which + // has no URL of its own for any of this to come from + describe("hosted defaults", () => { + it("assume nothing about a session or a page", () => { + expect(hostedProperties).toMatchObject({ + // The host is not a widget host, and supplies the client itself, so + // none of the widget or session plumbing applies + isWidget: false, + widgetId: null, + parentUrl: null, + userId: null, + deviceId: null, + baseUrl: null, + homeserver: null, + // The gradient is drawn by a `position: fixed` pseudo-element, which + // would escape the container and cover the host's own interface + background: BackgroundStyle.Solid, + }); + }); + + it("keep a hosted call inside its room", () => { + const hosted = configurationForIntent(UserIntent.JoinExistingCall); + expect(hosted).toMatchObject({ + // A host owns navigation, so Element Call must not offer a way out of + // the room + confineToRoom: true, + perParticipantE2EE: true, + // The lobby first, so that the user picks their devices rather than + // being thrown into the call by the act of being rendered + skipLobby: false, + }); + // No Element Call branding inside someone else's application + expect(hosted.header).not.toBe(HeaderStyle.Standard); + }); + + it("fall back to the standalone app's when no intent is stated", () => { + expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({ + confineToRoom: false, + header: HeaderStyle.Standard, + perParticipantE2EE: false, + }); + }); + }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 422163fc4..25b60dbd9 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -354,6 +354,135 @@ export const getUrlParams = ( return params; }; +/** + * The configuration implied by what the user meant to do — if they pressed a + * Start Call button this would be `start_call`, and if they pressed Join Call, + * `join_existing`. + * + * These are platform-specific defaults, so that a host can start a call by + * saying what the user asked for rather than by setting every parameter itself, + * and so that what each intent means is Element Call's decision, made in one + * place. A host that wants something else states it alongside the intent. + * + * {@link UserIntent.Unknown} means no intent was stated, and gives the + * standalone app's defaults: Element Call owns the whole page, so it offers the + * way out of the room that a hosted call must not. + */ +export function configurationForIntent(intent: UserIntent): UrlConfiguration { + // Only constants and `platform` here, so that this depends on nothing but + // the intent. + let preset: UrlConfiguration = { + confineToRoom: true, + preload: false, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification", + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + switch (intent) { + case UserIntent.StartNewCall: + preset.skipLobby = false; + preset.callIntent = "video"; + break; + case UserIntent.JoinExistingCall: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = false; + preset.callIntent = "video"; + break; + case UserIntent.StartNewCallVoice: + preset.skipLobby = false; + preset.callIntent = "audio"; + break; + case UserIntent.JoinExistingCallVoice: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = false; + preset.callIntent = "audio"; + break; + case UserIntent.StartNewCallDMVoice: + preset.callIntent = "audio"; + // Fall through + case UserIntent.StartNewCallDM: + preset.skipLobby = true; + preset.sendNotificationType = "ring"; + preset.autoLeaveWhenOthersLeft = true; + preset.waitForCallPickup = true; + preset.callIntent = preset.callIntent ?? "video"; + break; + case UserIntent.JoinExistingCallDMVoice: + preset.callIntent = "audio"; + // Fall through + case UserIntent.JoinExistingCallDM: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = true; + preset.autoLeaveWhenOthersLeft = true; + preset.callIntent = preset.callIntent ?? "video"; + break; + // Non widget usecase defaults + default: + preset = { + confineToRoom: false, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + } + return preset; +} + +/** + * The {@link UrlProperties} for Element Call embedded in a host application. + * + * It has no URL of its own to read these from, and it does not need most of + * them: the widget plumbing does not apply, the Matrix client and the analytics + * configuration come from the host by other routes, and what is left is either + * the host's to state through the component's props or Element Call's own + * default. + */ +export const hostedProperties: UrlProperties = { + widgetId: null, + parentUrl: null, + isWidget: false, + roomId: null, + userId: null, + displayName: null, + deviceId: null, + baseUrl: null, + lang: null, + fonts: [], + fontScale: null, + posthogUserId: null, + posthogApiHost: null, + posthogApiKey: null, + e2eEnabled: true, + password: null, + viaServers: null, + homeserver: null, + rageshakeSubmitUrl: null, + sentryDsn: null, + sentryEnvironment: null, + theme: null, + // Solid rather than the gradient the standalone app defaults to: the gradient + // is drawn by a `position: fixed` pseudo-element, which would escape the + // container Element Call was given and cover the host's own interface. + background: BackgroundStyle.Solid, +}; + /** * Gets the app parameters for the current URL. * @param search The URL search string @@ -383,78 +512,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { const intent = !isWidget ? UserIntent.Unknown : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); - // Here we only use constants and `platform` to determine the intent preset. - let intentPreset: UrlConfiguration = { - confineToRoom: true, - preload: false, - header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, - showControls: true, - hideScreensharing: false, - allowIceFallback: true, - perParticipantE2EE: true, - controlledAudioDevices: platform === "desktop" ? false : true, - skipLobby: true, - returnToLobby: false, - sendNotificationType: "notification", - autoLeaveWhenOthersLeft: false, - waitForCallPickup: false, - }; - switch (intent) { - case UserIntent.StartNewCall: - intentPreset.skipLobby = false; - intentPreset.callIntent = "video"; - break; - case UserIntent.JoinExistingCall: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = false; - intentPreset.callIntent = "video"; - break; - case UserIntent.StartNewCallVoice: - intentPreset.skipLobby = false; - intentPreset.callIntent = "audio"; - break; - case UserIntent.JoinExistingCallVoice: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = false; - intentPreset.callIntent = "audio"; - break; - case UserIntent.StartNewCallDMVoice: - intentPreset.callIntent = "audio"; - // Fall through - case UserIntent.StartNewCallDM: - intentPreset.skipLobby = true; - intentPreset.sendNotificationType = "ring"; - intentPreset.autoLeaveWhenOthersLeft = true; - intentPreset.waitForCallPickup = true; - intentPreset.callIntent = intentPreset.callIntent ?? "video"; - break; - case UserIntent.JoinExistingCallDMVoice: - intentPreset.callIntent = "audio"; - // Fall through - case UserIntent.JoinExistingCallDM: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = true; - intentPreset.autoLeaveWhenOthersLeft = true; - intentPreset.callIntent = intentPreset.callIntent ?? "video"; - break; - // Non widget usecase defaults - default: - intentPreset = { - confineToRoom: false, - preload: false, - header: HeaderStyle.Standard, - showControls: true, - hideScreensharing: false, - allowIceFallback: false, - perParticipantE2EE: false, - controlledAudioDevices: false, - skipLobby: false, - returnToLobby: false, - sendNotificationType: undefined, - autoLeaveWhenOthersLeft: false, - waitForCallPickup: false, - }; - } + const intentPreset = configurationForIntent(intent); const properties: UrlProperties = { widgetId, From 3361ce2b609b70b473e3d6cf811866ef1d5d5840 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 17:30:16 +0200 Subject: [PATCH 26/54] Keep what the component draws inside the container it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening settings in an embedded call put the dialog in the middle of the host's window, spilling outside the container, and with two calls on a page the second drew over the first's dialog. The container established a stacking context but not a containing block, which are two different things and only the first had been done. `position: fixed` resolves against the viewport unless an ancestor makes itself the containing block, so the modal scrim and dialog in Overlay.module.css — `fixed`, `inset: 0`, centred, because in the standalone app they are meant to cover the page — were positioned and sized against the window. Layout and paint containment makes the container the containing block for those descendants and clips what we paint to our own box. The second-call-on-top symptom goes with it, since the dialog now stays inside the first call's box and there is nothing to overlap. Two sibling components still cannot draw over one another by construction, neither being able to leave its own stacking context, but that only shows if a host overlaps them. Containment clips a box measured in viewport units but cannot resize it, so anything positioned that way is simply put somewhere outside the container and disappears. That applied to the reactions overlay, a `100vw` by `100vh` box, and to the reaction picker, which sits at `82vh` so as to appear near the footer it belongs to. Both are positioned against whatever Element Call treats as its root — `[data-overlay-container]` in the app, which is the size of the page, and the host's container when embedded — so percentages mean the same thing there and the right thing here. The earpiece overlay is `inset: 0` with no viewport units, so containment is enough for it. Three viewport-relative sizes remain, all of which need more than a change of unit: the lobby's video preview is `50vh` on a flex item with an aspect ratio, `--content-inset-*` ramps up to a desktop inset from the window width, and the picker's `max-width` cap is the window's. The clipping cuts both ways: a menu near the edge of a small container is trimmed rather than overflowing into the host. That is the trade an embedded component makes. --- component/ElementCall.module.css | 17 ++++++++++++++--- src/button/ReactionToggleButton.module.css | 7 ++++++- src/room/ReactionsOverlay.module.css | 15 +++++++++------ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css index 461ad9b0c..f99dccb16 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -6,15 +6,26 @@ 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. */ +it, and nothing we draw may leave it. + +That takes two separate things, which are easy to mistake for one. `isolation` +gives us a stacking context, so nothing inside can be layered above the host's +own interface. Containment makes us the containing block for `position: fixed` +descendants, and clips what we paint to our own box: without it, the modal +scrim and dialog — which are positioned `fixed` and centred, since in the app +they are meant to cover the page — resolve against the viewport and appear in +the middle of the host's window rather than in the middle of the call. + +The clipping cuts both ways: a menu near the edge of a small container is +trimmed rather than overflowing into the host. That is the trade being an +embedded component makes. */ .root { display: flex; flex-direction: column; inline-size: 100%; block-size: 100%; isolation: isolate; + contain: layout paint; position: relative; background-color: var(--cpd-color-bg-canvas-default); color: var(--cpd-color-text-primary); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 90c6af021..705d4d9ed 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -19,7 +19,12 @@ } div.reactionPopupMenuRoot.reactionPopupMenuModal { - --overlay-top: 82vh; + /* Down near the footer it belongs to, rather than centred like other modals. + A percentage, not a viewport unit: the overlay is positioned `fixed`, so this + resolves against the page in the standalone app and against the container + when a host embeds us — where 82vh would put it below the container + entirely. */ + --overlay-top: 82%; width: fit-content; } diff --git a/src/room/ReactionsOverlay.module.css b/src/room/ReactionsOverlay.module.css index 3738dc09e..618adbf38 100644 --- a/src/room/ReactionsOverlay.module.css +++ b/src/room/ReactionsOverlay.module.css @@ -3,8 +3,11 @@ display: inline; z-index: 2; pointer-events: none; - width: 100vw; - height: 100vh; + /* Percentages, not viewport units: the containing block is the element + Element Call treats as its root, which is the page in the standalone app but + the container a host gave us when embedded. */ + width: 100%; + height: 100%; left: 0; top: 0; } @@ -16,7 +19,7 @@ animation-name: reaction-up; width: fit-content; position: relative; - top: 80vh; + top: 80%; } @keyframes reaction-up { @@ -24,7 +27,7 @@ opacity: 1; translate: 0 0; scale: 200%; - top: 80vh; + top: 80%; } to { @@ -48,7 +51,7 @@ .reaction { font-size: 48pt; animation-name: reaction-up-reduced; - top: calc(-50vh + (48pt / 2)); - left: calc(50vw - (48pt / 2)) !important; + top: calc(-50% + (48pt / 2)); + left: calc(50% - (48pt / 2)) !important; } } From ace78de749ebb067cd258cd6be61610b14ca969a Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 18:09:12 +0200 Subject: [PATCH 27/54] Cover Element Call as a component with end-to-end tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget tests cannot reach what makes a component different, because the iframe used to guarantee it: that Element Call stays inside the space it was given, and that two of them can exist in one page. So the development harness gets driven by Playwright. Three tests. Two components in one page holding a real call between two devices of one account. The settings dialog and the reaction picker staying inside the container the host gave, asserted by bounding box. And the host bridge reporting in both directions, including a host-initiated mute coming back as a report of the new state. The containment test is the one worth having: both of the escapes found by running the harness by hand — the settings dialog centred on the window, and the reaction picker at 82vh landing below the container — would have failed it, and neither was visible to typechecking, linting or the unit tests. Users and rooms are created through the Synapse admin and client-server APIs rather than by driving an interface, and the harness now takes its credentials from its own query string so that a test can say which account and room to use. A host reading its own URL is proper; it was Element Call doing so that was the mistake. Playwright gains a second web server for the harness on port 3001, a Vite dev server whether or not the app itself is served from Docker, since the harness is a development page with nothing to build. --- component/dev/Harness.tsx | 31 ++++-- playwright.config.ts | 35 +++++-- playwright/component/component-call.spec.ts | 102 ++++++++++++++++++ playwright/component/harness.ts | 110 ++++++++++++++++++++ 4 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 playwright/component/component-call.spec.ts create mode 100644 playwright/component/harness.ts diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index ddb69d327..141f94922 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -37,16 +37,31 @@ const DEFAULT_CREDENTIALS: Credentials = { room: "", }; -/** The last credentials used, so that a reload does not mean typing them again. */ +/** + * The credentials to start with: the last ones used, so that a reload does not + * mean typing them again, overridden by anything in the query string. + * + * A host reading its own URL is entirely proper — it was Element Call doing so + * that was the mistake. It lets the end-to-end tests, or a shared link, say + * which account and room to use. + */ function loadCredentials(): Credentials { + let stored: Partial = {}; try { - const stored = localStorage.getItem(CREDENTIALS_KEY); - if (stored !== null) - return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) }; + const json = localStorage.getItem(CREDENTIALS_KEY); + if (json !== null) stored = JSON.parse(json) as Credentials; } catch (e) { logger.warn("Could not read the stored harness credentials", e); } - return DEFAULT_CREDENTIALS; + + const query = new URLSearchParams(location.search); + const fromUrl = Object.fromEntries( + (["homeserver", "username", "password", "room"] as const) + .map((name) => [name, query.get(name)]) + .filter(([, value]) => value !== null), + ) as Partial; + + return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl }; } interface Session { @@ -88,7 +103,7 @@ const Pane: FC<{ ); return ( -
+
{session.label} {session.client.getDeviceId()} @@ -110,7 +125,7 @@ const Pane: FC<{
{/* Resizable, because how Element Call copes with the size it is given is one of the things we cannot find out from the standalone app */} -
+
{mounted && ( { ))}
-
+

Host bridge

    {entries.map((entry, i) => ( diff --git a/playwright.config.ts b/playwright.config.ts index 85e65e13f..73112c7ad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,8 @@ import { join } from "path"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts"; + const baseURL = process.env.USE_DOCKER ? "http://localhost:8080" : "https://localhost:3000"; @@ -115,14 +117,29 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: "./scripts/playwright-webserver-command.sh", - url: baseURL, - reuseExistingServer: !process.env.CI, - ignoreHTTPSErrors: true, - gracefulShutdown: { - signal: "SIGTERM", - timeout: 500, + webServer: [ + { + command: "./scripts/playwright-webserver-command.sh", + url: baseURL, + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 500, + }, }, - }, + { + // The harness that embeds Element Call as a component. Always a Vite dev + // server, whether or not the app itself is being served from Docker, + // since there is nothing to build: it is a development page only. + command: "pnpm dev:component", + url: COMPONENT_HARNESS_URL, + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 500, + }, + }, + ], }); diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts new file mode 100644 index 000000000..233b826a3 --- /dev/null +++ b/playwright/component/component-call.spec.ts @@ -0,0 +1,102 @@ +/* +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 { expect, type Locator, test } from "@playwright/test"; + +import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; + +/** + * Element Call embedded as a React component, driven through the development + * harness in `component/dev`. + * + * What these cover that the widget tests cannot is everything that follows from + * sharing a page with a host: whether Element Call stays inside the space it + * was given, and whether two of it can exist at once. As a widget, the iframe + * guaranteed both. + */ + +/** The settings button, whichever of the two the footer is currently showing. */ +function settingsButton(pane: Locator): Locator { + return pane + .getByTestId("settings-bottom-left") + .or(pane.getByTestId("settings-bottom-center")) + .filter({ visible: true }) + .first(); +} + +test("holds a call between two components on one page", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("twocomponents"); + const panes = await startHarness(page, username, roomId); + + // Each component shows a lobby of its own, and neither has joined anything + // just by being rendered + for (const index of [0, 1]) + await expect(panes.nth(index).getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + for (const index of [0, 1]) + await panes.nth(index).getByTestId("lobby_joinCall").click(); + + // Two devices of one account, so each component should see itself and the + // other. This is the part that proves two Element Calls in one page are two + // calls, and not one shared thing wearing two hats. + for (const index of [0, 1]) + await expect(panes.nth(index).getByTestId("videoTile")).toHaveCount(2, { + timeout: 60_000, + }); +}); + +test("keeps its modals inside the container it was given", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("containment"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.getByTestId("footer-container")).toBeVisible({ + timeout: 60_000, + }); + + // Both of these are positioned `fixed`, and were centred on the window + // rather than the container until it was made a containing block. The + // settings dialog spilled over the host's interface; the reaction picker sat + // at 82vh, which put it below the container entirely and so out of sight. + await settingsButton(pane).click(); + await expectWithin(pane.getByRole("dialog"), container); + await pane.getByTestId("modal_close").click(); + + await pane.getByRole("button", { name: "Reactions" }).click(); + await expectWithin( + pane.getByRole("dialog", { name: "Pick reaction" }), + container, + ); +}); + +test("tells its host what it is doing", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("hostbridge"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const log = page.getByTestId("bridge-log"); + + // Every component reports to its host through the bridge, whether that host + // is a widget container or an application embedding it directly + await expect(log).toContainText("contentLoaded", { timeout: 60_000 }); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(log).toContainText("notifyJoined", { timeout: 60_000 }); + await expect(log).toContainText("setAlwaysOnScreen(true)", { + timeout: 60_000, + }); + + // And takes instructions back: the host asking for a mute should come back + // as the component reporting the new state + await pane.getByRole("button", { name: "Mute" }).click(); + await expect(log).toContainText("notifyDeviceMute(audio: false", { + timeout: 30_000, + }); +}); diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts new file mode 100644 index 000000000..4602f22d8 --- /dev/null +++ b/playwright/component/harness.ts @@ -0,0 +1,110 @@ +/* +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 { expect, type Locator, type Page } from "@playwright/test"; + +import { SynapseAdmin } from "../utils/synapse-admin.ts"; + +/** + * Where the component harness is served — `component/dev`, which embeds Element + * Call the way a host application would. Not the `baseURL` the rest of the + * suite uses: these tests drive a page that contains Element Call rather than + * Element Call itself. + */ +export const COMPONENT_HARNESS_URL = "https://localhost:3001"; + +const HOMESERVER_URL = "https://synapse.m.localhost"; +const PASSWORD = "foobarbaz1!"; + +/** + * Registers a user through the Synapse admin API and creates a room for it to + * call in, without touching a browser. The harness signs into this account + * twice, giving two devices in one page and so a real call between the two + * components. + */ +export async function createUserAndRoom( + name: string, +): Promise<{ username: string; roomId: string }> { + const username = `${name}_${Date.now()}`; + const { access_token: accessToken } = await SynapseAdmin.forHomeserver( + HOMESERVER_URL, + ).registerUser(username, PASSWORD, name); + + const response = await fetch( + `${HOMESERVER_URL}/_matrix/client/v3/createRoom`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: `${name}'s call`, preset: "private_chat" }), + }, + ); + if (!response.ok) + throw new Error( + `Could not create a room: ${response.status} ${await response.text()}`, + ); + const { room_id: roomId } = (await response.json()) as { room_id: string }; + + return { username, roomId }; +} + +/** + * Opens the harness signed in as the given user, and waits for both embedded + * calls to appear. + * + * @returns The two containers the host gave Element Call, in order. + */ +export async function startHarness( + page: Page, + username: string, + roomId: string, +): Promise { + const query = new URLSearchParams({ + homeserver: HOMESERVER_URL, + username, + password: PASSWORD, + room: roomId, + }); + await page.goto(`${COMPONENT_HARNESS_URL}/?${query.toString()}`); + await page.getByRole("button", { name: "Start" }).click(); + + const panes = page.getByTestId("call-pane"); + // Two logins, two crypto setups and two initial syncs happen first + await expect(panes).toHaveCount(2, { timeout: 120_000 }); + return panes; +} + +/** + * Asserts that one element is drawn entirely inside another. + * + * This is the check that being a component rather than an iframe costs us: an + * iframe could not paint outside itself whatever its stylesheets said, whereas + * a component shares the page and has to be made to stay put. + */ +export async function expectWithin( + inner: Locator, + outer: Locator, +): Promise { + await expect(inner).toBeVisible(); + const innerBox = await inner.boundingBox(); + const outerBox = await outer.boundingBox(); + if (innerBox === null || outerBox === null) + throw new Error("Expected both elements to be laid out"); + + // A pixel of slack, for subpixel layout + const slack = 1; + expect(innerBox.x).toBeGreaterThanOrEqual(outerBox.x - slack); + expect(innerBox.y).toBeGreaterThanOrEqual(outerBox.y - slack); + expect(innerBox.x + innerBox.width).toBeLessThanOrEqual( + outerBox.x + outerBox.width + slack, + ); + expect(innerBox.y + innerBox.height).toBeLessThanOrEqual( + outerBox.y + outerBox.height + slack, + ); +} From f5458bb03e93bda8128b96e0225c178fc614e0e6 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 13:51:47 +0200 Subject: [PATCH 28/54] de-globalise styles --- README.md | 6 + component/build/scopeStylesToRoot.test.ts | 112 +++++++++++++ component/build/scopeStylesToRoot.ts | 161 +++++++++++++++++++ component/index.tsx | 5 +- package.json | 1 + playwright/component/component-call.spec.ts | 32 ++++ pnpm-lock.yaml | 3 + src/base.css | 13 +- src/settings/DeveloperSettingsTab.module.css | 2 +- src/settings/DeveloperSettingsTab.tsx | 4 +- vite-component-dev.config.ts | 4 + vite-component.config.ts | 133 ++++++++------- vitest.config.ts | 6 +- 13 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 component/build/scopeStylesToRoot.test.ts create mode 100644 component/build/scopeStylesToRoot.ts diff --git a/README.md b/README.md index 792665f9f..248609e53 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,12 @@ Element Call behaves when it does not own the page — the size it is given, whether it stays inside its container, and what it says to its host, which is logged along the bottom. +The component's stylesheet is confined to the element it is mounted in: the +build rewrites every selector so that it matches only Element Call's root or +what is inside it, with `html`, `body` and `:root` standing for that root (see +`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, +and Element Call brings its own fonts and design tokens along. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/build/scopeStylesToRoot.test.ts b/component/build/scopeStylesToRoot.test.ts new file mode 100644 index 000000000..ae42062c3 --- /dev/null +++ b/component/build/scopeStylesToRoot.test.ts @@ -0,0 +1,112 @@ +/* +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 { describe, expect, it } from "vitest"; +import postcss from "postcss"; + +import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot"; + +const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +const isRoot = `:where(${ROOT_SELECTOR})`; + +async function scope(css: string, file = "base.css"): Promise { + const result = await postcss([scopeStylesToRoot()]).process(css, { + from: file, + }); + return result.css; +} + +describe("scopeStylesToRoot", () => { + it("makes the root stand in for the document", async () => { + expect(await scope("html { line-height: 1.15 }")).toBe( + `${isRoot} { line-height: 1.15 }`, + ); + expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`); + expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`); + expect(await scope("body.no-scroll-body { position: fixed }")).toBe( + `${isRoot}.no-scroll-body { position: fixed }`, + ); + expect(await scope("body .x { color: red }")).toBe( + `${isRoot} .x { color: red }`, + ); + }); + + it("collapses selectors that all became the root", async () => { + expect(await scope("html, body, input { font: inherit }")).toBe( + `${isRoot},input${inRoot} { font: inherit }`, + ); + }); + + it("confines everything else to the root and what is inside it", async () => { + expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`); + expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe( + `.cpd-theme-dark${inRoot} { --a: 1 }`, + ); + expect(await scope("* { box-sizing: border-box }")).toBe( + `*${inRoot} { box-sizing: border-box }`, + ); + expect(await scope(".a > .b + .c { color: red }")).toBe( + `.a>.b+.c${inRoot} { color: red }`, + ); + }); + + it("keeps pseudo-elements last", async () => { + expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe( + `button${inRoot}::-moz-focus-inner { border: 0 }`, + ); + expect(await scope(".a .b:hover::after { content: '' }")).toBe( + `.a .b:hover${inRoot}::after { content: '' }`, + ); + expect(await scope("p:first-letter { color: red }")).toBe( + `p${inRoot}:first-letter { color: red }`, + ); + }); + + it("leaves alone what already names the root", async () => { + const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`; + expect(await scope(css)).toBe(css); + }); + + it("reaches into layers and media queries", async () => { + expect( + await scope( + "@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }", + ), + ).toBe( + `@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`, + ); + }); + + it("does not touch keyframes or nested rules", async () => { + expect( + await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"), + ).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"); + expect( + await scope( + ".a { color: red; &:hover { color: blue } .b { color: green } }", + ), + ).toBe( + `.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`, + ); + }); + + it("only touches the bare selectors of a CSS module", async () => { + const file = "Settings.module.css"; + expect(await scope("pre { font-size: 1px }", file)).toBe( + `pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".modal pre { font-size: 1px }", file)).toBe( + `.modal pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".box_abc12 { border: 0 }", file)).toBe( + ".box_abc12 { border: 0 }", + ); + expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe( + ".a .b_abc12:hover { border: 0 }", + ); + }); +}); diff --git a/component/build/scopeStylesToRoot.ts b/component/build/scopeStylesToRoot.ts new file mode 100644 index 000000000..29532d532 --- /dev/null +++ b/component/build/scopeStylesToRoot.ts @@ -0,0 +1,161 @@ +/* +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 { + type AtRule, + type Container, + type Document, + type Plugin, + type Rule, +} from "postcss"; +import selectorParser, { + type Node, + type Pseudo, + type Selector, +} from "postcss-selector-parser"; + +/** + * How the stylesheets find Element Call's root element. The attribute is put + * there by `useTheme`, on the container the host gives the component. + */ +export const ROOT_SELECTOR = "[data-element-call-root]"; + +// Both are `:where()`, which has no specificity of its own, so the rules keep +// exactly the weight they had before being scoped and nothing in Element Call's +// cascade changes — only where it applies. +// +// The root, or anything inside it. Appended to the element a rule is about, +// rather than prepended to the whole selector, so that a rule about the root +// itself (its theme class, say) still matches. +const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +// The root itself, standing in for the document. +const IS_ROOT = `:where(${ROOT_SELECTOR})`; + +/** + * Confines a stylesheet to Element Call's root element, for the build of + * Element Call as a component. + * + * As a page of its own, Element Call can style the document: normalize.css and + * Compound speak of `html`, `body` and bare elements, and the design tokens are + * declared on `:root`. Embedded in a host, all of that would land on the host's + * document too. This rewrites every selector so that it matches only the root + * or its descendants: + * + * - `html`, `body` and `:root` become the root element, which is what stands in + * for the document inside a host. + * - Everything else keeps its selector and gains `:where([data-element-call-root], + * [data-element-call-root] *)` on the element it styles. + * - Selectors that already name the root are left alone, as are keyframe + * selectors and rules nested inside another rule, which are relative to it. + * + * CSS modules are scoped by their class names already, so only their selectors + * that would match by element alone — `pre` rather than `.pre` — are touched. + * + * The root's fonts and design tokens are still inherited by everything inside + * it, the way they were from `body` and `:root`, and `@font-face` declarations + * stay global, which they are by nature. + */ +export function scopeStylesToRoot(): Plugin { + return { + postcssPlugin: "element-call-scope-styles-to-root", + Once(root) { + const isModule = + root.source?.input.file?.endsWith(".module.css") ?? false; + root.walkRules((rule) => { + if (isRelative(rule)) return; + rule.selector = (isModule ? scopeBare : scopeAll).processSync( + rule.selector, + { lossless: false }, + ); + }); + }, + }; +} + +/** Whether a rule's selectors are relative to something other than the document. */ +function isRelative(rule: Rule): boolean { + let parent: Container | Document | undefined = rule.parent; + while (parent !== undefined) { + if (parent.type === "rule") return true; + if (parent.type === "atrule") { + const { name } = parent as AtRule; + if (name.endsWith("keyframes") || name === "page") return true; + } + parent = parent.parent; + } + return false; +} + +const processor = (isModule: boolean): ReturnType => + selectorParser((selectors) => { + selectors.each((selector) => { + scopeSelector(selector, isModule); + }); + // Mapping `html, body` onto the root leaves the same selector twice + const seen = new Set(); + selectors.each((selector) => { + const text = String(selector).trim(); + if (seen.has(text)) selector.remove(); + else seen.add(text); + }); + }); + +// Everything, for stylesheets that speak of the document; only what a class +// does not already confine, for CSS modules +const scopeAll = processor(false); +const scopeBare = processor(true); + +function scopeSelector(selector: Selector, isModule: boolean): void { + if (String(selector).includes(ROOT_SELECTOR)) return; + + const compounds = splitCompounds(selector); + if (compounds.length === 0) return; + + // Something said of the document is said of the root instead + const document = compounds[0].find(isDocumentSelector); + if (document !== undefined) { + document.replaceWith(pseudo(IS_ROOT)); + return; + } + + const subject = compounds.at(-1)!; + if (isModule && subject.some((node) => node.type === "class")) return; + + // Pseudo-elements have to come last in a compound selector + const pseudoElement = subject.find(isPseudoElement); + if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT)); + else selector.insertBefore(pseudoElement, pseudo(IN_ROOT)); +} + +/** The compound selectors making up a complex selector, in order. */ +function splitCompounds(selector: Selector): Node[][] { + const compounds: Node[][] = [[]]; + for (const node of selector.nodes) { + if (node.type === "combinator") compounds.push([]); + else if (node.type !== "comment") compounds.at(-1)!.push(node); + } + return compounds.filter((compound) => compound.length > 0); +} + +function isDocumentSelector(node: Node): boolean { + return ( + (node.type === "tag" && (node.value === "html" || node.value === "body")) || + (node.type === "pseudo" && node.value === ":root") + ); +} + +function isPseudoElement(node: Node): node is Pseudo { + if (node.type !== "pseudo") return false; + return ( + node.value.startsWith("::") || + [":before", ":after", ":first-line", ":first-letter"].includes(node.value) + ); +} + +function pseudo(text: string): Pseudo { + return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo; +} diff --git a/component/index.tsx b/component/index.tsx index 55e2f996f..84a18353c 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -18,7 +18,10 @@ Please see LICENSE in the repository root for full details. */ // The design tokens, fonts and element defaults every Element Call stylesheet -// builds on. +// 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 diff --git a/package.json b/package.json index cf1e1f717..471cd28b2 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "pako": "^2.0.4", "postcss": "^8.4.41", "postcss-preset-env": "^10.0.0", + "postcss-selector-parser": "^7.1.1", "posthog-js": "1.408.2", "qrcode": "^1.5.4", "react": "19", diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 233b826a3..d679fbb24 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -77,6 +77,38 @@ test("keeps its modals inside the container it was given", async ({ page }) => { ); }); +test("leaves the host's own page unstyled", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("hoststyles"); + const panes = await startHarness(page, username, roomId); + await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + // Element Call's stylesheet is written for a page of its own: normalize.css + // gives `html` a line height, Compound gives `body` its font and feature + // settings, and the design tokens live on `:root`. None of that may reach the + // host's document — the harness sets none of these itself, so anything other + // than the browser's defaults here came from us. + const host = await page.evaluate(() => { + const html = getComputedStyle(document.documentElement); + const body = getComputedStyle(document.body); + return { + lineHeight: html.lineHeight, + fontFeatureSettings: body.fontFeatureSettings, + token: html.getPropertyValue("--cpd-color-text-primary"), + }; + }); + expect(host).toEqual({ + lineHeight: "normal", + fontFeatureSettings: "normal", + token: "", + }); + + // While inside the container, the same rules do apply + const root = panes.first().locator("[data-element-call-root]"); + await expect(root).toHaveCSS("font-feature-settings", /"kern"/); +}); + test("tells its host what it is doing", async ({ page }) => { const { username, roomId } = await createUserAndRoom("hostbridge"); const panes = await startHarness(page, username, roomId); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfb3ccd3..b50a25f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: postcss-preset-env: specifier: ^10.0.0 version: 10.6.1(postcss@8.5.25) + postcss-selector-parser: + specifier: ^7.1.1 + version: 7.1.1 posthog-js: specifier: 1.408.2 version: 1.408.2 diff --git a/src/base.css b/src/base.css index 0d424e64a..ae09f4921 100644 --- a/src/base.css +++ b/src/base.css @@ -10,11 +10,14 @@ and element defaults its own stylesheets build on top of. Split out from index.css so that Element Call embedded in a host application can have these without also being given the standalone page's layout, which -would style the host's own document. What remains here does still reach outside -Element Call's container — normalize.css and the typography below use bare -element selectors, and the custom properties are declared on `:root` — so a -host gets those too. Narrowing them needs a real host to check against, so it -waits for the Element Web integration rather than being guessed at here. +would style the host's own document. What remains here still speaks of the +document — normalize.css and the typography below use bare element selectors, +and the custom properties are declared on `:root` — which is right for the +page, and is why the component build rewrites it: there every selector is +confined to Element Call's root element (see component/build/scopeStylesToRoot.ts), +with `html`, `body` and `:root` becoming that element. Nothing needs to be +written differently here for that to work, but nothing here may rely on +reaching the host's document either. Nothing here should depend on where it lands relative to Element Call's component stylesheets: the bundler decides that, and it decides differently for diff --git a/src/settings/DeveloperSettingsTab.module.css b/src/settings/DeveloperSettingsTab.module.css index 29f4211bc..369bec3b7 100644 --- a/src/settings/DeveloperSettingsTab.module.css +++ b/src/settings/DeveloperSettingsTab.module.css @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -pre { +.pre { font-size: var(--font-size-micro); } diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 25b3c81ce..ec5bf7b08 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -680,9 +680,9 @@ export const DeveloperSettingsTab: FC = ({

    {t("developer_mode.environment_variables")}

    -
    {JSON.stringify(env, null, 2)}
    +
    {JSON.stringify(env, null, 2)}

    {t("developer_mode.url_params")}

    -
    {JSON.stringify(urlParams, null, 2)}
    +
    {JSON.stringify(urlParams, null, 2)}
    ); }; diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts index 61cb5e512..2e6783820 100644 --- a/vite-component-dev.config.ts +++ b/vite-component-dev.config.ts @@ -10,6 +10,7 @@ import { realpathSync } from "node:fs"; import * as fs from "node:fs"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // Serves the harness under `component/dev`, which embeds Element Call as a // component the way a host application would. Development only: this is not @@ -35,6 +36,9 @@ export default defineConfig(({ mode }) => { return { ...vitePluginsConfig({ mode, html: false }), root: "component/dev", + // The same scoping the library build applies, so the harness shows what a + // host will get — including whether its own page is left alone + css: { postcss: { plugins: [scopeStylesToRoot()] } }, // So that the harness can read the same config.json the standalone app // does, if the developer has written one publicDir: "../../public", diff --git a/vite-component.config.ts b/vite-component.config.ts index 17f625628..127f599fa 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -8,67 +8,88 @@ Please see LICENSE in the repository root for full details. import { defineConfig } from "vite"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // 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, html: false }), - // A library has no public directory to serve. Without this the build copies - // whatever is in `public` — including the developer's own config.json, which - // is not in the repository — into the output we would publish. - publicDir: false, - 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", +export default defineConfig(({ mode }) => { + const base = vitePluginsConfig({ mode, html: false }); + return { + ...base, + resolve: { + ...base.resolve, + alias: { + ...base.resolve?.alias, + // react-i18next depends on the CommonJS `use-sync-external-store/shim`, whose + // `require("react")` cannot be bundled against an external React: rolldown leaves a + // `require` shim that throws in the browser. React 18+ provides `useSyncExternalStore` + // itself, so point the shim at React. + "use-sync-external-store/shim": "react", + }, }, - 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` reads this list and fails if the source imports - // one of these packages by a path it does not name; a few entries below - // are there only because the standalone app imports them, which costs - // nothing. - external: [ - "react", - "react/jsx-runtime", - "react-dom", - "react-dom/client", - "livekit-client", - "matrix-js-sdk", - "matrix-js-sdk/lib/browser-index", - "matrix-js-sdk/lib/client", - "matrix-js-sdk/lib/crypto-api", - "matrix-js-sdk/lib/indexeddb-worker", - "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", - ], + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, + // A host's document is not ours to style: everything in the stylesheet is + // confined to the element Element Call is mounted in + css: { postcss: { plugins: [scopeStylesToRoot()] } }, + 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` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. + external: [ + "react", + "react/jsx-runtime", + // Emitted by the React Compiler for every compiled component; part of React, so it must be + // the host's copy too (bundled, its CommonJS `require("react")` throws in the browser). + "react/compiler-runtime", + "react-dom", + "react-dom/client", + "livekit-client", + "matrix-js-sdk", + "matrix-js-sdk/lib/browser-index", + "matrix-js-sdk/lib/client", + "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/indexeddb-worker", + "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", + ], + }, }, - }, -})); + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts index c5e908e4b..81519325a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,11 @@ export default defineConfig((configEnv) => css: { include: /.+/ }, setupFiles: ["src/vitest.setup.ts"], environment: "jsdom", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + include: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "component/**/*.test.ts", + ], }, }, { From 38c28a1c8ffb8c7f26706e22f8845808de0ad311 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 14:16:40 +0200 Subject: [PATCH 29/54] scaling based on component size instead webview size --- README.md | 4 + playwright/component/component-call.spec.ts | 33 ++++++ sdk/main.ts | 3 + src/room/InCallView.test.tsx | 70 +++++++++++- src/room/InCallView.tsx | 10 ++ .../__snapshots__/InCallView.test.tsx.snap | 1 + src/state/CallViewModel/CallViewModel.ts | 30 ++--- src/utils/elementSize.test.ts | 104 ++++++++++++++++++ src/utils/elementSize.ts | 42 +++++++ src/utils/test-viewmodel.ts | 1 + src/vitest.setup.ts | 9 ++ 11 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 src/utils/elementSize.test.ts create mode 100644 src/utils/elementSize.ts diff --git a/README.md b/README.md index 248609e53..48b9bbc84 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,10 @@ Element Call behaves when it does not own the page — the size it is given, whether it stays inside its container, and what it says to its host, which is logged along the bottom. +The call lays itself out for the size of the element it is mounted in, not the +window: a host that shrinks the container to a corner of its page gets the +picture-in-picture layout, just as a host that shrank the whole iframe used to. + The component's stylesheet is confined to the element it is mounted in: the build rewrites every selector so that it matches only Element Call's root or what is inside it, with `html`, `body` and `:root` standing for that root (see diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index d679fbb24..97a002675 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -132,3 +132,36 @@ test("tells its host what it is doing", async ({ page }) => { timeout: 30_000, }); }); + +test("lays itself out for the space it is given, not the page", async ({ + page, +}) => { + const { username, roomId } = await createUserAndRoom("containersize"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + const call = pane.locator("[data-layout]"); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(call).toBeVisible({ timeout: 60_000 }); + await expect(call).not.toHaveAttribute("data-layout", "pip"); + + // As a widget, Element Call's container and its window were one and the same: + // a host wanting a picture-in-picture made the iframe small, and Element Call + // saw the window shrink. A component gets no such signal from the window, + // which stays as large as it ever was; only the container changes. + const resize = async (width: number, height: number): Promise => + container.evaluate( + (element, size) => { + element.style.width = `${size.width}px`; + element.style.height = `${size.height}px`; + }, + { width, height }, + ); + + await resize(300, 300); + await expect(call).toHaveAttribute("data-layout", "pip"); + + await resize(900, 700); + await expect(call).not.toHaveAttribute("data-layout", "pip"); +}); diff --git a/sdk/main.ts b/sdk/main.ts index 6347d1421..6cd37032d 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -60,6 +60,7 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { initializeWidget } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { createWidgetHostBridge } from "../src/HostBridge"; +import { observeElementSize$ } from "../src/utils/elementSize"; interface MatrixRTCSdk { /** @@ -151,6 +152,8 @@ export async function createMatrixRTCSdk( ...callViewModelOptionsFromParams(urlParams), encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, hostBridge, + // The SDK owns its page, so the body is the space it has + windowSize$: scope.behavior(observeElementSize$(document.body)), }, of({}), of({}), diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 3113c0727..357bc186e 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -14,7 +14,7 @@ import { type MockedFunction, vi, } from "vitest"; -import { render, type RenderResult } from "@testing-library/react"; +import { act, render, type RenderResult } from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; @@ -51,6 +51,7 @@ import { AppBar } from "../AppBar"; import { type MatrixInfo } from "./VideoPreview"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { initializeWidget } from "../widget"; +import { RootElementProvider } from "../RootElementContext"; initializeWidget(); vi.hoisted( @@ -263,4 +264,71 @@ describe("ActiveCall", () => { // Rendering at all proves ActiveCall created all of its view models expect(await findByTestId("incall_leave")).toBeVisible(); }); + + it("lays the call out for the size of its root element", async () => { + // jsdom has no layout and no ResizeObserver: the root reports whatever + // size we say, and the observer notifies whenever we tell it to + let size = { width: 1000, height: 800 }; + const root = document.createElement("div"); + Object.defineProperty(root, "clientWidth", { get: () => size.width }); + Object.defineProperty(root, "clientHeight", { get: () => size.height }); + document.body.appendChild(root); + + const observers: (() => void)[] = []; + const originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + public constructor(private readonly callback: ResizeObserverCallback) {} + public observe(): void { + observers.push(() => this.callback([], this as ResizeObserver)); + } + public unobserve(): void {} + public disconnect(): void {} + } as unknown as typeof ResizeObserver; + + try { + const mediaDevices = mockMediaDevices({}); + const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]); + const { findByTestId, container } = render( + + + + + + + {}} + /> + + + + + + , + { container: root }, + ); + await findByTestId("incall_leave"); + const call = container.querySelector("[data-layout]")!; + expect(call.getAttribute("data-layout")).not.toBe("pip"); + + // The host shrinks the container to a corner of its page. The window has + // not changed at all — what matters is the element we were given. + size = { width: 300, height: 300 }; + act(() => observers.forEach((notify) => notify())); + expect(call.getAttribute("data-layout")).toBe("pip"); + + size = { width: 1000, height: 800 }; + act(() => observers.forEach((notify) => notify())); + expect(call.getAttribute("data-layout")).not.toBe("pip"); + } finally { + window.ResizeObserver = originalResizeObserver; + root.remove(); + } + }); }); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 97fb75438..e2df75295 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,6 +29,8 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useHostBridge } from "../HostBridge.ts"; +import { useRootElement } from "../RootElementContext"; +import { observeElementSize$ } from "../utils/elementSize"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -120,6 +122,9 @@ export const ActiveCall: FC = (props) => { const hostBridge = useHostBridge(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); + // The element we have to draw the call in: the page, or the container a host + // gave us. Its size, not the window's, decides how the call is laid out. + const rootElement = useRootElement(); useEffect(() => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); @@ -140,6 +145,7 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, + windowSize$: scope.behavior(observeElementSize$(rootElement)), }, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -165,6 +171,7 @@ export const ActiveCall: FC = (props) => { mediaDevices, trackProcessorState$, props.client, + rootElement, ]); useEffect(() => { @@ -625,6 +632,9 @@ export const InCallView: FC = ({ [styles.overflowing]: overflowing, })} ref={containerRef} + // Which layout the call has settled on, for tests and for anyone + // wondering why the call looks the way it does at the size it was given + data-layout={layout.type} onPointerUp={onViewPointerUp} onPointerMove={onPointerMove} onPointerOut={onPointerOut} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 984e2b12e..cdf5d17b3 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
    LivekitRoom; /** Optional behavior overriding the local connection state, mainly for testing purposes. */ connectionState$?: Behavior; - /** Optional behavior overriding the computed window size, mainly for testing purposes. */ - windowSize$?: Behavior<{ width: number; height: number }>; + /** + * The size of the space the call is drawn in: the page when Element Call + * owns it, or the container a host mounted it in when it is a component. + * The layout — whether the call is shown full size, flat, narrow or as a + * picture-in-picture — follows this rather than the size of the window, so + * that a component shrunk by its host adapts even though the window has not + * changed. + */ + windowSize$: Behavior<{ width: number; height: number }>; /** Optional value overriding the local transport, for testing purposes. */ localTransport?: LocalTransport; /** Optional value overriding the connection factory, for testing purposes. */ @@ -263,6 +270,11 @@ const smallMobileCallThreshold = 3; // with the interface const showFooterMs = 4000; +/** + * The general shape of the space the call is drawn in. Called a window because + * that is what it is in the standalone app; for a component it is the container + * the host gave us, which may be a small corner of a large window. + */ export type WindowMode = "normal" | "narrow" | "flat" | "pip"; interface LayoutScanState { @@ -1097,18 +1109,10 @@ export function createCallViewModel$( const pipEnabled$ = scope.behavior(setPipEnabled$, false); - const windowSize$ = - options.windowSize$ ?? - scope.behavior<{ width: number; height: number }>( - fromEvent(window, "resize").pipe( - startWith(null), - map(() => ({ width: window.innerWidth, height: window.innerHeight })), - ), - ); - - // A guess at what the window's mode should be based on its size and shape. + // A guess at what the window's mode should be based on the size and shape of + // the space we have to draw in. const naturalWindowMode$ = scope.behavior( - windowSize$.pipe( + options.windowSize$.pipe( map(({ width, height }) => { if (height <= 400 && width <= 340) return "pip"; // Our layouts for flat windows are better at adapting to a small width diff --git a/src/utils/elementSize.test.ts b/src/utils/elementSize.test.ts new file mode 100644 index 000000000..854da6f1d --- /dev/null +++ b/src/utils/elementSize.test.ts @@ -0,0 +1,104 @@ +/* +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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { observeElementSize$ } from "./elementSize"; + +/** + * A ResizeObserver whose notifications the test triggers itself, and which + * keeps track of what it is watching. jsdom does not ship one. + */ +class MockResizeObserver { + public static instances: MockResizeObserver[] = []; + public observed: Element[] = []; + public disconnected = false; + + public constructor(private readonly callback: ResizeObserverCallback) { + MockResizeObserver.instances.push(this); + } + + public observe(element: Element): void { + this.observed.push(element); + // Real observers report the initial size once observation starts + this.fire(); + } + + public unobserve(): void {} + + public disconnect(): void { + this.disconnected = true; + } + + public fire(): void { + this.callback([], this as unknown as ResizeObserver); + } +} + +describe("observeElementSize$", () => { + const originalResizeObserver = window.ResizeObserver; + let element: HTMLDivElement; + let size: { width: number; height: number }; + + beforeEach(() => { + MockResizeObserver.instances = []; + window.ResizeObserver = + MockResizeObserver as unknown as typeof ResizeObserver; + // jsdom does no layout, so the element reports whatever we say it does + size = { width: 800, height: 600 }; + element = document.createElement("div"); + Object.defineProperty(element, "clientWidth", { get: () => size.width }); + Object.defineProperty(element, "clientHeight", { get: () => size.height }); + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + }); + + it("emits the current size synchronously on subscription", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith({ width: 800, height: 600 }); + expect(MockResizeObserver.instances[0].observed).toEqual([element]); + }); + + it("emits again whenever the element is resized", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + const [observer] = MockResizeObserver.instances; + + size = { width: 300, height: 300 }; + observer.fire(); + size = { width: 1000, height: 700 }; + observer.fire(); + + expect(next.mock.calls.map(([s]) => s)).toEqual([ + { width: 800, height: 600 }, + { width: 300, height: 300 }, + { width: 1000, height: 700 }, + ]); + }); + + it("does not repeat a size that has not changed", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + // The initial report the observer makes on observe() carried the same size + // as the synchronous measurement, and so did not count + expect(next).toHaveBeenCalledTimes(1); + MockResizeObserver.instances[0].fire(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("stops observing when unsubscribed", () => { + const subscription = observeElementSize$(element).subscribe(); + const [observer] = MockResizeObserver.instances; + expect(observer.disconnected).toBe(false); + subscription.unsubscribe(); + expect(observer.disconnected).toBe(true); + }); +}); diff --git a/src/utils/elementSize.ts b/src/utils/elementSize.ts new file mode 100644 index 000000000..c2d5e921b --- /dev/null +++ b/src/utils/elementSize.ts @@ -0,0 +1,42 @@ +/* +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 { Observable, distinctUntilChanged } from "rxjs"; + +export interface ElementSize { + width: number; + height: number; +} + +/** + * Observes the size of an element, starting with the size it currently has and + * following it through every change for as long as the subscription lasts. + * + * Measured with `clientWidth`/`clientHeight`, so borders, scrollbars and any + * transforms a host might animate the element with are left out: this is the + * space there is to draw in, not where the element happens to be painted. + */ +export function observeElementSize$(element: Element): Observable { + return new Observable((subscriber) => { + const measure = (): void => + subscriber.next({ + width: element.clientWidth, + height: element.clientHeight, + }); + // Synchronously, so that a Behavior built on this has an initial value + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return (): void => observer.disconnect(); + }).pipe( + // A ResizeObserver reports once on observe(), which repeats the first + // measurement above, and again for changes to dimensions we do not read + distinctUntilChanged( + (a, b) => a.width === b.width && a.height === b.height, + ), + ); +} diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 8e88e720c..c1feb03ee 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -176,6 +176,7 @@ export function getBasicCallViewModelEnvironment( }), connectionState$: constant(ConnectionState.Connected), matrixRTCMode$: constant(MatrixRTCMode.Compatibility), + windowSize$: constant({ width: 1000, height: 800 }), ...callViewModelOptions, }, handRaisedSubject$, diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 00d289b54..373f08c9f 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -53,6 +53,15 @@ window.matchMedia = global.matchMedia = (): MediaQueryList => removeEventListener: () => {}, }) as Partial as MediaQueryList; +// jsdom does no layout and has no ResizeObserver. The call view observes the +// size of its root element; this one reports nothing, so that element stays at +// whatever size jsdom says it is (zero) unless a test says otherwise. +window.ResizeObserver ??= class ResizeObserver { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} +}; + const storage: Record = {}; const localStoragePolyfill = { getItem(key: string) { From 218ee46b7d86a01b3344c05f4309af06406d2af1 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 14:35:44 +0200 Subject: [PATCH 30/54] Make the component installable as a git dependency `component/package.json` describes the built component as a package (`@element-hq/element-call-component`: entry, stylesheet subpath, types, peer dependencies) so that a host can depend on `github:element-hq/element-call#&path:/component`. Its `prepare` script builds on install, since nothing is published yet. For that the component build now lands in `component/dist` instead of the repository's `dist`, and `pnpm build:component` also emits the type declarations (`component/tsconfig.build.json`, `build:component:types`), which previously had to be produced by hand. Co-Authored-By: Claude Fable 5.1 --- README.md | 17 +++++++++++++- component/package.json | 42 +++++++++++++++++++++++++++++++++++ component/pnpm-lock.yaml | 9 ++++++++ component/pnpm-workspace.yaml | 14 ++++++++++++ component/tsconfig.build.json | 20 +++++++++++++++++ package.json | 6 +++-- vite-component.config.ts | 4 ++++ 7 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 component/package.json create mode 100644 component/pnpm-lock.yaml create mode 100644 component/pnpm-workspace.yaml create mode 100644 component/tsconfig.build.json diff --git a/README.md b/README.md index 48b9bbc84..0732ff419 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ See also: Element Call can also be embedded directly into another React application rather than being loaded in an iframe as a widget. `pnpm build:component` -builds it as a library, and +builds it as a library into `component/dist` (the bundle, its stylesheet and +type declarations), and ```sh pnpm dev:component @@ -240,6 +241,20 @@ what is inside it, with `html`, `body` and `:root` standing for that root (see `component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, and Element Call brings its own fonts and design tokens along. +The package is not published yet. A host installs it as a git dependency on the +`component` directory of this repository, + +```json +"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component" +``` + +whose `prepare` script runs the build on install (the host's pnpm has to allow +that: `allowBuilds` in its `pnpm-workspace.yaml`). It imports the component from +`@element-hq/element-call-component` and the stylesheet from +`@element-hq/element-call-component/style.css`, and has to provide `react`, +`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves +them external. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/package.json b/component/package.json new file mode 100644 index 000000000..9ed81e02a --- /dev/null +++ b/component/package.json @@ -0,0 +1,42 @@ +{ + "name": "@element-hq/element-call-component", + "version": "0.0.0", + "description": "Element Call as a React component. Consumed straight from the repository as a git dependency (github:element-hq/element-call#&path:/component): the host's package manager runs `prepare`, which builds `dist/`.", + "license": "SEE LICENSE IN ../README.md", + "repository": { + "type": "git", + "url": "https://github.com/element-hq/element-call", + "directory": "component" + }, + "type": "module", + "devEngines": { + "packageManager": { + "name": "pnpm" + } + }, + "files": [ + "dist" + ], + "main": "./dist/element-call.js", + "module": "./dist/element-call.js", + "types": "./dist/types/component/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/component/index.d.ts", + "default": "./dist/element-call.js" + }, + "./style.css": "./dist/element-call.css" + }, + "sideEffects": [ + "*.css" + ], + "scripts": { + "prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component" + }, + "peerDependencies": { + "livekit-client": "^2.18.1", + "matrix-js-sdk": "*", + "react": "^19", + "react-dom": "^19" + } +} diff --git a/component/pnpm-lock.yaml b/component/pnpm-lock.yaml new file mode 100644 index 000000000..490c2e4fa --- /dev/null +++ b/component/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/component/pnpm-workspace.yaml b/component/pnpm-workspace.yaml new file mode 100644 index 000000000..d6c26ac66 --- /dev/null +++ b/component/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +# Makes `component/` a pnpm project of its own rather than a directory inside +# the repository's workspace. That matters when a host installs the component +# straight from this repository (`github:element-hq/element-call#…&path:/component`): +# pnpm prepares such a dependency by running `pnpm install` in this directory, +# and only a project root gets its `prepare` script (see package.json) run, which +# is what builds `dist/`. Inside the repository's own workspace the install +# would silently target the repository instead and build nothing. +# +# Consequence for development: pnpm commands run from within this directory see +# this project, not the repository; run them from the repository root. + +# Nothing to install here: the peers in package.json are the host's, and the +# build runs against the repository's own node_modules (see `prepare`). +autoInstallPeers: false diff --git a/component/tsconfig.build.json b/component/tsconfig.build.json new file mode 100644 index 000000000..18af65d84 --- /dev/null +++ b/component/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + // Declaration output for the component package (`pnpm build:component:types`). + // The root tsconfig only type-checks; this one emits `.d.ts` files, and nothing + // else, for everything the component's entry point reaches. The layout under + // `dist/types` mirrors the repository (`component/index.d.ts`, `src/…`), which + // is what `package.json` points its `types` at. + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "rootDir": "..", + "outDir": "./dist/types" + }, + // The entry point, plus the ambient declarations (CSS modules, `?react` SVGs, + // `import.meta.env`, …) that the sources it reaches rely on. + "include": ["./index.tsx", "../src/@types/*.d.ts"], + "exclude": [] +} diff --git a/package.json b/package.json index 471cd28b2..fc5560931 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "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": "pnpm build:component:js && pnpm build:component:types", + "build:component:js": "pnpm build:full --config vite-component.config.js", + "build:component:types": "tsc -p component/tsconfig.build.json", "build:component:production": "pnpm build:component", - "build:component:development": "pnpm build:component --mode development", + "build:component:development": "pnpm build:component:js --mode development && pnpm build:component:types", "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/vite-component.config.ts b/vite-component.config.ts index 127f599fa..ae0ac1508 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -38,6 +38,10 @@ export default defineConfig(({ mode }) => { // confined to the element Element Call is mounted in css: { postcss: { plugins: [scopeStylesToRoot()] } }, build: { + // Into the package directory, so that `component/package.json` describes + // what sits next to it and the directory can be installed as a package + // (see its `files` and `exports`). + outDir: "component/dist", minify: mode === "production", sourcemap: true, // One stylesheet rather than one per chunk, so a host has a single file to From 365204d04ca99169ca21c8bb393024196292db76 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 7 Sep 2026 14:50:36 +0200 Subject: [PATCH 31/54] fix media queries in component based element call. --- README.md | 3 + playwright/component/component-call.spec.ts | 82 ++++++++++++++++++++- src/AppBar.module.css | 4 +- src/Header.module.css | 2 +- src/base.css | 12 ++- src/button/ReactionToggleButton.module.css | 2 +- src/components/CallFooter.module.css | 16 ++-- src/grid/OneOnOneMobileLayout.module.css | 2 +- src/grid/SpotlightExpandedLayout.module.css | 2 +- src/room/CallEndedView.module.css | 2 +- src/room/InCallView.module.css | 2 +- src/room/LobbyView.module.css | 4 +- src/room/VideoPreview.module.css | 2 +- 13 files changed, 114 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0732ff419..273846afc 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ logged along the bottom. The call lays itself out for the size of the element it is mounted in, not the window: a host that shrinks the container to a corner of its page gets the picture-in-picture layout, just as a host that shrank the whole iframe used to. +The breakpoints in Element Call's stylesheets are `@container element-call` +queries against its root element for the same reason; for the standalone app +the root is the page, so they mean what the media queries they replaced did. The component's stylesheet is confined to the element it is mounted in: the build rewrites every selector so that it matches only Element Call's root or diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 97a002675..784158adb 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, type Locator, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; +import { SpaHelpers } from "../spa-helpers.ts"; /** * Element Call embedded as a React component, driven through the development @@ -165,3 +166,82 @@ test("lays itself out for the space it is given, not the page", async ({ await resize(900, 700); await expect(call).not.toHaveAttribute("data-layout", "pip"); }); + +/** + * The shape of a call at whatever size it has been given: the layout it chose, + * how much of the height the tile and the footer take, and which controls the + * footer shows. Two calls with the same shape look the same, participants aside. + */ +async function callShape(scope: Page | Locator): Promise<{ + layout: string | null; + tileHeight: number; + footerHeight: number; + buttons: (string | null)[]; +}> { + const call = scope.locator("[data-layout]"); + const footer = scope.getByTestId("footer-container"); + await expect(footer).toBeVisible(); + const tile = scope.getByTestId("videoTile").first(); + await expect(tile).toBeVisible(); + const tileBox = (await tile.boundingBox())!; + const footerBox = (await footer.boundingBox())!; + const buttons = await footer + .getByRole("button") + .filter({ visible: true }) + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("aria-label")), + ); + return { + layout: await call.getAttribute("data-layout"), + tileHeight: Math.round(tileBox.height), + footerHeight: Math.round(footerBox.height), + buttons, + }; +} + +test("looks the same in a small container as in a small window", async ({ + page, + browser, +}) => { + // Two calls to set up, one of them through the harness's two logins + test.setTimeout(240_000); + const size = { width: 300, height: 300 }; + + // The reference is Element Call owning a window of that size, which is what + // a mobile app's webview or a browser's picture-in-picture gives it, and + // what its small-window styling was written for. + const referenceContext = await browser.newContext({ + viewport: size, + ignoreHTTPSErrors: true, + permissions: ["microphone", "camera"], + }); + const referencePage = await referenceContext.newPage(); + await referencePage.goto("/"); + await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true); + const reference = await callShape(referencePage); + await referencePage.screenshot({ + path: test.info().outputPath("small-window.png"), + }); + + // The component gets a container of that size, in a window that is far larger + const { username, roomId } = await createUserAndRoom("smallcontainer"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + await container.evaluate((element, { width, height }) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + }, size); + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 }); + const component = await callShape(pane); + await container.screenshot({ + path: test.info().outputPath("small-container.png"), + }); + await referenceContext.close(); + + // The breakpoints in Element Call's stylesheets are container queries, so a + // small container gets the compact footer a small window does, rather than + // the full-width one the window's own size would call for + expect(component).toEqual(reference); +}); diff --git a/src/AppBar.module.css b/src/AppBar.module.css index faf2b0bf1..1b234a223 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -68,7 +68,7 @@ } /* Hide everything but the subtitle in small windows */ -@media (max-height: 450px) { +@container element-call (max-height: 450px) { .bar { display: none; } @@ -166,7 +166,7 @@ } /* Hide everything but the subtitle in small windows */ - @media (max-height: 450px) { + @container element-call (max-height: 450px) { .bar:has(.subtitle) > header { grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto); grid-template-areas: "." "subtitle"; diff --git a/src/Header.module.css b/src/Header.module.css index f82f5fbd6..07a3e7242 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -107,7 +107,7 @@ Please see LICENSE in the repository root for full details. gap: var(--cpd-space-1-5x); } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .headerLogo, .leftNav.hideMobile, .rightNav.hideMobile { diff --git a/src/base.css b/src/base.css index ae09f4921..d31622fbf 100644 --- a/src/base.css +++ b/src/base.css @@ -33,7 +33,8 @@ the app and for the component build. */ @import url("@fontsource/inconsolata/700.css"); @import url("normalize.css/normalize.css") layer(normalize); -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") +layer(compound); @import url("@vector-im/compound-web/dist/style.css") layer(compound.components); :root { @@ -76,6 +77,15 @@ the app and for the component build. */ --video-tile-background: var(--cpd-color-bg-subtle-secondary); } +/* The breakpoints in Element Call's stylesheets are container queries against +this element rather than media queries against the viewport. For the standalone +app the two are the same thing, since the root is the page; for a host that +embeds Element Call in a corner of its own page they are not, and it is the +corner that the layout has to fit. */ +[data-element-call-root] { + container: element-call / size; +} + .cpd-theme-dark { --cpd-color-border-accent: var(--cpd-color-green-1100); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 705d4d9ed..3be0fd359 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -10,7 +10,7 @@ width: fit-content; } -@media (max-width: 420px) { +@container element-call (max-width: 420px) { .reactionPopupMenu { --reaction-button-padding: 8px; --reaction-button-fontsize: 16px; diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index d919b33eb..e006a5f7d 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details. } /*First hide the logo*/ -@media (max-width: 750px) { +@container element-call (max-width: 750px) { .logo { display: none; } @@ -94,7 +94,7 @@ Please see LICENSE in the repository root for full details. With the logo hidden >500px is enough space to show overflow, buttons, layout. Once we exceed 500 we hide everything except the buttons. */ -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .footer { grid-template-areas: "buttons buttons buttons"; } @@ -115,27 +115,27 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-height: 800px) { +@container element-call (max-height: 800px) { .footer { padding-block: var(--cpd-space-8x) calc(env(safe-area-inset-bottom) + var(--cpd-space-8x)); } } -@media (max-height: 400px) { +@container element-call (max-height: 400px) { .footer { padding-block: var(--cpd-space-4x) calc(env(safe-area-inset-bottom) + var(--cpd-space-4x)); } } -@media (max-width: 370px) { +@container element-call (max-width: 370px) { .shareScreen { display: none; } /* PIP custom css */ - @media (max-height: 400px) { + @container element-call (max-height: 400px) { .shareScreen { display: flex; } @@ -148,13 +148,13 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .raiseHand { display: none; } } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .buttons { gap: var(--cpd-space-4x); } diff --git a/src/grid/OneOnOneMobileLayout.module.css b/src/grid/OneOnOneMobileLayout.module.css index e781726c9..d07520f55 100644 --- a/src/grid/OneOnOneMobileLayout.module.css +++ b/src/grid/OneOnOneMobileLayout.module.css @@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details. block-size: 140px; } -@media (max-width: 600px) { +@container element-call (max-width: 600px) { /* Give the PiP a portrait aspect ratio */ .pip[data-size="sm"] { inline-size: 88px; diff --git a/src/grid/SpotlightExpandedLayout.module.css b/src/grid/SpotlightExpandedLayout.module.css index d765c6fce..570b62662 100644 --- a/src/grid/SpotlightExpandedLayout.module.css +++ b/src/grid/SpotlightExpandedLayout.module.css @@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details. var(--content-inset-left); } -@media (min-width: 600px) { +@container element-call (min-width: 600px) { .pip { inline-size: 180px; block-size: 135px; diff --git a/src/room/CallEndedView.module.css b/src/room/CallEndedView.module.css index e62e93d0c..7b2dbee06 100644 --- a/src/room/CallEndedView.module.css +++ b/src/room/CallEndedView.module.css @@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details. margin-bottom: 44px; } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .logo { display: none; } diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 736a915a2..3c393cd6d 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. * background: none; } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .invite { display: none; } diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css index b66d483cc..b112cdf20 100644 --- a/src/room/LobbyView.module.css +++ b/src/room/LobbyView.module.css @@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details. color: var(--cpd-color-theme-primary) !important; } -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .join { width: 100%; } } -@media (min-height: 650px) { +@container element-call (min-height: 650px) { .content { gap: var(--cpd-space-10x); } diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index 67eae10bb..44c43faf2 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -71,7 +71,7 @@ video.mirror { ); } -@media (max-width: 550px) { +@container element-call (max-width: 550px) { .preview { margin-inline: 0; border-radius: 0; From 489c1af4265a6a053e21be36c4ae38d913caf7c7 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 7 Sep 2026 17:35:25 +0200 Subject: [PATCH 32/54] test resizing via @container --- playwright/component/component-call.spec.ts | 41 ++++++++++++--------- playwright/component/harness.ts | 16 ++++++++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 784158adb..54e1a22a9 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details. import { expect, type Locator, type Page, test } from "@playwright/test"; -import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; +import { + createUserAndRoom, + expectWithin, + resizeContainer, + startHarness, +} from "./harness.ts"; import { SpaHelpers } from "../spa-helpers.ts"; /** @@ -58,6 +63,10 @@ test("keeps its modals inside the container it was given", async ({ page }) => { const pane = panes.first(); const container = pane.getByTestId("call-container"); + // In the flat container the harness gives it by default, Element Call hides + // its controls a few seconds after the call starts, as it would in a flat + // window. A full-size container keeps them on screen to be clicked. + await resizeContainer(container, { width: 900, height: 640 }); await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); await expect(pane.getByTestId("footer-container")).toBeVisible({ timeout: 60_000, @@ -151,19 +160,10 @@ test("lays itself out for the space it is given, not the page", async ({ // a host wanting a picture-in-picture made the iframe small, and Element Call // saw the window shrink. A component gets no such signal from the window, // which stays as large as it ever was; only the container changes. - const resize = async (width: number, height: number): Promise => - container.evaluate( - (element, size) => { - element.style.width = `${size.width}px`; - element.style.height = `${size.height}px`; - }, - { width, height }, - ); - - await resize(300, 300); + await resizeContainer(container, { width: 300, height: 300 }); await expect(call).toHaveAttribute("data-layout", "pip"); - await resize(900, 700); + await resizeContainer(container, { width: 900, height: 700 }); await expect(call).not.toHaveAttribute("data-layout", "pip"); }); @@ -181,12 +181,14 @@ async function callShape(scope: Page | Locator): Promise<{ const call = scope.locator("[data-layout]"); const footer = scope.getByTestId("footer-container"); await expect(footer).toBeVisible(); + // The tile arrives with the media connection, which can take a while const tile = scope.getByTestId("videoTile").first(); - await expect(tile).toBeVisible(); + await expect(tile).toBeVisible({ timeout: 60_000 }); const tileBox = (await tile.boundingBox())!; const footerBox = (await footer.boundingBox())!; + // Buttons and switches alike: the mute controls are switches const buttons = await footer - .getByRole("button") + .locator("button") .filter({ visible: true }) .evaluateAll((elements) => elements.map((element) => element.getAttribute("aria-label")), @@ -228,10 +230,7 @@ test("looks the same in a small container as in a small window", async ({ const panes = await startHarness(page, username, roomId); const pane = panes.first(); const container = pane.getByTestId("call-container"); - await container.evaluate((element, { width, height }) => { - element.style.width = `${width}px`; - element.style.height = `${height}px`; - }, size); + await resizeContainer(container, size); await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 }); const component = await callShape(pane); @@ -244,4 +243,10 @@ test("looks the same in a small container as in a small window", async ({ // small container gets the compact footer a small window does, rather than // the full-width one the window's own size would call for expect(component).toEqual(reference); + // And that footer is the compact one: a single row of controls, not the + // full-height bar with its logo and layout switch that a large window gets + expect(component.footerHeight).toBeLessThan(size.height / 3); + expect(component.tileHeight + component.footerHeight).toBeLessThanOrEqual( + size.height, + ); }); diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts index 4602f22d8..8e1a6e589 100644 --- a/playwright/component/harness.ts +++ b/playwright/component/harness.ts @@ -108,3 +108,19 @@ export async function expectWithin( outerBox.y + outerBox.height + slack, ); } + +/** + * Gives one of the harness's containers a new size. Element Call lays itself + * out for the size of its container, so this is how a test puts it into a + * particular mode: a flat or narrow one, a picture-in-picture, or a full-size + * window, without the window itself changing at all. + */ +export async function resizeContainer( + container: Locator, + size: { width: number; height: number }, +): Promise { + await container.evaluate((element, { width, height }) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + }, size); +} From a010cc983f588b602cf67baecd267a71d5bc571e Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:49:19 +0200 Subject: [PATCH 33/54] Compare the component's config by value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything downstream of the component's params — the mute state, the call view model and with it the media connection — is keyed on the identity of the params object, which was memoised on the identity of the `config` prop. A host writing `config={{ ... }}` inline, which is the natural way to write it, therefore tore the whole call down on every render. The harness happened to pass a constant, so nothing noticed. `useStableValue` hands out the same object for as long as a deep comparison says nothing changed, so an inline config costs nothing. Co-Authored-By: Claude Fable 5.1 --- component/index.tsx | 14 +++++++-- src/useStableValue.test.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/useStableValue.ts | 29 +++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/useStableValue.test.ts create mode 100644 src/useStableValue.ts diff --git a/component/index.tsx b/component/index.tsx index 84a18353c..b58782be0 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -66,6 +66,7 @@ import { type ConfigOptions } from "../src/config/ConfigOptions"; import { i18n } from "../src/utils/i18n"; import { useTheme } from "../src/useTheme"; import { useInitial } from "../src/useInitial"; +import { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; // Everything needed to implement a HostBridge, not just the interface itself @@ -115,6 +116,9 @@ export interface ElementCallProps { * How Element Call should behave, overriding whatever {@link intent} implies. * A host that finds itself setting a lot of these probably wants a different * intent instead. + * + * Compared by value, so it is fine to write this inline; only a change to + * what it says restarts anything. */ config?: ElementCallConfiguration; /** @@ -180,14 +184,20 @@ export const ElementCall: FC = ({ // 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 => ({ ...hostedProperties, roomId, ...configurationForIntent(intent), - ...config, + ...stableConfig, }), - [roomId, intent, config], + [roomId, intent, stableConfig], ); const mediaDevices = useInitial( diff --git a/src/useStableValue.test.ts b/src/useStableValue.test.ts new file mode 100644 index 000000000..8759cbafd --- /dev/null +++ b/src/useStableValue.test.ts @@ -0,0 +1,64 @@ +/* +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 { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { useStableValue } from "./useStableValue"; + +describe("useStableValue", () => { + test("keeps the first identity while the contents stay equal", () => { + const first = { skipLobby: true, fonts: ["Inter"] }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: first } }, + ); + expect(result.current).toBe(first); + + rerender({ value: { skipLobby: true, fonts: ["Inter"] } }); + expect(result.current).toBe(first); + }); + + test("takes the new identity once the contents change", () => { + const first = { skipLobby: true }; + const second = { skipLobby: false }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: first } }, + ); + + rerender({ value: second }); + expect(result.current).toBe(second); + + // And that identity is then the stable one + rerender({ value: { skipLobby: false } }); + expect(result.current).toBe(second); + }); + + test("handles undefined, for an optional prop left out", () => { + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: undefined as { a: number } | undefined } }, + ); + expect(result.current).toBeUndefined(); + + const given = { a: 1 }; + rerender({ value: given }); + expect(result.current).toBe(given); + }); + + test("accepts its own notion of equality", () => { + const first = { id: 1, label: "a" }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value, (a, b) => a.id === b.id), + { initialProps: { value: first } }, + ); + + rerender({ value: { id: 1, label: "b" } }); + expect(result.current).toBe(first); + }); +}); diff --git a/src/useStableValue.ts b/src/useStableValue.ts new file mode 100644 index 000000000..890832166 --- /dev/null +++ b/src/useStableValue.ts @@ -0,0 +1,29 @@ +/* +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 { useState } from "react"; +import { isEqual } from "lodash-es"; + +/** + * Returns a value whose identity only changes when its contents do. + * + * For a prop that a caller is likely to write inline — an options object, say + * — so that a fresh but equal object on every render does not restart whatever + * depends on it. Deep equality by default. + */ +export function useStableValue( + value: T, + equals: (a: T, b: T) => boolean = isEqual, +): T { + const [stable, setStable] = useState(value); + if (equals(stable, value)) return stable; + // Setting state during render makes React re-run this render immediately + // with the new state, at which point the two are identical and the stored + // one is returned — so the identity handed out is consistent. + setStable(value); + return value; +} From 608f107f8d59a5be925467238023b95b745fa7b2 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:50:17 +0200 Subject: [PATCH 34/54] End the component's media device scope on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component built its `MediaDevices` once, in a scope nothing ever ended, so every mount left device observers running for the rest of the page's life. Building it in an effect ties the scope to the component's lifetime — and to the options it was built with, which were previously frozen at first render. Co-Authored-By: Claude Fable 5.1 --- component/index.tsx | 79 +++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/component/index.tsx b/component/index.tsx index b58782be0..a64cee655 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -32,7 +32,14 @@ Please see LICENSE in the repository root for full details. // sits in a `@layer`, which loses to unlayered rules either way. import "../src/base.css"; -import { type FC, type JSX, type ReactNode, useMemo, useState } from "react"; +import { + type FC, + type JSX, + type ReactNode, + useEffect, + 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"; @@ -65,7 +72,6 @@ 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 { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; @@ -200,13 +206,22 @@ export const ElementCall: FC = ({ [roomId, intent, stableConfig], ); - const mediaDevices = useInitial( - () => - new MediaDevices(new ObservableScope(), { - controlledAudioDevices: params.controlledAudioDevices, - callIntent: params.callIntent, - }), - ); + // 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( @@ -227,28 +242,30 @@ export const ElementCall: FC = ({ embedded cannot disturb the host's URL. */}
    - {container !== null && rtcSession !== null && ( - - - - - - - - - - - - - - )} + {container !== null && + rtcSession !== null && + mediaDevices !== null && ( + + + + + + + + + + + + + + )}
    From bc58aed0d7443bc5cbdcf22a56d6cbd4efa4339f Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:52:34 +0200 Subject: [PATCH 35/54] Keep keyboard shortcuts within Element Call's root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call's shortcuts were listened for on the window and allowed whenever focus was inside the standalone app's `#root` — which, for a component embedded in a host, is the host's own root, or nothing. So m, v and space fired while the user typed in the host's composer, and two Element Calls on one page both answered every key. Listen on the element Element Call treats as its root instead (the body standalone, so nothing changes there), and judge whether a key press is spoken for by what has focus — a dialog or a text field — rather than by where it sits in the DOM, since the modals are now portalled to that same root. Co-Authored-By: Claude Fable 5.1 --- src/useCallViewKeyboardShortcuts.test.tsx | 63 +++++++++++++++++- src/useCallViewKeyboardShortcuts.ts | 80 ++++++++++++++--------- 2 files changed, 109 insertions(+), 34 deletions(-) diff --git a/src/useCallViewKeyboardShortcuts.test.tsx b/src/useCallViewKeyboardShortcuts.test.tsx index b002c23e9..fddba5039 100644 --- a/src/useCallViewKeyboardShortcuts.test.tsx +++ b/src/useCallViewKeyboardShortcuts.test.tsx @@ -18,6 +18,7 @@ import { ReactionsRowSize, } from "./reactions"; import { type Controls } from "./controls"; +import { RootElementProvider } from "./RootElementContext"; // Test Explanation: // - The main objective is to test `useCallViewKeyboardShortcuts`. @@ -48,10 +49,11 @@ const TestComponent: FC = ({ ); return ( <> -
    +
    - {/*// modal lives outside of the root*/} + {/* A dialog, which is what claims key presses for itself; where it + lives in the DOM does not matter */} {modalOpen && ( { // container element that can be interactive and receive focus / keydown // events.
    @@ -333,6 +356,7 @@ export const Harness: FC = (): ReactNode => { key={session.label} session={session} roomId={state.roomId} + language={language} log={log} /> ))} diff --git a/component/index.tsx b/component/index.tsx index 41c584830..90532966f 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -50,6 +50,8 @@ 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"; @@ -81,6 +83,10 @@ import { type ElementCallHostBridge, useComponentHostBridge, } from "./host"; +import { supportedLanguages, translationsBackend } from "./localization"; + +// The languages Element Call can be shown in +export { supportedLanguages } from "./localization"; // How the host and Element Call talk to each other, and what they say export { type ElementCallHandle, type ElementCallHostBridge } from "./host"; @@ -152,6 +158,15 @@ export interface ElementCallProps { * Available once the component has rendered. */ ref?: Ref; + /** + * The language to show Element Call in, as a BCP 47 tag: one of + * {@link supportedLanguages}, or something that falls back to one (`de-AT` + * to `de`). Left out, the browser's language is used. + * + * Translations are one thing shared by every Element Call on the page, so + * the most recently set language wins for all of them. + */ + language?: string; } /** @@ -171,22 +186,29 @@ export async function initializeElementCall( await Promise.all(polyfills); Config.initWith(config); - await i18n.init({ - fallbackLng: "en", - defaultNS: "app", - keySeparator: ".", - nsSeparator: false, - pluralSeparator: "_", - contextSeparator: "|", - lng: "en", - interpolation: { escapeValue: false }, - // English only, bundled in. The standalone app fetches its locale files at - // runtime from URLs its own build emits, which a host serving the library - // from elsewhere could not resolve; bundling one language at least keeps - // the component self-contained. Letting a host supply the rest, or its own - // translations, is still to do. - resources: { en: { app: EN } }, - }); + 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. */ @@ -207,9 +229,17 @@ export const ElementCall: FC = ({ config, hostBridge: suppliedHostBridge, ref, + language, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref); + 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); diff --git a/component/localization.test.ts b/component/localization.test.ts new file mode 100644 index 000000000..fae1c7065 --- /dev/null +++ b/component/localization.test.ts @@ -0,0 +1,46 @@ +/* +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 { describe, expect, test } from "vitest"; + +import { supportedLanguages, translationsBackend } from "./localization"; + +const read = async ( + language: string, + namespace = "app", +): Promise> => + await new Promise((resolve, reject) => + translationsBackend.read(language, namespace, (error, data) => { + if (error) reject(error); + else resolve(data as Record); + }), + ); + +describe("component translations", () => { + test("offer every language in locales/, tagged as its directory is", () => { + expect(supportedLanguages).toContain("en"); + expect(supportedLanguages).toContain("de"); + expect(supportedLanguages).toContain("zh-Hans"); + expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length); + }); + + test("load a language's translations on demand", async () => { + const de = await read("de"); + expect(de).toHaveProperty("action"); + expect(de).not.toEqual(await read("en")); + }); + + test("refuse a language there are no translations for", async () => { + await expect(read("xx")).rejects.toThrow("No app translations for xx"); + }); + + test("refuse a namespace there are no translations for", async () => { + await expect(read("en", "other")).rejects.toThrow( + "No other translations for en", + ); + }); +}); diff --git a/component/localization.ts b/component/localization.ts new file mode 100644 index 000000000..b64172818 --- /dev/null +++ b/component/localization.ts @@ -0,0 +1,55 @@ +/* +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. +*/ + +/** + * Translations for Element Call as a component. + * + * The standalone app fetches its locale files at runtime from URLs its own + * build emits, which a host serving the library from somewhere else could not + * resolve. The component instead has the bundler split every locale into a + * chunk of its own, loaded the first time its language is asked for; English, + * the fallback, is bundled in so that the first paint never waits for it. + */ + +import { type BackendModule, type ResourceKey } from "i18next"; + +import { languageOfLocalePath } from "../src/utils/i18n"; + +/** Every locale, as a lazily imported module. */ +const translations = import.meta.glob<{ default: ResourceKey }>( + "../locales/*/app.json", +); + +/** + * The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`, + * `zh-Hans` and so on. A language that is not one of these falls back to its + * base language where there is one (`de-AT` to `de`), and to English otherwise. + */ +export const supportedLanguages: readonly string[] = [ + ...new Set(Object.keys(translations).map(languageOfLocalePath)), +]; + +/** Loads translations on demand. */ +export const translationsBackend: BackendModule = { + type: "backend", + init(): void {}, + read(language: string, namespace: string, callback): void { + const load = translations[`../locales/${language}/${namespace}.json`]; + if (load === undefined) { + callback(new Error(`No ${namespace} translations for ${language}`), null); + return; + } + load().then( + (module) => callback(null, module.default), + (error: unknown) => + callback( + error instanceof Error ? error : new Error(String(error)), + null, + ), + ); + }, +}; diff --git a/src/initializer.tsx b/src/initializer.tsx index 253dcbc41..76a7fcc39 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -37,7 +37,7 @@ import { type AnalyticsConfig, PosthogAnalytics, } from "./analytics/PosthogAnalytics.ts"; -import { i18n } from "./utils/i18n.ts"; +import { i18n, languageOfLocalePath } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { @@ -56,17 +56,7 @@ const getLocaleUrl = ( ): string | undefined => locales[`../locales/${language}/${namespace}.json`]; const supportedLngs = [ - ...new Set( - Object.keys(locales).map((url) => { - // The URLs are of the form ../locales/en/app.json - // This extracts the language code from the URL - const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; - if (!lang) { - throw new Error(`Could not parse locale URL ${url}`); - } - return lang; - }), - ), + ...new Set(Object.keys(locales).map(languageOfLocalePath)), ]; // A backend that fetches the locale files from the URLs generated by the glob above diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 65dca697f..0796ab380 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -10,6 +10,17 @@ import i18next, { type i18n as I18nInstance } from "i18next"; // Custom marker function to allow i18next extraction export const i18nKey = (key: string): string => key; +/** + * The language a locale file under `locales/` is for, from its path as a + * bundler glob reports it: `../locales/zh-Hans/app.json` is for `zh-Hans`. + */ +export function languageOfLocalePath(path: string): string { + const language = path.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; + if (language === undefined) + throw new Error(`Could not parse locale path ${path}`); + return language; +} + /** * Element Call's own i18next instance. * From b722cc277e77bafa79bc00ea130e58c2cc58c083 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 17:02:24 +0200 Subject: [PATCH 48/54] Make the theme a prop, next to the language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme is state — what Element Call should look like right now — and so belongs beside `language` as a prop, not on the imperative handle (where it was a request, `setTheme`, because the internal host bridge speaks the widget API and a widget's host sends theme changes as requests) and not in the configuration (where `config.theme` only ever set the starting theme). The `theme` prop feeds the same channel the rest of Element Call listens to for a host's theme, replayed so that whatever subscribes after the host has set it still hears the current one. Changing it re-themes the container and nothing else; unlike the language, it is per component. `setTheme` and `config.theme` are gone, and the harness gets a theme picker in place of its per-pane buttons. Co-Authored-By: Claude Fable 5.1 --- README.md | 3 ++- component/dev/Harness.tsx | 31 +++++++++++----------- component/host.test.ts | 56 ++++++++++++++++++++++++++++----------- component/host.ts | 22 ++++++++++----- component/index.tsx | 25 ++++++++++------- 5 files changed, 89 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b1c462759..b27602906 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,8 @@ The component speaks every language the app does. English is bundled in; the other locales are split into chunks the host's bundler loads the first time they are needed. It starts in the browser's language, and follows the host's own language setting through the `language` prop (`supportedLanguages` lists -the tags it accepts). +the tags it accepts). The `theme` prop works the same way for `light` and +`dark`; both can change while a call is running without disturbing it. The package is not published yet. A host installs it as a git dependency on the `component` directory of this repository, diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index 9e40e2c7a..ded9155a9 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -94,9 +94,10 @@ interface LogEntry { const Pane: FC<{ session: Session; roomId: string; + theme: string | undefined; language: string | undefined; log: (pane: string, message: string) => void; -}> = ({ session, roomId, language, log }): ReactNode => { +}> = ({ session, roomId, theme, language, log }): ReactNode => { const [mounted, setMounted] = useState(true); const bridge = useMemo( @@ -139,20 +140,6 @@ const Pane: FC<{ - - +