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
{session.client.getDeviceId()}
+
+
+
+ This should cover the calls completely.
+ +{state.roomId}
+
+
+
+ {entry.at} {entry.pane}{" "}
+ {entry.message}
+
{t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts
new file mode 100644
index 000000000..3eda39f51
--- /dev/null
+++ b/src/RootElementContext.ts
@@ -0,0 +1,43 @@
+/*
+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; as a component
+ * it is the container the host mounted it into, so that Element Call does not
+ * reach outside its own subtree.
+ *
+ * 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.
+ */
+const RootElementContext = createContext
{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 (
@@ -216,7 +163,6 @@ export const RoomPage: FC = (): ReactNode => {
{groupCallState.error.messageBody} {t("developer_mode.environment_variables")} {t("developer_mode.url_params")}
Local Participant
@@ -417,7 +421,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
remote
)
Local Participant
@@ -674,7 +682,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
Environment variables
URL parameters
{JSON.stringify(env, null, 2)}
+ {JSON.stringify(env, null, 2)}
{JSON.stringify(urlParams, null, 2)}
+ {JSON.stringify(urlParams, null, 2)}
>
);
};
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx
index b2ffef4ab..933ac66f5 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
+
{
"region": "local",
"version": "1.2.3"
@@ -390,7 +392,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
localParticipantIdentity
+
{
"region": "remote",
"version": "4.5.6"
@@ -427,7 +433,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
localParticipantIdentity
+
{
"MY_MOCK_ENV": 10,
"ENV": "test"
@@ -683,7 +693,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
{
"mocked": true,
"answer": 42
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..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 {
@@ -1593,12 +1592,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 },
@@ -1700,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 aa88e6115..957a56fad 100644
--- a/src/state/CallViewModel/CallViewModel.ts
+++ b/src/state/CallViewModel/CallViewModel.ts
@@ -45,8 +45,9 @@ 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";
import { v4 as uuidv4 } from "uuid";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
@@ -87,9 +88,9 @@ 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, type UrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
-import { ElementWidgetActions, widget } from "../../widget";
+import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
layoutShallowEquals,
type Alignment,
@@ -172,6 +173,28 @@ 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.
+ */
+ 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
@@ -182,8 +205,15 @@ export interface CallViewModelOptions {
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
connectionState$?: Behavior