{children}
>
@@ -107,6 +145,36 @@ export function useAppBarTitle(title: string): void {
}, [title, setTitle]);
}
+/**
+ * React hook which sets the subtitle to be shown in the app bar, if present. It
+ * is an error to call this hook from multiple sites in the same component tree.
+ */
+export function useAppBarSubtitle(subtitle: ReactNode): void {
+ const setSubtitle = use(AppBarContext)?.setSubtitle;
+ useEffect(() => {
+ if (setSubtitle !== undefined) {
+ setSubtitle(subtitle);
+ return (): void => setSubtitle("");
+ }
+ }, [subtitle, setSubtitle]);
+}
+
+/**
+ * React hook which sets the primary button icon kind. Can only be "minimise" or "back"
+ * It is an error to call this hook from multiple sites in the same component tree.
+ */
+export function useAppBarPrimaryButtonIconKind(
+ icon: "back" | "minimise",
+): void {
+ const setIconKind = use(AppBarContext)?.setPrimaryButtonIconKind;
+ useEffect(() => {
+ if (setIconKind !== undefined) {
+ setIconKind(icon);
+ return (): void => setIconKind("minimise");
+ }
+ }, [setIconKind, icon]);
+}
+
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx
index 41e6cb16..eb84010e 100644
--- a/src/FullScreenView.tsx
+++ b/src/FullScreenView.tsx
@@ -47,7 +47,7 @@ export const FullScreenView: FC = ({
};
interface ErrorPageProps {
- error: Error | unknown;
+ error: unknown;
widget: WidgetHelpers | null;
}
diff --git a/src/Slider.tsx b/src/Slider.tsx
index c6520e42..29f9ef42 100644
--- a/src/Slider.tsx
+++ b/src/Slider.tsx
@@ -31,6 +31,11 @@ interface Props {
max: number;
step: number;
disabled?: boolean;
+ /**
+ * Custom formatter for the tooltip label. If not provided, the value is
+ * displayed as a percentage.
+ */
+ tooltipFormatter?: (value: number) => string;
}
/**
@@ -46,6 +51,7 @@ export const Slider: FC = ({
max,
step,
disabled,
+ tooltipFormatter,
}) => {
const onValueChange = useCallback(
([v]: number[]) => onValueChangeProp(v),
@@ -71,7 +77,14 @@ export const Slider: FC = ({
{/* Note: This is expected not to be visible on mobile.*/}
-
+
diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts
index 4ca3ef7d..47da4cbc 100644
--- a/src/UrlParams.test.ts
+++ b/src/UrlParams.test.ts
@@ -16,12 +16,15 @@ import {
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
+import { mockConfig } from "./utils/test";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "!d45f138fsd";
const ORIGIN = "https://call.element.io";
const HOMESERVER = "localhost";
+mockConfig();
+
describe("UrlParams", () => {
describe("handles URL with /room/", () => {
it("and nothing else", () => {
diff --git a/src/UrlParams.ts b/src/UrlParams.ts
index ec3d5553..cc8a53d1 100644
--- a/src/UrlParams.ts
+++ b/src/UrlParams.ts
@@ -19,6 +19,7 @@ import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
+import { redact } from "./utils/redact";
interface RoomIdentifier {
roomAlias: string | null;
@@ -44,6 +45,11 @@ export enum HeaderStyle {
AppBar = "app_bar",
}
+export enum BackgroundStyle {
+ Solid = "solid",
+ Gradient = "gradient",
+}
+
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
@@ -144,6 +150,10 @@ export interface UrlProperties {
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
+ /**
+ * The visual style of the page background.
+ */
+ background: BackgroundStyle;
}
/**
@@ -457,6 +467,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
theme: parser.getParam("theme"),
+ background:
+ parser.getEnumParam("background", BackgroundStyle) ??
+ BackgroundStyle.Gradient,
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
posthogApiHost: parser.getParam("posthogApiHost"),
@@ -501,7 +514,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"intent:",
intent,
"\nproperties:",
- properties,
+ redact(properties, "password"),
"configuration:",
configuration,
);
diff --git a/src/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap
index 0df18767..99b322ac 100644
--- a/src/__snapshots__/AppBar.test.tsx.snap
+++ b/src/__snapshots__/AppBar.test.tsx.snap
@@ -3,44 +3,90 @@
exports[`AppBar > renders 1`] = `
-
-
+
+
+
+
+
+
+
+
+ This is the content.
+
+
+`;
+
+exports[`AppBar > renders with title and subtitle 1`] = `
+
+
+
+
+
+
+
+
+
+ Title
+
+
+ Subtitle
+
+
diff --git a/src/__snapshots__/Modal.test.tsx.snap b/src/__snapshots__/Modal.test.tsx.snap
index 648bdfd5..4753701e 100644
--- a/src/__snapshots__/Modal.test.tsx.snap
+++ b/src/__snapshots__/Modal.test.tsx.snap
@@ -3,7 +3,7 @@
exports[`the content is rendered when the modal is open 1`] = `
This is the content.
@@ -37,7 +37,7 @@ exports[`the content is rendered when the modal is open 1`] = `
exports[`the modal renders as a drawer in mobile viewports 1`] = `
This is the content.
diff --git a/src/__snapshots__/QrCode.test.tsx.snap b/src/__snapshots__/QrCode.test.tsx.snap
index 701f427b..484ad72c 100644
--- a/src/__snapshots__/QrCode.test.tsx.snap
+++ b/src/__snapshots__/QrCode.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`QrCode > renders 1`] = `
renders 1`] = `
svg {
- color: var(--stopgap-color-on-solid-accent);
-}
-
.rotate > svg {
animation: spin 1s linear infinite;
}
diff --git a/src/button/Button.tsx b/src/button/Button.tsx
index e639e76e..b8d052d6 100644
--- a/src/button/Button.tsx
+++ b/src/button/Button.tsx
@@ -135,21 +135,12 @@ interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
}
-export const EndCallButton: FC = ({
- className,
- ...props
-}) => {
+export const EndCallButton: FC = (props) => {
const { t } = useTranslation();
return (
-
+
);
};
@@ -173,7 +164,7 @@ export const LoudspeakerButton: FC = ({
iconOnly
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
{...props}
- kind={loudspeakerModeEnabled ? "primary" : "secondary"}
+ kind={loudspeakerModeEnabled ? "secondary" : "primary"}
aria-checked={loudspeakerModeEnabled}
/>
diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
index a1e319d9..5db4582c 100644
--- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
+++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_1j_"
- class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
+ class="_button_1nw83_8 _raisedButton_fb25ab _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css
index c25b21ef..bd7b081b 100644
--- a/src/components/CallFooter.module.css
+++ b/src/components/CallFooter.module.css
@@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details.
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
padding-block: var(--cpd-space-10x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
- background: linear-gradient(
- 180deg,
- rgba(0, 0, 0, 0) 0%,
- var(--cpd-color-bg-canvas-default) 100%
- );
}
.footer.hidden {
diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx
index a6b509fa..667cb607 100644
--- a/src/components/CallFooter.stories.tsx
+++ b/src/components/CallFooter.stories.tsx
@@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
-import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
+import { constant } from "../state/Behavior";
+import { type LayoutMode } from "../state/LayoutSwitchViewModel";
+
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
@@ -32,6 +34,7 @@ const mediaDevices = new MediaDevices(globalScope);
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
+ * - constructing the layout switch view model
* - Add additional react context
* The paraeters are all params from the FooterSnapshot,
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
@@ -40,11 +43,18 @@ const mediaDevices = new MediaDevices(globalScope);
*/
function CallFooterStoryWrapper({
children,
+ layout,
+ setLayout,
...vmSnapshot
-}: FooterSnapshot & {
+}: Omit & {
children?: false | JSX.Element | JSX.Element[] | undefined;
+ layout: LayoutMode | null;
+ setLayout: (value: LayoutMode) => void;
}): ReactNode {
- const vm = useStaticViewModel(vmSnapshot);
+ const vm = useStaticViewModel({
+ ...vmSnapshot,
+ layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
+ });
return (
@@ -62,28 +72,50 @@ function CallFooterStoryWrapper({
);
}
-const meta = {
- component: CallFooterStoryWrapper,
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
const fnArgType = {
control: { type: "select" as const },
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
+const meta = {
+ component: CallFooterStoryWrapper,
+ argTypes: {
+ layout: {
+ control: "radio",
+ options: ["grid", "spotlight"] satisfies LayoutMode[],
+ },
+ audioOutputSwitcher: {
+ control: "select",
+ options: ["NoOutputCallback", "speaker", "earpiece"],
+ table: { defaultValue: { summary: "NoOutputCallback" } },
+ mapping: {
+ NoOutputCallback: undefined,
+ // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
+ speaker: { targetOutput: "earpiece", switch: fn() },
+ earpiece: { targetOutput: "speaker", switch: fn() },
+ },
+ },
+ toggleScreenSharing: fnArgType,
+ openSettings: fnArgType,
+ toggleAudio: fnArgType,
+ toggleVideo: fnArgType,
+ hangup: fnArgType,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
export const Default: Story = {
args: {
showLogo: false,
- layoutMode: "grid",
+ layout: "grid",
+ setLayout: fn(),
audioEnabled: true,
audioBusy: false,
videoEnabled: true,
videoBusy: false,
- setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),
toggleVideo: fn(),
@@ -95,6 +127,7 @@ export const Default: Story = {
showFooter: true,
hideControls: false,
asOverlay: false,
+ showModals: true,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
@@ -111,29 +144,6 @@ export const Default: Story = {
parameters: {
layout: "fullscreen",
},
- argTypes: {
- layoutMode: {
- control: "radio",
- options: ["grid", "spotlight"] satisfies GridMode[],
- },
- audioOutputSwitcher: {
- control: "select",
- options: ["NoOutputCallback", "speaker", "earpiece"],
- table: { defaultValue: { summary: "NoOutputCallback" } },
- mapping: {
- NoOutputCallback: undefined,
- // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
- speaker: { targetOutput: "earpiece", switch: fn() },
- earpiece: { targetOutput: "speaker", switch: fn() },
- },
- },
- toggleScreenSharing: fnArgType,
- setLayoutMode: fnArgType,
- openSettings: fnArgType,
- toggleAudio: fnArgType,
- toggleVideo: fnArgType,
- hangup: fnArgType,
- },
};
export const WithAudioAndVideoOptions: Story = {
@@ -194,7 +204,7 @@ export const AudioVideoEnabled: Story = {
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
- await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
+ await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
@@ -225,14 +235,14 @@ export const SpotlightMode: Story = {
...Default,
args: {
...Default.args,
- layoutMode: "spotlight",
+ layout: "spotlight",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio);
- await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
+ await expect(args.setLayout).toHaveBeenCalledWith("grid");
},
};
@@ -264,7 +274,7 @@ export const Pip: Story = {
args: {
...Default.args,
buttonSize: "md",
- layoutMode: undefined,
+ layout: null,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
@@ -348,7 +358,7 @@ export const Lobby: Story = {
...Default.args,
showLogo: false,
openSettings: undefined,
- setLayoutMode: undefined,
+ layout: null,
toggleScreenSharing: undefined,
},
parameters: {
@@ -362,7 +372,7 @@ export const LobbyMobile: Story = {
...Default.args,
showLogo: false,
- setLayoutMode: undefined,
+ layout: null,
toggleScreenSharing: undefined,
},
globals: {
@@ -379,7 +389,7 @@ export const LobbyRecentButton: Story = {
...Default.args,
children: Back To Recents,
showLogo: false,
- setLayoutMode: undefined,
+ layout: null,
toggleScreenSharing: undefined,
},
parameters: {
@@ -393,7 +403,7 @@ export const LobbyRecentButtonMobile: Story = {
...Default.args,
children: Back To Recents,
showLogo: false,
- setLayoutMode: undefined,
+ layout: null,
toggleScreenSharing: undefined,
},
globals: {
diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx
index f952601d..4f79f236 100644
--- a/src/components/CallFooter.tsx
+++ b/src/components/CallFooter.tsx
@@ -7,12 +7,6 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
-import {
- SpotlightIcon,
- GridIcon,
-} from "@vector-im/compound-design-tokens/assets/web/icons";
-import { Switch } from "@vector-im/compound-web";
-import { t } from "i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -28,13 +22,15 @@ import {
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
-import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
+import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
+import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
+import { LayoutSwitch } from "../room/LayoutSwitch";
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -61,8 +57,6 @@ export interface FooterActions {
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
- /** Also controls if the layout button is visible */
- setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
@@ -83,11 +77,13 @@ export interface FooterState {
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay: boolean;
+ showModals: boolean;
buttonSize: "md" | "lg";
showLogo: boolean;
- layoutMode: GridMode | undefined;
+ /** Also controls if the layout switch is visible */
+ layoutSwitchVm: LayoutSwitchViewModel | null;
sharingScreen: boolean;
@@ -112,16 +108,22 @@ export interface FooterState {
}
export interface FooterProps {
+ className?: string;
ref?: Ref;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel;
}
-export const CallFooter: FC = ({ ref, children, vm }) => {
+export const CallFooter: FC = ({
+ className,
+ ref,
+ children,
+ vm,
+}) => {
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
- const layoutMode = useBehavior(vm.layoutMode$);
- const setLayoutMode = useBehavior(vm.setLayoutMode$);
+ const showModals = useBehavior(vm.showModals$);
+ const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const audioBusy = useBehavior(vm.audioBusy$);
@@ -136,7 +138,6 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
- const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
@@ -236,7 +237,8 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
);
}
- if (reactionIdentifier && reactionData) {
+ // Reaction button contains a pretty large menu, so treat it like a modal
+ if (reactionIdentifier && reactionData && showModals) {
buttons.push(
= ({ ref, children, vm }) => {
/>
>
)}
- {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
+ {debugTileLayout ? (
+
+ ) : undefined}
);
@@ -292,7 +296,7 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
renders 1`] = `
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
- class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
+ class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
diff --git a/src/config/Config.test.ts b/src/config/Config.test.ts
new file mode 100644
index 00000000..34dd44cb
--- /dev/null
+++ b/src/config/Config.test.ts
@@ -0,0 +1,54 @@
+/*
+Copyright 2026 New Vector 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, vi, afterEach } from "vitest";
+import { logger } from "matrix-js-sdk/lib/logger";
+
+import { validateConfig } from "./Config";
+import { MatrixRTCMode } from "./ConfigOptions";
+
+describe("validateConfig", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("passes through a missing matrix_rtc_mode unchanged", () => {
+ const result = validateConfig({});
+ expect(result.matrix_rtc_mode).toBeUndefined();
+ });
+
+ it.each(Object.values(MatrixRTCMode))(
+ "keeps a valid matrix_rtc_mode value (%s)",
+ (mode) => {
+ const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
+ const result = validateConfig({ matrix_rtc_mode: mode });
+ expect(result.matrix_rtc_mode).toBe(mode);
+ expect(warnSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it("drops an invalid matrix_rtc_mode value and warns", () => {
+ const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
+ const result = validateConfig({
+ // Intentionally bypass the type to simulate bad JSON.
+ matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
+ });
+ expect(result.matrix_rtc_mode).toBeUndefined();
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0][0]).toContain("nonsense");
+ });
+
+ it("does not touch unrelated fields when dropping an invalid mode", () => {
+ vi.spyOn(logger, "warn").mockImplementation(() => {});
+ const result = validateConfig({
+ matrix_rtc_mode: "nope" as unknown as MatrixRTCMode,
+ ssla: "https://example.invalid/ssla",
+ });
+ expect(result.matrix_rtc_mode).toBeUndefined();
+ expect(result.ssla).toBe("https://example.invalid/ssla");
+ });
+});
diff --git a/src/config/Config.ts b/src/config/Config.ts
index b52acc46..f52b28fd 100644
--- a/src/config/Config.ts
+++ b/src/config/Config.ts
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { merge } from "lodash-es";
+import { logger } from "matrix-js-sdk/lib/logger";
import { getUrlParams } from "../UrlParams";
import {
@@ -14,6 +15,11 @@ import {
type ResolvedConfigOptions,
} from "./ConfigOptions";
import { isFailure } from "../utils/fetch";
+import { MatrixRTCMode } from "./ConfigOptions";
+
+const VALID_MATRIX_RTC_MODES: ReadonlySet = new Set(
+ Object.values(MatrixRTCMode),
+);
export class Config {
private static internalInstance: Config | undefined;
@@ -44,7 +50,11 @@ export class Config {
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
(config) => {
- internalInstance.config = merge({}, DEFAULT_CONFIG, config);
+ internalInstance.config = merge(
+ {},
+ DEFAULT_CONFIG,
+ validateConfig(config),
+ );
},
);
}
@@ -84,6 +94,17 @@ export class Config {
private initPromise?: Promise;
}
+export function validateConfig(config: ConfigOptions): ConfigOptions {
+ const mode = config.matrix_rtc_mode;
+ if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
+ logger.warn(
+ `Ignoring invalid matrix_rtc_mode in config.json: ${String(mode)}`,
+ );
+ delete config.matrix_rtc_mode;
+ }
+ return config;
+}
+
async function downloadConfig(fetchTarget: string): Promise {
const response = await fetch(fetchTarget);
diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts
index 165a14f0..536966e0 100644
--- a/src/config/ConfigOptions.ts
+++ b/src/config/ConfigOptions.ts
@@ -6,6 +6,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
+/**
+ * The MatrixRTC mode determines how Element Call interacts with the
+ * MatrixRTC backend and other participants. Selectable via the Developer
+ * Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
+ */
+export enum MatrixRTCMode {
+ /** Multi-SFU transport, legacy JWT endpoint, state events. */
+ Compatibility = "compatibility",
+ /**
+ * Multi-SFU transport with:
+ * - sticky events
+ * - hashed RTC backend identity
+ * - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
+ * - use the hashed identity for the local membership
+ */
+ Matrix_2_0 = "matrix_2_0",
+}
+
export interface ConfigOptions {
/**
* The Posthog endpoint to which analytics data will be sent.
@@ -54,10 +72,7 @@ export interface ConfigOptions {
livekit?: {
// The link to the service that returns a livekit url and token to use it.
// This is a fallback link in case the homeserver in use does not advertise
- // a livekit service url in the client well-known.
- // The well known needs to be formatted like so:
- // {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
- // and stored under the key: "org.matrix.msc4143.rtc_foci"
+ // a livekit service url over the transports endpoint.
livekit_service_url: string;
};
@@ -69,15 +84,6 @@ export interface ConfigOptions {
* Allow to join group calls without audio and video.
*/
feature_group_calls_without_video_and_audio?: boolean;
-
- /**
- * Send device-specific call session membership state events instead of
- * legacy user-specific call membership state events.
- * This setting has no effect when the user joins an active call with
- * legacy state events. For compatibility, Element Call will always join
- * active legacy calls with legacy state events.
- */
- feature_use_device_session_member_events?: boolean;
};
/**
@@ -85,6 +91,61 @@ export interface ConfigOptions {
*/
ssla?: string;
+ /**
+ * Media quality settings for video and screen sharing.
+ * These override the hardcoded LiveKit defaults.
+ */
+ media_quality?: {
+ /**
+ * Video codec preference. The server must also have the codec enabled.
+ * @default "vp8"
+ */
+ video_codec?: "vp8" | "vp9" | "h264" | "av1";
+
+ /**
+ * Camera video settings.
+ */
+ video?: {
+ /** Max resolution height in pixels (e.g. 720, 1080, 1440). @default 720 */
+ max_resolution?: number;
+ /** Max bitrate in bits per second. @default 1700000 */
+ max_bitrate?: number;
+ /** Max framerate. @default 30 */
+ max_framerate?: number;
+ /**
+ * Simulcast layers as an array of {height, bitrate} objects,
+ * ordered from lowest to highest quality.
+ * @default [{height: 180, bitrate: 160000}, {height: 360, bitrate: 450000}]
+ */
+ simulcast_layers?: Array<{
+ height: number;
+ bitrate: number;
+ }>;
+ };
+
+ /**
+ * Screen share settings.
+ */
+ screen_share?: {
+ /** Max resolution height in pixels. @default 1080 */
+ max_resolution?: number;
+ /** Max bitrate in bits per second. @default 5000000 */
+ max_bitrate?: number;
+ /** Max framerate. @default 30 */
+ max_framerate?: number;
+ /**
+ * Simulcast layers for screen sharing as an array of {height, bitrate, framerate} objects,
+ * ordered from lowest to highest quality. If omitted, LiveKit SDK defaults apply (1 extra
+ * layer at half resolution).
+ */
+ simulcast_layers?: Array<{
+ height: number;
+ bitrate: number;
+ framerate?: number;
+ }>;
+ };
+ };
+
media_devices?: {
/**
* Defines whether participants should start with audio enabled by default.
@@ -104,6 +165,14 @@ export interface ConfigOptions {
*/
sync_disconnect_grace_period_ms?: number;
+ /**
+ * Pins the {@link MatrixRTCMode} for all clients on this deployment,
+ * overriding any per-user choice from the Developer Settings. If unset,
+ * the user's Developer Settings choice (or its default of `Compatibility`)
+ * wins.
+ */
+ matrix_rtc_mode?: MatrixRTCMode;
+
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
@@ -150,20 +219,44 @@ export interface ConfigOptions {
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membership_event_expiry_ms?: number;
+
+ /**
+ * The number of participants in the session at which the media encryption key will no longer
+ * be rotated.
+ *
+ * Rotating a key requires sending it to every participant device, so in large sessions the
+ * cost of rotating on every join/leave becomes prohibitive. At this limit the current key is
+ * kept and distributed to new joiners; no new keys are generated for joiners/leavers.
+ *
+ * Defaults to the js-sdk default (undefined). Which means that rotation will always happen.
+ */
+ key_rotation_participant_limit?: number;
};
}
// Overrides members from ConfigOptions that are always provided by the
// default config and are therefore non-optional.
export interface ResolvedConfigOptions extends ConfigOptions {
- default_server_config: {
- ["m.homeserver"]: {
- base_url: string;
- server_name: string;
- };
- };
sync_disconnect_grace_period_ms: number;
ssla: string;
+ media_quality: Required<
+ Pick, "video_codec">
+ > & {
+ video: Required<
+ Pick<
+ NonNullable["video"]>,
+ "max_resolution" | "max_bitrate" | "max_framerate"
+ >
+ >;
+ screen_share: Required<
+ Pick<
+ NonNullable<
+ NonNullable["screen_share"]
+ >,
+ "max_resolution" | "max_bitrate" | "max_framerate"
+ >
+ >;
+ };
matrix_rtc_session: {
wait_for_key_rotation_ms?: number;
delayed_leave_event_delay_ms: number;
@@ -171,21 +264,26 @@ export interface ResolvedConfigOptions extends ConfigOptions {
delayed_leave_event_restart_ms?: number;
network_error_retry_ms: number;
membership_event_expiry_ms?: number;
+ key_rotation_participant_limit?: number;
};
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
- default_server_config: {
- ["m.homeserver"]: {
- base_url: "http://localhost:8008",
- server_name: "localhost",
- },
- },
- features: {
- feature_use_device_session_member_events: true,
- },
sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
+ media_quality: {
+ video_codec: "vp8",
+ video: {
+ max_resolution: 720,
+ max_bitrate: 1_700_000,
+ max_framerate: 30,
+ },
+ screen_share: {
+ max_resolution: 1080,
+ max_bitrate: 5_000_000,
+ max_framerate: 30,
+ },
+ },
matrix_rtc_session: {
delayed_leave_event_delay_ms: 10000,
network_error_retry_ms: 1000,
diff --git a/src/controls.ts b/src/controls.ts
index 1ddb1704..1978946d 100644
--- a/src/controls.ts
+++ b/src/controls.ts
@@ -12,6 +12,7 @@ export interface Controls {
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
+ onPipMediaOrientationUpdate?: (orientation: "landscape" | "portrait") => void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
setAudioDevice(id: string): void;
diff --git a/src/e2ee/matrixKeyProvider.ts b/src/e2ee/matrixKeyProvider.ts
index 63a96755..f1a66a28 100644
--- a/src/e2ee/matrixKeyProvider.ts
+++ b/src/e2ee/matrixKeyProvider.ts
@@ -10,15 +10,15 @@ import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
-import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
+import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
-const logger = rootLogger.getChild("[MatrixKeyProvider]");
export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;
-
+ private logger: Logger;
public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
+ this.logger = rootLogger.getChild("[MatrixKeyProvider]");
}
public setRTCSession(rtcSession: MatrixRTCSession): void {
@@ -60,12 +60,12 @@ export class MatrixKeyProvider extends BaseKeyProvider {
encryptionKeyIndex,
);
- logger.debug(
+ this.logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
- logger.error(
+ this.logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
diff --git a/src/graphics/backgroundGradient.svg b/src/graphics/backgroundGradient.svg
deleted file mode 100644
index 088244d8..00000000
--- a/src/graphics/backgroundGradient.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
diff --git a/src/graphics/desktop-gradient.png b/src/graphics/desktop-gradient.png
new file mode 100644
index 00000000..84b414cf
Binary files /dev/null and b/src/graphics/desktop-gradient.png differ
diff --git a/src/graphics/loggedOutGradient.svg b/src/graphics/loggedOutGradient.svg
deleted file mode 100644
index 7855ef81..00000000
--- a/src/graphics/loggedOutGradient.svg
+++ /dev/null
@@ -1,48 +0,0 @@
-
diff --git a/src/graphics/mobile-gradient.png b/src/graphics/mobile-gradient.png
new file mode 100644
index 00000000..ab6d1ae4
Binary files /dev/null and b/src/graphics/mobile-gradient.png differ
diff --git a/src/graphics/video-placeholder.gif b/src/graphics/video-placeholder.gif
new file mode 100644
index 00000000..db8d3841
Binary files /dev/null and b/src/graphics/video-placeholder.gif differ
diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx
index 05e4d6ed..69bfc076 100644
--- a/src/grid/Grid.tsx
+++ b/src/grid/Grid.tsx
@@ -41,6 +41,8 @@ import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
+const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
+
interface Rect {
x: number;
y: number;
@@ -285,7 +287,6 @@ export function Grid<
const [visibleTilesCallback, setVisibleTilesCallback] =
useState(null);
const tiles = useInitial(() => new Map>());
- const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC> = useMemo(
() =>
@@ -372,6 +373,10 @@ export function Grid<
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef(null);
+ // If true, disables animations
+ const immediate =
+ usePrefersReducedMotion() || placedTiles.length > MAX_ANIMATED_TILES;
+
const [tileTransitions, springRef] = useTransition(
placedTiles,
() => ({
@@ -389,9 +394,9 @@ export function Grid<
y,
width,
height,
- immediate: prefersReducedMotion,
+ immediate,
}),
- enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
+ enter: { opacity: 1, scale: 1, immediate },
update: ({
id,
x,
@@ -406,9 +411,9 @@ export function Grid<
y,
width,
height,
- immediate: prefersReducedMotion,
+ immediate,
},
- leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
+ leave: { opacity: 0, scale: 0, immediate },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
// react-spring's types are bugged and can't infer the spring type
@@ -441,8 +446,7 @@ export function Grid<
y: tile.y,
width: tile.width,
height: tile.height,
- immediate:
- prefersReducedMotion || ((key): boolean => key === "zIndex"),
+ immediate: immediate || ((key): boolean => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key): number => (key === "zIndex" ? 500 : 0),
@@ -453,7 +457,7 @@ export function Grid<
x: tileX,
y: tileY,
immediate:
- prefersReducedMotion ||
+ immediate ||
((key): boolean =>
key === "zIndex" || key === "x" || key === "y"),
},
diff --git a/src/grid/OneOnOneLandscapeLayout.module.css b/src/grid/OneOnOneDesktopLayout.module.css
similarity index 100%
rename from src/grid/OneOnOneLandscapeLayout.module.css
rename to src/grid/OneOnOneDesktopLayout.module.css
diff --git a/src/grid/OneOnOneLandscapeLayout.tsx b/src/grid/OneOnOneDesktopLayout.tsx
similarity index 81%
rename from src/grid/OneOnOneLandscapeLayout.tsx
rename to src/grid/OneOnOneDesktopLayout.tsx
index 1e21d112..ccf24977 100644
--- a/src/grid/OneOnOneLandscapeLayout.tsx
+++ b/src/grid/OneOnOneDesktopLayout.tsx
@@ -10,28 +10,28 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
-import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
+import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
-import styles from "./OneOnOneLandscapeLayout.module.css";
+import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
- * An implementation of the "one-on-one" layout for landscape screens, in which
+ * An implementation of the "one-on-one" layout for desktop platforms, in which
* the remote participant is shown at maximum size, overlaid by a small view of
* the local participant.
*/
-export const makeOneOnOneLandscapeLayout: CallLayout<
- OneOnOneLandscapeLayoutModel
+export const makeOneOnOneDesktopLayout: CallLayout<
+ OneOnOneDesktopLayoutModel
> = ({ minBounds$ }) => ({
foreground: "fixed",
- fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
+ fixed: function OneOnOneDesktopLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return ;
},
- scrolling: function OneOnOneLandscapeLayoutScrolling({
+ scrolling: function OneOnOneDesktopLayoutScrolling({
ref,
model,
Slot,
diff --git a/src/grid/OneOnOnePortraitLayout.module.css b/src/grid/OneOnOneMobileLayout.module.css
similarity index 75%
rename from src/grid/OneOnOnePortraitLayout.module.css
rename to src/grid/OneOnOneMobileLayout.module.css
index cfe355c3..735c8985 100644
--- a/src/grid/OneOnOnePortraitLayout.module.css
+++ b/src/grid/OneOnOneMobileLayout.module.css
@@ -26,14 +26,28 @@ Please see LICENSE in the repository root for full details.
var(--content-inset-left);
}
+/* Give the PiP a landscape aspect ratio */
.pip[data-size="sm"] {
- inline-size: 88px;
- block-size: 132px;
+ inline-size: 132px;
+ block-size: 88px;
}
.pip[data-size="lg"] {
- inline-size: 140px;
- block-size: 210px;
+ inline-size: 210px;
+ block-size: 140px;
+}
+
+@media (max-width: 600px) {
+ /* Give the PiP a portrait aspect ratio */
+ .pip[data-size="sm"] {
+ inline-size: 88px;
+ block-size: 132px;
+ }
+
+ .pip[data-size="lg"] {
+ inline-size: 140px;
+ block-size: 210px;
+ }
}
.pip[data-block-alignment="start"] {
diff --git a/src/grid/OneOnOnePortraitLayout.tsx b/src/grid/OneOnOneMobileLayout.tsx
similarity index 78%
rename from src/grid/OneOnOnePortraitLayout.tsx
rename to src/grid/OneOnOneMobileLayout.tsx
index 4f7c9f45..628ade1c 100644
--- a/src/grid/OneOnOnePortraitLayout.tsx
+++ b/src/grid/OneOnOneMobileLayout.tsx
@@ -9,23 +9,23 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode, useCallback } from "react";
import classNames from "classnames";
-import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
+import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
-import styles from "./OneOnOnePortraitLayout.module.css";
+import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
- * An implementation of the "one-on-one" layout for portrait screens, in which
+ * An implementation of the "one-on-one" layout for mobile platforms, in which
* the remote participant is shown at maximum size, overlaid by a small view of
* the local participant.
*/
-export const makeOneOnOnePortraitLayout: CallLayout<
- OneOnOnePortraitLayoutModel
+export const makeOneOnOneMobileLayout: CallLayout<
+ OneOnOneMobileLayoutModel
> = () => ({
foreground: "scrolling",
- fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
+ fixed: function OneOnOneMobileLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
return (
@@ -38,7 +38,7 @@ export const makeOneOnOnePortraitLayout: CallLayout<
);
},
- scrolling: function OneOnOnePortraitLayoutScrolling({
+ scrolling: function OneOnOneMobileLayoutScrolling({
ref,
model,
Slot,
diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx
index d76890c5..5da12a89 100644
--- a/src/grid/SpotlightLandscapeLayout.tsx
+++ b/src/grid/SpotlightLandscapeLayout.tsx
@@ -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.
*/
-import { type ReactNode } from "react";
+import { type FC, type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,6 +13,9 @@ import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
+import { type MediaViewModel } from "../state/media/MediaViewModel.ts";
+import { type Behavior } from "../state/Behavior.ts";
+import { useBehavior } from "../useBehavior.ts";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -54,16 +57,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
- const withIndicators =
- useObservableEagerState(model.spotlight.media$).length > 1;
return (
-
+
{model.grid.map((m) => (
@@ -73,3 +70,20 @@ export const makeSpotlightLandscapeLayout: CallLayout<
);
},
});
+
+interface SpotlightSlotProps {
+ media$: Behavior;
+}
+
+// This component isolates the subscription to the spotlight media so that it
+// can change without causing the whole layout to re-render
+const SpotlightSlot: FC = ({ media$ }) => {
+ const withIndicators = useBehavior(media$).length > 1;
+ return (
+
+ );
+};
diff --git a/src/grid/TileWrapper.module.css b/src/grid/TileWrapper.module.css
index 2147b194..ba973b8c 100644
--- a/src/grid/TileWrapper.module.css
+++ b/src/grid/TileWrapper.module.css
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
.tile.draggable {
cursor: grab;
- box-shadow: var(--big-drop-shadow);
+ --draggable-shadow: var(--big-drop-shadow);
}
.tile.draggable:active {
diff --git a/src/index.css b/src/index.css
index a11a69ed..d1134513 100644
--- a/src/index.css
+++ b/src/index.css
@@ -15,8 +15,7 @@ Please see LICENSE in the repository root for full details.
@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 {
@@ -28,12 +27,6 @@ layer(compound);
--font-size-title: calc(24px * var(--font-scale));
--font-size-headline: calc(32px * var(--font-scale));
- /* These colors are needed during the transitionary period between the old and
- new Compound design systems, but should be removed ASAP */
- --stopgap-color-on-solid-accent: var(--cpd-color-bg-canvas-default);
- --stopgap-background-85: rgba(255, 255, 255, 0.85);
- --stopgap-bgColor3: #444;
-
--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
@@ -55,7 +48,6 @@ layer(compound);
--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);
- --background-gradient: url("graphics/backgroundGradient.svg");
--call-view-overlay-layer: 1;
--call-view-header-footer-layer: 2;
@@ -74,9 +66,6 @@ layer(compound);
body {
background-color: var(--cpd-color-bg-canvas-default);
- background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
- background-repeat: no-repeat;
- background-position: center;
color: var(--cpd-color-text-primary);
color-scheme: dark;
margin: 0;
@@ -94,6 +83,13 @@ body.lotus-transparent #root {
background: transparent !important;
}
+/* [lotus] Upstream v0.25.0 added a full-viewport gradient painted on
+`body[data-background="gradient"]::before`. It sits above the transparent body
+and would hide the host's wallpaper, so suppress it in transparent mode. */
+body.lotus-transparent[data-background="gradient"]::before {
+ display: none !important;
+}
+
/* [lotus] Native Lotus/TDS theme, applied when lotusTheme=1, instead of the
host injecting CSS into the iframe after load. Overrides Compound design tokens
with Lotus values at the source so theming is complete and flash-free. Extend
@@ -104,6 +100,27 @@ body.lotus-theme {
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
}
+@media (min-height: 330px) {
+ body[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;
+ }
+
+ body[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 {
diff --git a/src/initializer.tsx b/src/initializer.tsx
index 7c6fc529..91436d10 100644
--- a/src/initializer.tsx
+++ b/src/initializer.tsx
@@ -30,6 +30,7 @@ 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 } from "./widget";
@@ -237,6 +238,7 @@ export class Initializer {
this.loadStates.config = LoadState.Loading;
Config.init().then(
() => {
+ seedSettingsFromConfig(Config.get().media_quality);
this.loadStates.config = LoadState.Loaded;
this.initStep(resolve);
},
diff --git a/src/input/Input.module.css b/src/input/Input.module.css
index 869416b6..6c229f1c 100644
--- a/src/input/Input.module.css
+++ b/src/input/Input.module.css
@@ -173,14 +173,6 @@ Please see LICENSE in the repository root for full details.
border-color: var(--cpd-color-border-disabled);
}
-.checkbox svg {
- display: none;
-}
-
-.checkbox svg * {
- stroke: var(--stopgap-color-on-solid-accent);
-}
-
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
background: var(--cpd-color-text-action-accent);
border-color: var(--cpd-color-text-action-accent);
diff --git a/src/livekit/MatrixAudioRenderer.test.tsx b/src/livekit/MatrixAudioRenderer.test.tsx
index bc6ef668..bb79a64d 100644
--- a/src/livekit/MatrixAudioRenderer.test.tsx
+++ b/src/livekit/MatrixAudioRenderer.test.tsx
@@ -99,7 +99,7 @@ function renderTestComponent(
),
} as unknown as Room;
- if (explicitTracks?.length ?? 0 > 0) {
+ if ((explicitTracks?.length ?? 0) > 0) {
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
const participant =
liveKitParticipants.find((p) => p.identity === participantId) ??
diff --git a/src/livekit/MatrixAudioRenderer.tsx b/src/livekit/MatrixAudioRenderer.tsx
index 10579c1b..e3970e9f 100644
--- a/src/livekit/MatrixAudioRenderer.tsx
+++ b/src/livekit/MatrixAudioRenderer.tsx
@@ -14,7 +14,7 @@ import {
AudioTrack,
type AudioTrackProps,
} from "@livekit/components-react";
-import { logger } from "matrix-js-sdk/lib/logger";
+import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
@@ -40,7 +40,6 @@ export interface MatrixAudioRendererProps {
muted?: boolean;
}
-const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
/**
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
*
@@ -60,6 +59,7 @@ export function LivekitRoomAudioRenderer({
validIdentities,
muted,
}: MatrixAudioRendererProps): ReactNode {
+ const logger = rootLogger.getChild("[MatrixAudioRenderer]");
const tracks = useTracks(
[
Track.Source.Microphone,
@@ -80,7 +80,7 @@ export function LivekitRoomAudioRenderer({
if (!isValid) {
// TODO make sure to also skip the warn logging for the local identity
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
- prefixedLogger.warn(
+ logger.warn(
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx
index 02888466..21cd609e 100644
--- a/src/livekit/TrackProcessorContext.tsx
+++ b/src/livekit/TrackProcessorContext.tsx
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
ProcessorWrapper,
- supportsBackgroundProcessors,
+ supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
type BackgroundOptions,
} from "@livekit/track-processors";
import {
@@ -29,6 +29,7 @@ import {
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
+import { platform } from "../Platform";
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
@@ -106,6 +107,10 @@ interface Props {
children: JSX.Element;
}
+function supportsBackgroundProcessors(): boolean {
+ return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop";
+}
+
export const ProcessorProvider: FC = ({ children }) => {
// The setting the user wants to have
const [blurActivated] = useSetting(backgroundBlurSettings);
diff --git a/src/livekit/options.test.ts b/src/livekit/options.test.ts
new file mode 100644
index 00000000..03134f0c
--- /dev/null
+++ b/src/livekit/options.test.ts
@@ -0,0 +1,213 @@
+/*
+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, vi } from "vitest";
+import { VideoPresets, type VideoPreset } from "livekit-client";
+
+import { buildLiveKitOptions, getLiveKitOptions } from "./options";
+import { Config } from "../config/Config";
+
+vi.mock("../config/Config", () => ({
+ Config: {
+ get: vi.fn(),
+ },
+}));
+
+describe("buildLiveKitOptions", () => {
+ it("returns sensible defaults with no config", () => {
+ const opts = buildLiveKitOptions();
+ expect(opts.adaptiveStream).toBe(true);
+ expect(opts.dynacast).toBe(true);
+ expect(opts.videoCaptureDefaults?.resolution).toEqual(
+ VideoPresets.h720.resolution,
+ );
+ expect(opts.publishDefaults?.videoCodec).toBe("vp8");
+ expect(opts.publishDefaults?.videoEncoding).toEqual({
+ maxBitrate: 1_700_000,
+ maxFramerate: 30,
+ });
+ expect(opts.publishDefaults?.screenShareEncoding).toEqual({
+ maxBitrate: 5_000_000,
+ maxFramerate: 30,
+ });
+ expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
+ VideoPresets.h180,
+ VideoPresets.h360,
+ ]);
+ });
+
+ it("applies video codec from config", () => {
+ const opts = buildLiveKitOptions({ video_codec: "vp9" });
+ expect(opts.publishDefaults?.videoCodec).toBe("vp9");
+ });
+
+ it("applies video resolution and encoding from config", () => {
+ const baseVideoConfig = {
+ max_resolution: 1080,
+ max_bitrate: 3_000_000,
+ max_framerate: 60,
+ };
+ const opts1080 = buildLiveKitOptions({
+ video: baseVideoConfig,
+ });
+ const opts1440 = buildLiveKitOptions({
+ video: { ...baseVideoConfig, max_resolution: 1440 },
+ });
+ const opts2160 = buildLiveKitOptions({
+ video: { ...baseVideoConfig, max_resolution: 2160 },
+ });
+ expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
+ VideoPresets.h1080.resolution,
+ );
+ expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
+ VideoPresets.h1440.resolution,
+ );
+ expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
+ VideoPresets.h2160.resolution,
+ );
+ expect(opts1080.publishDefaults?.videoEncoding).toEqual({
+ maxBitrate: 3_000_000,
+ maxFramerate: 60,
+ });
+ });
+
+ it("applies screen share encoding from config", () => {
+ const opts = buildLiveKitOptions({
+ screen_share: {
+ max_bitrate: 8_000_000,
+ max_framerate: 15,
+ },
+ });
+ expect(opts.publishDefaults?.screenShareEncoding).toEqual({
+ maxBitrate: 8_000_000,
+ maxFramerate: 15,
+ });
+ });
+
+ it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
+ const opts = buildLiveKitOptions({
+ screen_share: {
+ max_resolution: 720,
+ },
+ });
+ // Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
+ expect(opts.publishDefaults?.screenShareEncoding).toEqual({
+ maxBitrate: 5_000_000,
+ maxFramerate: 30,
+ });
+ });
+
+ it("maps low resolutions to the closest preset, rounding up", () => {
+ const expectations: [number, VideoPreset][] = [
+ [180, VideoPresets.h180],
+ [360, VideoPresets.h360],
+ [480, VideoPresets.h540],
+ [540, VideoPresets.h540],
+ [720, VideoPresets.h720],
+ ];
+ for (const [height, preset] of expectations) {
+ const opts = buildLiveKitOptions({ video: { max_resolution: height } });
+ expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
+ }
+ });
+
+ it("screen share layers fall back to max_framerate, then 30", () => {
+ const fromMax = buildLiveKitOptions({
+ screen_share: {
+ max_framerate: 15,
+ simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
+ },
+ });
+ expect(
+ fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
+ ).toMatchObject({ encoding: { maxFramerate: 15 } });
+
+ const fromDefault = buildLiveKitOptions({
+ screen_share: {
+ simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
+ },
+ });
+ expect(
+ fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
+ ).toMatchObject({ encoding: { maxFramerate: 30 } });
+ });
+
+ it("applies custom video simulcast layers", () => {
+ const opts = buildLiveKitOptions({
+ video: {
+ simulcast_layers: [
+ { height: 180, bitrate: 100_000 },
+ { height: 360, bitrate: 300_000 },
+ { height: 540, bitrate: 600_000 },
+ ],
+ max_framerate: 24,
+ },
+ });
+ const layers = opts.publishDefaults?.videoSimulcastLayers;
+ expect(layers).toHaveLength(3);
+ expect(layers?.[0]).toMatchObject({
+ width: 320,
+ height: 180,
+ encoding: { maxBitrate: 100_000, maxFramerate: 24 },
+ });
+ expect(layers?.[2]).toMatchObject({
+ width: 960,
+ height: 540,
+ encoding: { maxBitrate: 600_000, maxFramerate: 24 },
+ });
+ });
+
+ it("applies custom screen share simulcast layers", () => {
+ const opts = buildLiveKitOptions({
+ screen_share: {
+ simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
+ },
+ });
+ const layers = opts.publishDefaults?.screenShareSimulcastLayers;
+ expect(layers).toHaveLength(1);
+ expect(layers?.[0]).toMatchObject({
+ width: 960,
+ height: 540,
+ encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
+ });
+ });
+
+ it("does not include screenShareSimulcastLayers when not configured", () => {
+ const opts = buildLiveKitOptions();
+ expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
+ });
+
+ it("backupCodec always uses stock VP8 720p encoding", () => {
+ const opts = buildLiveKitOptions({
+ video_codec: "av1",
+ video: { max_bitrate: 10_000_000, max_framerate: 60 },
+ });
+ const backup = opts.publishDefaults?.backupCodec as {
+ codec: string;
+ encoding: { maxBitrate: number; maxFramerate: number };
+ };
+ expect(backup.codec).toBe("vp8");
+ expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
+ });
+});
+
+describe("getLiveKitOptions", () => {
+ it("reads from Config singleton", () => {
+ vi.mocked(Config.get).mockReturnValue({
+ media_quality: { video_codec: "h264" },
+ } as ReturnType);
+ const opts = getLiveKitOptions();
+ expect(opts.publishDefaults?.videoCodec).toBe("h264");
+ });
+
+ it("throws when Config is not initialized", () => {
+ vi.mocked(Config.get).mockImplementation(() => {
+ throw new Error("Config not initialized");
+ });
+ expect(() => getLiveKitOptions()).toThrow("Config not initialized");
+ });
+});
diff --git a/src/livekit/options.ts b/src/livekit/options.ts
index 1d4cad77..a4972f00 100644
--- a/src/livekit/options.ts
+++ b/src/livekit/options.ts
@@ -9,46 +9,144 @@ import {
AudioPresets,
DefaultReconnectPolicy,
type RoomOptions,
- ScreenSharePresets,
type TrackPublishDefaults,
type VideoPreset,
VideoPresets,
+ VideoPreset as VideoPresetClass,
} from "livekit-client";
-const defaultLiveKitPublishOptions: TrackPublishDefaults = {
- audioPreset: AudioPresets.music,
- dtx: true,
- // disable red because the livekit server strips out red packets for clients
- // that don't support it (firefox) but of course that doesn't work with e2ee.
- red: false,
- forceStereo: false,
- simulcast: true,
- videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
- screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
- stopMicTrackOnMute: false,
- videoCodec: "vp8",
- videoEncoding: VideoPresets.h720.encoding,
- backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding },
-} as const;
+import { Config } from "../config/Config";
+import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
-export const defaultLiveKitOptions: RoomOptions = {
- // automatically manage subscribed video quality
- adaptiveStream: true,
+/**
+ * Find the closest matching VideoPreset for a given height.
+ */
+function videoPresetForHeight(height: number): VideoPreset {
+ if (height <= 180) return VideoPresets.h180;
+ if (height <= 360) return VideoPresets.h360;
+ if (height <= 540) return VideoPresets.h540;
+ if (height <= 720) return VideoPresets.h720;
+ if (height <= 1080) return VideoPresets.h1080;
+ if (height <= 1440) return VideoPresets.h1440;
+ return VideoPresets.h2160;
+}
- // optimize publishing bandwidth and CPU for published tracks
- dynacast: true,
+/**
+ * Build LiveKit publish options from config, falling back to sensible defaults.
+ */
+function buildPublishOptions(
+ mediaQuality: ConfigOptions["media_quality"],
+): TrackPublishDefaults {
+ const defaults = DEFAULT_CONFIG.media_quality;
+ const videoConf = mediaQuality?.video;
+ const screenConf = mediaQuality?.screen_share;
+ const codec = mediaQuality?.video_codec ?? defaults.video_codec;
- // capture settings
- videoCaptureDefaults: {
- resolution: VideoPresets.h720.resolution,
- },
+ // Camera video encoding
+ const videoEncoding = {
+ maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
+ maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
+ };
- // publish settings
- publishDefaults: defaultLiveKitPublishOptions,
+ // Camera simulcast layers
+ let videoSimulcastLayers: VideoPreset[];
+ if (videoConf?.simulcast_layers) {
+ videoSimulcastLayers = videoConf.simulcast_layers.map(
+ (layer) =>
+ new VideoPresetClass(
+ Math.round((layer.height * 16) / 9),
+ layer.height,
+ layer.bitrate,
+ videoConf?.max_framerate ?? defaults.video.max_framerate,
+ ),
+ );
+ } else {
+ videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
+ }
- // default LiveKit options that seem to be sane
- stopLocalTrackOnUnpublish: true,
- reconnectPolicy: new DefaultReconnectPolicy(),
- disconnectOnPageLeave: true,
- webAudioMix: false,
-};
+ // Screen share encoding
+ const screenShareEncoding = {
+ maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
+ maxFramerate:
+ screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
+ };
+
+ // Screen share simulcast layers
+ let screenShareSimulcastLayers: VideoPreset[] | undefined;
+ if (screenConf?.simulcast_layers) {
+ screenShareSimulcastLayers = screenConf.simulcast_layers.map(
+ (layer) =>
+ new VideoPresetClass(
+ Math.round((layer.height * 16) / 9),
+ layer.height,
+ layer.bitrate,
+ layer.framerate ?? screenConf?.max_framerate ?? 30,
+ ),
+ );
+ }
+
+ return {
+ audioPreset: AudioPresets.music,
+ dtx: true,
+ // disable red because the livekit server strips out red packets for clients
+ // that don't support it (firefox) but of course that doesn't work with e2ee.
+ red: false,
+ forceStereo: false,
+ simulcast: true,
+ videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
+ screenShareEncoding,
+ ...(screenShareSimulcastLayers && {
+ screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
+ }),
+ stopMicTrackOnMute: false,
+ videoCodec: codec,
+ videoEncoding,
+ backupCodec: {
+ codec: "vp8",
+ encoding: VideoPresets.h720.encoding,
+ },
+ } as TrackPublishDefaults;
+}
+
+/**
+ * Build LiveKit RoomOptions from config.
+ * Call this after Config.init() has resolved.
+ */
+export function buildLiveKitOptions(
+ mediaQuality?: ConfigOptions["media_quality"],
+): RoomOptions {
+ const videoHeight =
+ mediaQuality?.video?.max_resolution ??
+ DEFAULT_CONFIG.media_quality.video.max_resolution;
+ const basePreset = videoPresetForHeight(videoHeight);
+
+ return {
+ // automatically manage subscribed video quality
+ adaptiveStream: true,
+
+ // optimize publishing bandwidth and CPU for published tracks
+ dynacast: true,
+
+ // capture settings
+ videoCaptureDefaults: {
+ resolution: basePreset.resolution,
+ },
+
+ // publish settings
+ publishDefaults: buildPublishOptions(mediaQuality),
+
+ // default LiveKit options that seem to be sane
+ stopLocalTrackOnUnpublish: true,
+ reconnectPolicy: new DefaultReconnectPolicy(),
+ disconnectOnPageLeave: true,
+ webAudioMix: false,
+ };
+}
+
+/**
+ * Get LiveKit options, reading from the loaded Config singleton.
+ * Requires Config.init() to have resolved first.
+ */
+export function getLiveKitOptions(): RoomOptions {
+ return buildLiveKitOptions(Config.get().media_quality);
+}
diff --git a/src/lotus/lotusAudioConstraints.test.ts b/src/lotus/lotusAudioConstraints.test.ts
new file mode 100644
index 00000000..90d66591
--- /dev/null
+++ b/src/lotus/lotusAudioConstraints.test.ts
@@ -0,0 +1,130 @@
+/*
+Copyright 2026 Lotus Guild
+
+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, test, vi } from "vitest";
+import { Room as LivekitRoom } from "livekit-client";
+import { BehaviorSubject } from "rxjs";
+import { logger } from "matrix-js-sdk/lib/logger";
+import EventEmitter from "events";
+
+import { ObservableScope } from "../state/ObservableScope.ts";
+import { ECConnectionFactory } from "../state/CallViewModel/remoteMembers/ConnectionFactory.ts";
+import type { OpenIDClientParts } from "../livekit/openIDSFU.ts";
+import {
+ exampleTransport,
+ mockMediaDevices,
+ ownMemberMock,
+} from "../utils/test.ts";
+import type { ProcessorState } from "../livekit/TrackProcessorContext.tsx";
+import {
+ autoGainControlSetting,
+ echoCancellationSetting,
+ noiseSuppressionSetting,
+} from "../settings/settings.ts";
+
+// [lotus] Upstream v0.25.0 moved the audio-capture constraints to Settings.
+// The Lotus host still drives them per-call via URL params (it turns browser
+// noiseSuppression/AGC OFF for the in-source ML denoise tier), so
+// ConnectionFactory ANDs the Setting with the URL param. These tests pin that
+// contract.
+
+const { getUrlParams } = vi.hoisted(() => ({ getUrlParams: vi.fn() }));
+vi.mock("../UrlParams", () => ({ getUrlParams }));
+
+vi.mock("livekit-client", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Room: vi.fn().mockImplementation(function (this: LivekitRoom) {
+ const emitter = new EventEmitter();
+ return {
+ on: emitter.on.bind(emitter),
+ off: emitter.off.bind(emitter),
+ emit: emitter.emit.bind(emitter),
+ disconnect: vi.fn(),
+ remoteParticipants: new Map(),
+ } as unknown as LivekitRoom;
+ }),
+}));
+
+let testScope: ObservableScope;
+const mockClient: OpenIDClientParts = {
+ getOpenIdToken: vi.fn().mockReturnValue(""),
+ getDeviceId: vi.fn().mockReturnValue("DEV000"),
+};
+
+function createRoom(): void {
+ new ECConnectionFactory(
+ mockClient,
+ "!roomid:example.org",
+ mockMediaDevices({}),
+ new BehaviorSubject({
+ supported: true,
+ processor: undefined,
+ }),
+ undefined,
+ false,
+ ).createConnection(testScope, exampleTransport, ownMemberMock, logger);
+}
+
+function capturedAudioDefaults(): Record {
+ const RoomConstructor = vi.mocked(LivekitRoom);
+ const options = RoomConstructor.mock.calls.at(-1)?.[0];
+ return (options?.audioCaptureDefaults ?? {}) as Record;
+}
+
+beforeEach(() => {
+ testScope = new ObservableScope();
+ echoCancellationSetting.setValue(true);
+ noiseSuppressionSetting.setValue(true);
+ autoGainControlSetting.setValue(true);
+});
+
+afterEach(() => {
+ testScope.end();
+ vi.mocked(LivekitRoom).mockClear();
+});
+
+describe("[lotus] audio-capture URL param overrides", () => {
+ test("with params defaulted to true, the Settings govern (upstream behaviour)", () => {
+ getUrlParams.mockReturnValue({
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ });
+ autoGainControlSetting.setValue(false);
+ createRoom();
+ expect(capturedAudioDefaults()).toMatchObject({
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: false,
+ });
+ });
+
+ test("host-passed noiseSuppression=false / autoGainControl=false force OFF regardless of Settings", () => {
+ getUrlParams.mockReturnValue({
+ echoCancellation: true,
+ noiseSuppression: false,
+ autoGainControl: false,
+ });
+ createRoom();
+ expect(capturedAudioDefaults()).toMatchObject({
+ echoCancellation: true,
+ noiseSuppression: false,
+ autoGainControl: false,
+ });
+ });
+
+ test("a URL param cannot force a constraint ON when the Setting is off", () => {
+ getUrlParams.mockReturnValue({
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ });
+ echoCancellationSetting.setValue(false);
+ createRoom();
+ expect(capturedAudioDefaults()).toMatchObject({ echoCancellation: false });
+ });
+});
diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts
index cc25a667..db3a7a35 100644
--- a/src/lotus/lotusAudioInject.ts
+++ b/src/lotus/lotusAudioInject.ts
@@ -55,7 +55,7 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
// Always ack so the transport doesn't hang, but only act when the host has
// explicitly opted in: audio-inject publishes under the local user's
// identity, so it must not be silently armed for every call.
- void w.api.transport.reply(ev.detail, {});
+ w.api.transport.reply(ev.detail, {});
if (!lotusFlag("lotusAudioInject")) return;
const data = ev.detail.data as
| { url?: unknown; volume?: unknown }
@@ -79,6 +79,12 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
sub.unsubscribe();
w.lazyActions.off(LotusWidgetActions.InjectAudio, handler);
// Abort anything still playing.
+ // oxlint-disable-next-line unicorn/no-useless-spread -- the spread is a
+ // required defensive copy: abort() deletes from activeClips while we
+ // iterate it.
+ // The spread is a required defensive copy: abort() deletes from
+ // activeClips while we iterate it.
+ // eslint-disable-next-line unicorn/no-useless-spread
for (const abort of [...activeClips]) abort();
};
}
@@ -106,6 +112,9 @@ async function playInjectedClip(
// Max ONE clip at a time (replace mode): stop any in-flight or playing clip
// before starting a new one, so clips can't overlap or be spammed.
+ // The spread is a required defensive copy: abort() deletes from
+ // activeClips while we iterate it.
+ // eslint-disable-next-line unicorn/no-useless-spread
for (const abort of [...activeClips]) abort();
// A second inject action can arrive while THIS one is still awaiting its
@@ -240,7 +249,10 @@ async function playInjectedClip(
const durationMs = Number.isFinite(buffer.duration)
? buffer.duration * 1000 + 500
: MAX_CLIP_MS;
- const guard = setTimeout(cleanup, Math.min(MAX_CLIP_MS, Math.max(0, durationMs)));
+ const guard = setTimeout(
+ cleanup,
+ Math.min(MAX_CLIP_MS, Math.max(0, durationMs)),
+ );
source.addEventListener("ended", () => clearTimeout(guard));
source.start();
diff --git a/src/lotus/lotusCallState.ts b/src/lotus/lotusCallState.ts
index 86c7ea88..59d36528 100644
--- a/src/lotus/lotusCallState.ts
+++ b/src/lotus/lotusCallState.ts
@@ -6,7 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { combineLatest, of, type Subscription } from "rxjs";
-import { distinctUntilChanged, map, switchMap, throttleTime } from "rxjs/operators";
+import {
+ distinctUntilChanged,
+ map,
+ switchMap,
+ throttleTime,
+} from "rxjs/operators";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
@@ -46,7 +51,11 @@ export function startLotusCallState(vm: CallViewModel): () => void {
m.videoEnabled$,
]).pipe(
map(
- ([speaking, audioEnabled, videoEnabled]): ParticipantState => ({
+ ([
+ speaking,
+ audioEnabled,
+ videoEnabled,
+ ]): ParticipantState => ({
id: m.id,
userId: m.userId,
speaking,
diff --git a/src/lotus/lotusDeafen.ts b/src/lotus/lotusDeafen.ts
index 2d0bc5b2..f0de1e4a 100644
--- a/src/lotus/lotusDeafen.ts
+++ b/src/lotus/lotusDeafen.ts
@@ -95,7 +95,7 @@ export function startLotusDeafen(vm: CallViewModel): () => void {
});
const handler = (ev: CustomEvent): void => {
- void w.api.transport.reply(ev.detail, {});
+ w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as
| { deafened?: boolean; screenshareAudioMuted?: boolean }
| undefined;
diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts
index ed7216e5..de9407d2 100644
--- a/src/lotus/lotusDecorations.ts
+++ b/src/lotus/lotusDecorations.ts
@@ -65,7 +65,7 @@ export function startLotusDecorations(): () => void {
if (registrations === 0) {
const handler = (ev: CustomEvent): void => {
- void w.api.transport.reply(ev.detail, {});
+ w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as
| { decorations?: Record }
| undefined;
diff --git a/src/lotus/lotusDenoiseProcessor.ts b/src/lotus/lotusDenoiseProcessor.ts
index 71f09275..80cfbd41 100644
--- a/src/lotus/lotusDenoiseProcessor.ts
+++ b/src/lotus/lotusDenoiseProcessor.ts
@@ -12,11 +12,7 @@ import {
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
-export type LotusDenoiseModel =
- | "rnnoise"
- | "speex"
- | "dtln"
- | "deepfilternet";
+export type LotusDenoiseModel = "rnnoise" | "speex" | "dtln" | "deepfilternet";
export interface LotusDenoiseConfig {
model: LotusDenoiseModel;
@@ -111,8 +107,8 @@ function supportsSimd(): boolean {
// Minimal SIMD module (v128) — validates only where SIMD is supported.
return WebAssembly.validate(
new Uint8Array([
- 0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10,
- 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11,
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10,
+ 1, 8, 0, 65, 0, 253, 15, 253, 98, 11,
]),
);
} catch {
@@ -145,9 +141,10 @@ interface Graph {
* stopped track on the sender: on failure it degrades to the RAW mic track
* rather than silence.
*/
-export class LotusDenoiseProcessor
- implements TrackProcessor
-{
+export class LotusDenoiseProcessor implements TrackProcessor<
+ Track.Kind.Audio,
+ AudioProcessorOptions
+> {
public readonly name = "lotus-denoise";
public processedTrack?: MediaStreamTrack;
@@ -209,7 +206,11 @@ export class LotusDenoiseProcessor
/** Create (once) the model-rate context + register the flat worklet modules. */
private async ensureContext(): Promise {
const rate = sampleRateFor(this.config.model);
- if (this.ctx && this.ctx.state !== "closed" && this.ctx.sampleRate === rate) {
+ if (
+ this.ctx &&
+ this.ctx.state !== "closed" &&
+ this.ctx.sampleRate === rate
+ ) {
if (this.ctx.state === "suspended") await resumeCtx(this.ctx);
return;
}
@@ -316,7 +317,12 @@ export class LotusDenoiseProcessor
logger.info(
`[lotus] denoise processor active (${this.config.model}, floor=${floor})`,
);
- return { source, nodes, disposes, track: dest.stream.getAudioTracks()[0] };
+ return {
+ source,
+ nodes,
+ disposes,
+ track: dest.stream.getAudioTracks()[0],
+ };
} catch (e) {
// A node constructor / model load can throw mid-build; clean up the
// partially-built graph so it doesn't leak (init/restart still fall back
@@ -338,15 +344,20 @@ export class LotusDenoiseProcessor
if (model === "dtln") {
// Self-contained ESM that resolves its own processor + LiteRT wasm +
// TFLite models. bypassUntilReady passes raw audio until the model loads.
- const mod = await import(/* @vite-ignore */ `${base}workadventure/audio-worklet.js`);
+ const mod = await import(
+ /* @vite-ignore */ `${base}workadventure/audio-worklet.js`
+ );
return (await mod.createNoiseSuppressionAudioWorklet(ctx, {
bypassUntilReady: true,
})) as MlNode;
}
if (model === "deepfilternet") {
- const dfnBase = new URL(`${base}deepfilternet`, window.location.href).href;
- const mod = await import(/* @vite-ignore */ `${base}deepfilternet/index.esm.js`);
+ const dfnBase = new URL(`${base}deepfilternet`, window.location.href)
+ .href;
+ const mod = await import(
+ /* @vite-ignore */ `${base}deepfilternet/index.esm.js`
+ );
const core = new mod.DeepFilterNet3Core({
sampleRate: 48_000,
// 60, not 80: full-strength suppression is the main source of the
@@ -357,7 +368,7 @@ export class LotusDenoiseProcessor
});
await core.initialize();
const node = (await core.createAudioWorkletNode(ctx)) as AudioNode;
- return { node, dispose: () => void safeCall(() => core.destroy()) };
+ return { node, dispose: () => safeCall(() => core.destroy()) };
}
// Flat sapphi worklet (rnnoise/speex).
@@ -379,7 +390,10 @@ export class LotusDenoiseProcessor
numberOfOutputs: 1,
processorOptions: { maxChannels: 1, wasmBinary },
});
- return { node, dispose: () => void safeCall(() => node.port.postMessage("destroy")) };
+ return {
+ node,
+ dispose: () => safeCall(() => node.port.postMessage("destroy")),
+ };
}
private disposeGraph(graph: Graph | undefined): void {
diff --git a/src/lotus/lotusFocus.ts b/src/lotus/lotusFocus.ts
index 3868497e..163085fc 100644
--- a/src/lotus/lotusFocus.ts
+++ b/src/lotus/lotusFocus.ts
@@ -26,7 +26,7 @@ export function startLotusFocus(vm: CallViewModel): () => void {
const handler = (ev: CustomEvent): void => {
// Always reply so the host transport doesn't time out.
- void w.api.transport.reply(ev.detail, {});
+ w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as { userId?: unknown } | undefined;
// Mirror deafen's partial-payload semantics: a payload that OMITS `userId`
// must keep the current spotlight, not clear it. Only act when the key is
@@ -38,6 +38,5 @@ export function startLotusFocus(vm: CallViewModel): () => void {
};
w.lazyActions.on(LotusWidgetActions.FocusParticipant, handler);
- return () =>
- w.lazyActions.off(LotusWidgetActions.FocusParticipant, handler);
+ return () => w.lazyActions.off(LotusWidgetActions.FocusParticipant, handler);
}
diff --git a/src/lotus/lotusQuality.ts b/src/lotus/lotusQuality.ts
index 8c182358..46102bd8 100644
--- a/src/lotus/lotusQuality.ts
+++ b/src/lotus/lotusQuality.ts
@@ -135,7 +135,7 @@ export function startLotusQuality(vm: CallViewModel): () => void {
});
const handler = (ev: CustomEvent): void => {
- void w.api.transport.reply(ev.detail, {});
+ w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as Record | undefined;
if (!data) return;
// Clamp to sane ranges so a typo can't brick the encoder (e.g. a 1 bps mic).
diff --git a/src/lotus/lotusWidget.ts b/src/lotus/lotusWidget.ts
index 77d7d7ba..dab3775c 100644
--- a/src/lotus/lotusWidget.ts
+++ b/src/lotus/lotusWidget.ts
@@ -17,7 +17,7 @@ Please see LICENSE in the repository root for full details.
import { logger } from "matrix-js-sdk/lib/logger";
import { widget } from "../widget";
-import { LotusWidgetActions } from "./lotusActions";
+import type { LotusWidgetActions } from "./lotusActions";
export { LotusWidgetActions } from "./lotusActions";
@@ -33,7 +33,9 @@ export function lotusParam(name: string): string | null {
// Match EC's own ParamParser precedence: the hash fragment wins over the
// query string. So seed from the fragment first, then fill gaps from query.
const hash = window.location.hash.replace(/^#\/?/, "");
- const hashQuery = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : "";
+ const hashQuery = hash.includes("?")
+ ? hash.slice(hash.indexOf("?") + 1)
+ : "";
cachedParams = new URLSearchParams(hashQuery);
for (const [k, v] of new URLSearchParams(window.location.search)) {
if (!cachedParams.has(k)) cachedParams.append(k, v);
@@ -53,11 +55,16 @@ export function lotusFlag(name: string): boolean {
* rejection when the host hasn't (yet) registered a handler for it. Returns
* true if the widget transport was available to attempt the send.
*/
-export function lotusSendToHost(action: LotusWidgetActions, data: unknown): boolean {
+export function lotusSendToHost(
+ action: LotusWidgetActions,
+ data: unknown,
+): boolean {
const api = widget?.api;
if (!api) return false;
- void api.transport.send(action, data as Record).catch((e) => {
- logger.debug(`[lotus] host did not ack ${action}`, e);
- });
+ void api.transport
+ .send(action, data as Record)
+ .catch((e) => {
+ logger.debug(`[lotus] host did not ack ${action}`, e);
+ });
return true;
}
diff --git a/src/reactions/ReactionIndicator.module.css b/src/reactions/ReactionIndicator.module.css
index 0fba7351..ef5c14cb 100644
--- a/src/reactions/ReactionIndicator.module.css
+++ b/src/reactions/ReactionIndicator.module.css
@@ -1,9 +1,9 @@
.reactionIndicatorWidget {
display: flex;
- background-color: #00000030;
border-radius: var(--cpd-radius-pill-effect);
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
- background: "ffffff40";
+ color: var(--cpd-color-text-on-solid-primary);
+ background: var(--cpd-color-icon-secondary-alpha);
backdrop-filter: blur(10px);
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
outline-offset: calc(-1 * var(--cpd-border-width-1));
@@ -33,7 +33,6 @@
.reaction {
margin: var(--cpd-space-1x);
- color: white;
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
diff --git a/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap
index 43c3f928..ca8ecfb0 100644
--- a/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap
+++ b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap
@@ -2,10 +2,10 @@
exports[`RaisedHandIndicator > renders a smaller indicator when miniature is specified 1`] = `
renders a smaller indicator when miniature is spe
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = `
renders an indicator when a hand has been raised
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = `
{
@@ -320,3 +330,99 @@ test("should not show technical details when error has no matrix error cause", a
// Technical details should not be present (ConnectionLostError has no cause)
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
});
+
+describe("LiveKit ConnectionError variants", () => {
+ test.each([
+ {
+ name: "notAllowed",
+ error: ConnectionError.notAllowed("Permission denied by server", 403),
+ expectedReason: "NotAllowed",
+ },
+ {
+ name: "timeout",
+ error: ConnectionError.timeout("Connection timed out"),
+ expectedReason: "Timeout",
+ },
+ {
+ name: "serverUnreachable",
+ error: ConnectionError.serverUnreachable("Server is unreachable", 503),
+ expectedReason: "ServerUnreachable",
+ },
+ {
+ name: "serviceNotFound",
+ error: ConnectionError.serviceNotFound(
+ "RTC service not found",
+ "v0-rtc" as const,
+ ),
+ expectedReason: "ServiceNotFound",
+ },
+ {
+ name: "internal",
+ error: ConnectionError.internal("Internal server error", {
+ status: 500,
+ statusText: "Internal Server Error",
+ }),
+ expectedReason: "InternalError",
+ },
+ ])(
+ "should display LiveKit $name error correctly",
+ async ({ error, expectedReason }) => {
+ const TestComponent = (): ReactNode => {
+ throw new LivekitConnectionError(error);
+ };
+
+ const { asFragment } = render(
+
+
+
+
+ ,
+ );
+
+ // Check title
+ await screen.findByText("Failed to connect to Livekit server");
+
+ // Check that reason is displayed in the description
+ expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
+ expect(screen.getByText(expectedReason)).toBeInTheDocument();
+
+ expect(asFragment()).toMatchSnapshot();
+ },
+ );
+
+ test("should link to troubleshoot guide when timeout error", async () => {
+ const error = new PeerConnectionTimeoutError();
+
+ const TestComponent = (): ReactNode => {
+ throw error;
+ };
+
+ const { asFragment } = render(
+
+
+
+
+ ,
+ );
+
+ await screen.findByText("Connection timeout");
+
+ // Verify the link is present and has correct href
+ const link = screen.getByText("troubleshooting guide");
+ expect(link).toHaveAttribute(
+ "href",
+ "https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/#sfu-connectivity-troubleshooting",
+ );
+
+ // Snapshot the complete rendered error
+ expect(asFragment()).toMatchSnapshot();
+ });
+});
diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx
index ab84678c..390a5a8c 100644
--- a/src/room/GroupCallErrorBoundary.tsx
+++ b/src/room/GroupCallErrorBoundary.tsx
@@ -92,7 +92,26 @@ const ErrorPage: FC = ({
widget={widget}
>
- {error.localisedMessage ?? (
+ {error.localisedMessageKey ? (
+
+ {/* Content injected by Trans component */}
+ ,
+ ,
+ ,
+ ]}
+ />
+ ) : error.localisedMessage ? (
+ error.localisedMessage
+ ) : (
, ]}
diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx
index 02fcd64b..a5c3b0d8 100644
--- a/src/room/GroupCallView.test.tsx
+++ b/src/room/GroupCallView.test.tsx
@@ -19,7 +19,12 @@ import {
vitest,
} from "vitest";
import { render, waitFor, screen, act } from "@testing-library/react";
-import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
+import {
+ type MatrixClient,
+ JoinRule,
+ type RoomState,
+ UnsupportedStickyEventsEndpointError,
+} from "matrix-js-sdk";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
@@ -46,6 +51,7 @@ import {
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
+import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
@@ -130,6 +136,9 @@ beforeEach(() => {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
+ options: {
+ withErrorBoundary?: boolean;
+ } = {},
): {
rtcSession: MatrixRTCSession;
getByText: ReturnType["getByText"];
@@ -166,24 +175,36 @@ function createGroupCallView(
video: { enabled: false },
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
} as unknown as MuteStates;
+ const groupCallView = (
+
+ );
const { getByText } = render(
-
+ {options.withErrorBoundary ? (
+
+ {groupCallView}
+
+ ) : (
+ groupCallView
+ )}
@@ -251,7 +272,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
-test("Should close widget when all other left and have time to play a sound", async () => {
+test("Should close widget when all other left and play a sound", async () => {
const user = userEvent.setup();
let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
@@ -289,8 +310,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve();
- // Expect the leave sound to be played but silent (volumeOverwrite = 0)
- // The allOthersLeft effect should already play a leave sound for the last user in the call.
expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise;
await flushPromises();
@@ -298,37 +317,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetStopMock).toHaveBeenCalledOnce();
}, 80000);
-test("Should close widget when all other left", async () => {
- const user = userEvent.setup();
- const widgetClosedCalled = Promise.withResolvers();
- const widgetSendMock = vi.fn().mockImplementation((action: string) => {
- if (action === ElementWidgetActions.Close) {
- widgetClosedCalled.resolve();
- }
- });
- 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 { getByText } = createGroupCallView(widget as WidgetHelpers);
- const leaveButton = getByText("SimulateOtherLeft");
- await user.click(leaveButton);
- await flushPromises();
-
- await widgetClosedCalled.promise;
- await flushPromises();
- expect(widgetStopMock).toHaveBeenCalledOnce();
-});
-
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();
@@ -394,6 +382,46 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
screen.getByText("Call is not supported");
});
+test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
+ // Mirror the shape the SDK emits: the MembershipManager scheduler wraps
+ // the original UnsupportedStickyEventsEndpointError in a generic Error
+ // but preserves the original on `.cause`.
+ const stickyError = new UnsupportedStickyEventsEndpointError(
+ "Server does not support the sticky events",
+ "sendStickyEvent",
+ );
+ const wrappedError = new Error(
+ "The MembershipManager shut down because of the end condition: " +
+ String(stickyError),
+ { cause: stickyError },
+ );
+
+ const { rtcSession } = createGroupCallView(null, true, {
+ withErrorBoundary: true,
+ });
+
+ await act(() =>
+ rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
+ );
+
+ await screen.findByText("Homeserver does not support Matrix 2.0 calls");
+});
+
+test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
+ const { rtcSession } = createGroupCallView(null, true, {
+ withErrorBoundary: true,
+ });
+
+ await act(() =>
+ rtcSession.emit(
+ MatrixRTCSessionEvent.MembershipManagerError,
+ new Error("something else broke"),
+ ),
+ );
+
+ await screen.findByText("Connection lost");
+});
+
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx
index 7c9009fe..fbd589e7 100644
--- a/src/room/GroupCallView.tsx
+++ b/src/room/GroupCallView.tsx
@@ -13,7 +13,12 @@ import {
useMemo,
useState,
} from "react";
-import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
+import {
+ type MatrixClient,
+ JoinRule,
+ type Room,
+ UnsupportedStickyEventsEndpointError,
+} from "matrix-js-sdk";
import {
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
@@ -67,6 +72,7 @@ import {
ConnectionLostError,
E2EENotSupportedError,
ElementCallError,
+ StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
@@ -162,8 +168,22 @@ export const GroupCallView: FC = ({
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
- (error) => setExternalError(new ConnectionLostError()),
+ (error) => {
+ // When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
+ // not advertise MSC4354 (sticky events), the SDK throws an
+ // `UnsupportedStickyEventsEndpointError`. The MembershipManager
+ // scheduler wraps it and exposes the original via `.cause`.
+ if (
+ error instanceof Error &&
+ error.cause instanceof UnsupportedStickyEventsEndpointError
+ ) {
+ setExternalError(new StickyEventsRequiredError());
+ } else {
+ setExternalError(new ConnectionLostError());
+ }
+ },
);
+
useEffect(() => {
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
@@ -533,11 +553,12 @@ export const GroupCallView: FC = ({
});
}
}}
- onError={
- (/**error*/) => {
- if (rtcSession.isJoined()) onLeft("error");
- }
- }
+ 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);
+ }}
>
{body}
diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css
index fcf1a492..736a915a 100644
--- a/src/room/InCallView.module.css
+++ b/src/room/InCallView.module.css
@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
overflow-y: auto;
}
+/* Normally the footer uses a transparent background to allow our expressive
+page gradients to shine through. However, we sometimes need to visually separate
+it from the content underneath. If the call layout is overflowing, or if the
+spotlight tile is maximised and displaying video, apply a gradient background. */
+.overflowing > .footer,
+.fixedGrid:has(
+ > .tile[data-maximised="true"]
+ .spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"]
+ )
+ ~ .footer {
+ background: linear-gradient(
+ 180deg,
+ rgba(0, 0, 0, 0) 0%,
+ var(--cpd-color-bg-canvas-default) 100%
+ );
+}
+
.header {
position: sticky;
flex-shrink: 0;
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
-}
-.fixedGrid > :not(:first-child),
-.scrollingGrid > :not(:first-child) {
- pointer-events: initial;
+ > :not(:first-child) {
+ pointer-events: initial;
+ }
+
+ .tile {
+ position: absolute;
+ inset-block-start: 0;
+ }
}
.tile {
- position: absolute;
- inset-block-start: 0;
-}
-
-.tile.maximised {
position: relative;
flex-grow: 1;
}
diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx
index c2d8a729..3113c072 100644
--- a/src/room/InCallView.test.tsx
+++ b/src/room/InCallView.test.tsx
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
- afterEach,
beforeEach,
describe,
expect,
@@ -15,12 +14,7 @@ import {
type MockedFunction,
vi,
} from "vitest";
-import {
- render,
- type RenderResult,
- getByRole,
- screen,
-} from "@testing-library/react";
+import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -28,7 +22,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
-import { InCallView } from "./InCallView";
+import { ActiveCall, InCallView } from "./InCallView";
import {
mockLivekitRoom,
mockLocalParticipant,
@@ -39,7 +33,10 @@ import {
type MockRTCSession,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
-import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
+import {
+ getBasicCallViewModelEnvironment,
+ getBasicRTCSession,
+} from "../utils/test-viewmodel";
import {
type CallViewModel,
type CallViewModelOptions,
@@ -50,8 +47,9 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
-import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
+import { type MatrixInfo } from "./VideoPreview";
+import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { initializeWidget } from "../widget";
initializeWidget();
@@ -85,6 +83,17 @@ const remoteParticipant = mockRemoteParticipant({
identity: "@alice:example.org:AAAAAA",
});
+const matrixInfo = {
+ userId: "",
+ displayName: "",
+ avatarUrl: "",
+ roomId: "",
+ roomName: "",
+ roomAlias: null,
+ roomAvatar: null,
+ e2eeSystem: { kind: E2eeType.NONE },
+} satisfies MatrixInfo;
+
let useRoomEncryptionSystemMock: MockedFunction;
beforeEach(() => {
@@ -129,12 +138,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
- const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
- [local, alice],
- undefined,
- mediaDevices,
- args.callViewModelOptions,
- );
+ const { vm, footerVm, developerSettingsVm, rtcSession } =
+ getBasicCallViewModelEnvironment(
+ [local, alice],
+ undefined,
+ mediaDevices,
+ args.callViewModelOptions,
+ );
rtcSession.joined = true;
const room = rtcSession.room;
@@ -147,18 +157,8 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
muteStates={muteState}
vm={vm}
footerVm={footerVm}
- matrixInfo={{
- userId: "",
- displayName: "",
- avatarUrl: "",
- roomId: "",
- roomName: "",
- roomAlias: null,
- roomAvatar: null,
- e2eeSystem: {
- kind: E2eeType.NONE,
- },
- }}
+ developerSettingsVm={developerSettingsVm}
+ matrixInfo={matrixInfo}
matrixRoom={room}
onShareClick={null}
/>
@@ -195,45 +195,6 @@ describe("InCallView", () => {
});
});
- describe("settings button with AppBar header", () => {
- beforeEach(() => {
- // getUrlParams() reads window.location directly rather than from the
- // React Router context, so MemoryRouter alone is not enough to make
- // it see "header=app_bar". Push the real URL so both paths agree.
- window.history.pushState({}, "", "?header=app_bar");
- });
-
- afterEach(() => {
- window.history.pushState({}, "", "/");
- });
-
- it("mobile portrait, is visible in the header", () => {
- createInCallView({
- withAppBar: true,
- callViewModelOptions: {
- // Narrow like a mobile phone in portrait orientation
- windowSize$: constant({ width: 400, height: 700 }),
- },
- });
-
- getByRole(screen.getByRole("banner"), "button", {
- name: "Settings",
- });
- });
-
- it("mobile landscape, is not visible anywhere", () => {
- const { queryByRole } = createInCallView({
- withAppBar: true,
- callViewModelOptions: {
- // Flat like a mobile phone in landscape orientation
- windowSize$: constant({ width: 700, height: 400 }),
- },
- });
-
- expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
- });
- });
-
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
@@ -272,3 +233,34 @@ describe("InCallView", () => {
});
});
});
+
+describe("ActiveCall", () => {
+ it("creates the view models and renders the call", async () => {
+ const mediaDevices = mockMediaDevices({});
+ const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
+ const { findByTestId } = render(
+
+
+
+
+
+ {}}
+ />
+
+
+
+
+ ,
+ );
+ // Rendering at all proves ActiveCall created all of its view models
+ expect(await findByTestId("incall_leave")).toBeVisible();
+ });
+});
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index f4c0813c..110863e1 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -51,14 +51,13 @@ import {
createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
-import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { type CallLayoutOutputs } from "../grid/CallLayout";
-import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
-import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
+import { makeOneOnOneDesktopLayout } from "../grid/OneOnOneDesktopLayout";
+import { makeOneOnOneMobileLayout } from "../grid/OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
@@ -76,22 +75,26 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
-import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
+import {
+ useAppBarHidden,
+ useAppBarSecondaryButton,
+ useAppBarSubtitle,
+} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
+import { constant } from "../state/Behavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
-import { prefetchSounds } from "../soundUtils";
-import { useAudioContext } from "../useAudioContext";
-import ringtoneMp3 from "../sound/ringtone.mp3?url";
-import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
-import { useLatest } from "../useLatest.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
+import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
+import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
+import { RingingStatus } from "../tile/RingingStatus.tsx";
+import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
declare module "react" {
interface CSSProperties {
@@ -100,11 +103,9 @@ declare module "react" {
}
}
-const logger = rootLogger.getChild("[InCallView]");
-
export interface ActiveCallProps extends Omit<
InCallViewProps,
- "vm" | "livekitRoom" | "connState" | "footerVm"
+ "vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
@@ -118,11 +119,14 @@ export const ActiveCall: FC = (props) => {
const [footerVm, setFooterVm] = useState | null>(
null,
);
+ const [developerSettingsVm, setDeveloperSettingsVm] =
+ useState | null>(null);
+
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
- logger.info("START CALL VIEW SCOPE");
+ rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
@@ -177,6 +181,7 @@ export const ActiveCall: FC = (props) => {
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
);
setFooterVm(footerVm);
+ setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
return (): void => {
scope.end();
@@ -196,10 +201,16 @@ export const ActiveCall: FC = (props) => {
if (vm === null) return null;
if (footerVm === null) return null;
+ if (developerSettingsVm === null) return null;
return (
-
+
);
};
@@ -208,6 +219,7 @@ export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel;
+ developerSettingsVm: ViewModel;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -219,11 +231,13 @@ export const InCallView: FC = ({
client,
vm,
footerVm,
+ developerSettingsVm,
matrixInfo,
matrixRoom,
muteStates,
onShareClick,
}) => {
+ const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
@@ -247,20 +261,6 @@ export const InCallView: FC = ({
const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$);
-
- // Preload a waiting and decline sounds
- const pickupPhaseSoundCache = useInitial(async () => {
- return prefetchSounds({
- waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
- });
- });
-
- const pickupPhaseAudio = useAudioContext({
- sounds: pickupPhaseSoundCache,
- latencyHint: "interactive",
- muted: muteAllAudio,
- });
- const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -273,14 +273,16 @@ export const InCallView: FC = ({
() => void toggleRaisedHand(),
);
- const ringing = useBehavior(vm.ringing$);
+ const ringingVm = useBehavior(vm.ringingVm$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
+ const overflowing = useBehavior(vm.overflowing$);
const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
+ const showModals = useBehavior(vm.showModals$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
@@ -316,22 +318,6 @@ export const InCallView: FC = ({
throw fatalCallError;
}
- // While ringing, loop the ringtone
- useEffect((): void | (() => void) => {
- const audio = latestPickupPhaseAudio.current;
- if (ringing && audio) {
- const endSound = audio.playSoundLooping(
- "waiting",
- audio.soundDuration["waiting"] ?? 1,
- );
- return () => {
- void endSound().catch((e) => {
- logger.error("Failed to stop ringing sound", e);
- });
- };
- }
- }, [ringing, latestPickupPhaseAudio]);
-
// iOS Safari doesn't reliably fire `click` on plain
s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps.
@@ -393,6 +379,11 @@ export const InCallView: FC = ({
);
useAppBarHidden(!showHeader);
+ useAppBarSubtitle(
+ ringingVm && vm.ringingStatusLocation === "app_bar" && (
+
+ ),
+ );
let header: ReactNode = null;
switch (headerStyle) {
@@ -487,6 +478,12 @@ export const InCallView: FC = ({
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
+ const showRingingStatus = vm.ringingStatusLocation === "tile";
+ const showOutline = useBehavior(
+ model instanceof GridTileViewModel
+ ? model.showOutline$
+ : constant(false),
+ );
return model instanceof GridTileViewModel ? (
= ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
+ showRingingStatus={showRingingStatus}
+ showOutline={showOutline}
focusable={!contentObscured}
/>
) : (
@@ -511,8 +510,10 @@ export const InCallView: FC = ({
targetHeight={targetHeight}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
+ showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
+ itemClassName={styles.spotlightItem}
style={style}
/>
);
@@ -527,8 +528,8 @@ export const InCallView: FC = ({
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
- "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
- "one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
+ "one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
+ "one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
};
}, [gridBoundsObservable$]);
@@ -537,7 +538,9 @@ export const InCallView: FC = ({
if (layout.type === "pip") {
return (
= ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
+ showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -628,7 +632,7 @@ export const InCallView: FC = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && (
-
+
);
const allConnections = useBehavior(vm.allConnections$);
@@ -637,7 +641,9 @@ export const InCallView: FC = ({
// and the footer is also viewable by moving focus into it, so this is fine.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
= ({
{renderContent()}
+
{reconnectingToast}
{earpieceOverlay}
{footer}
- {layout.type !== "pip" && (
+ {showModals && (
<>
= ({
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
+ developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections
.getConnections()
.map((connectionItem) => ({
diff --git a/src/room/LayoutSwitch.tsx b/src/room/LayoutSwitch.tsx
new file mode 100644
index 00000000..91ba0654
--- /dev/null
+++ b/src/room/LayoutSwitch.tsx
@@ -0,0 +1,44 @@
+/*
+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 { useId, type FC } from "react";
+import {
+ SpotlightViewIcon,
+ GridIcon,
+} from "@vector-im/compound-design-tokens/assets/web/icons";
+
+import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
+import { useBehavior } from "../useBehavior";
+import { useTranslation } from "react-i18next";
+import { Switch } from "@vector-im/compound-web";
+
+interface Props {
+ vm: LayoutSwitchViewModel;
+ className?: string;
+}
+
+export const LayoutSwitch: FC = ({ vm, className }) => {
+ const { t } = useTranslation();
+ const layout = useBehavior(vm.layout$);
+ const name = useId();
+
+ return (
+
+ name={name}
+ aria-label={t("layout_switch_label")}
+ leftLabel={t("layout_spotlight_label")}
+ leftValue="spotlight"
+ leftIcon={SpotlightViewIcon}
+ rightLabel={t("layout_grid_label")}
+ rightValue="grid"
+ rightIcon={GridIcon}
+ className={className}
+ value={layout}
+ onChange={vm.setLayout}
+ />
+ );
+};
diff --git a/src/room/LobbyView.test.tsx b/src/room/LobbyView.test.tsx
index 487501af..8cbe6be1 100644
--- a/src/room/LobbyView.test.tsx
+++ b/src/room/LobbyView.test.tsx
@@ -11,6 +11,10 @@ import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { axe } from "vitest-axe";
+import {
+ ArrowLeftIcon,
+ ChevronLeftIcon,
+} from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -20,6 +24,7 @@ import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
+import { AppBar } from "../AppBar";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
@@ -47,6 +52,13 @@ const mockClient = {
getDeviceId: () => "DEVICE",
} as Partial as MatrixClient;
+const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
+vi.mock("../Platform", () => ({
+ get platform(): string {
+ return platformMock();
+ },
+}));
+
const matrixInfo = {
userId: "@user:example.org",
displayName: "Test User",
@@ -60,25 +72,32 @@ const matrixInfo = {
function renderLobbyView(
props: Partial[0]> = {},
+ withAppBar = false,
+ platform = "android",
): ReturnType {
+ platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
-
+ const hideHeader = withAppBar ? true : false;
+ const lobbyView = (
+ {}}
+ confineToRoom={false}
+ hideHeader={hideHeader}
+ participantCount={3}
+ onShareClick={null}
+ {...props}
+ />
+ );
return render(
- {}}
- confineToRoom={false}
- hideHeader={false}
- participantCount={3}
- onShareClick={null}
- {...props}
- />
+ {withAppBar && {lobbyView}}
+ {!withAppBar && lobbyView}
,
@@ -97,9 +116,10 @@ describe("LobbyView", () => {
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
- expect(
- container.getElementsByClassName(headerStyles.header).length,
- ).toBeFalsy();
+ const els = container.getElementsByClassName(headerStyles.header);
+ for (const el of els) {
+ expect(el).not.toBeVisible();
+ }
});
it("renders with waiting for invite state", () => {
@@ -108,4 +128,50 @@ describe("LobbyView", () => {
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
+
+ it("renders with AppBar android", async () => {
+ const { container, getByRole } = renderLobbyView(
+ {
+ waitingForInvite: true,
+ },
+ true,
+ "android",
+ );
+ getByRole("banner");
+ // Check that the primary button uses ArrowLeftIcon (the back/return icon),
+ // not the default CollapseIcon
+ const { container: iconContainer } = render();
+ const expectedSvgPath = iconContainer
+ .querySelector("path")!
+ .getAttribute("d");
+ const primaryButtonSvgPath = container
+ .querySelector("path")
+ ?.getAttribute("d");
+ expect(primaryButtonSvgPath).toBe(expectedSvgPath);
+ expect(container).toMatchSnapshot();
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it("renders with AppBar ios", async () => {
+ const { container, getByRole } = renderLobbyView(
+ {
+ waitingForInvite: true,
+ },
+ true,
+ "ios",
+ );
+ getByRole("banner");
+ // Check that the primary button uses ArrowLeftIcon (the back/return icon),
+ // not the default CollapseIcon
+ const { container: iconContainer } = render();
+ const expectedSvgPath = iconContainer
+ .querySelector("path")!
+ .getAttribute("d");
+ const primaryButtonSvgPath = container
+ .querySelector("path")
+ ?.getAttribute("d");
+ expect(primaryButtonSvgPath).toBe(expectedSvgPath);
+ expect(container).toMatchSnapshot();
+ expect(await axe(container)).toHaveNoViolations();
+ });
});
diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx
index cec9f6ac..e122b3f2 100644
--- a/src/room/LobbyView.tsx
+++ b/src/room/LobbyView.tsx
@@ -51,6 +51,7 @@ import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
+import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
client: MatrixClient;
@@ -85,8 +86,9 @@ export const LobbyView: FC = ({
}, []);
const { t } = useTranslation();
- usePageTitle(matrixInfo.roomName);
+ usePageTitle(matrixInfo.roomName);
+ useAppBarPrimaryButtonIconKind("back");
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
diff --git a/src/room/RingingAudioRenderer.test.tsx b/src/room/RingingAudioRenderer.test.tsx
new file mode 100644
index 00000000..4d95ffac
--- /dev/null
+++ b/src/room/RingingAudioRenderer.test.tsx
@@ -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 { expect, type MockedFunction, test, vi } from "vitest";
+import { act, render } from "@testing-library/react";
+import { BehaviorSubject } from "rxjs";
+
+import { useAudioContext } from "../useAudioContext";
+import { createRingingMedia } from "../state/media/RingingMediaViewModel";
+import { alice, aliceId } from "../utils/test-fixtures";
+import { constant } from "../state/Behavior";
+import { RingingAudioRenderer } from "./RingingAudioRenderer";
+import { prefetchSounds } from "../soundUtils";
+
+vi.mock("../useAudioContext");
+vi.mock("../soundUtils");
+
+test("ringtone plays on loop while ringing", () => {
+ (prefetchSounds as MockedFunction).mockResolvedValue({
+ sound: new ArrayBuffer(0),
+ });
+ const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
+ const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
+ (useAudioContext as MockedFunction).mockReturnValue({
+ playSound: vi.fn(),
+ playSoundLooping,
+ soundDuration: {},
+ });
+
+ const pickupState$ = new BehaviorSubject<"ringing" | "timeout" | "decline">(
+ "ringing",
+ );
+ const vm = createRingingMedia({
+ id: aliceId,
+ userId: alice.userId,
+ displayName$: constant("Alice"),
+ mxcAvatarUrl$: constant(undefined),
+ intent: "audio",
+ pickupState$,
+ });
+
+ // Begin ringing
+ render();
+ expect(playSoundLooping).toHaveBeenCalledExactlyOnceWith(
+ "ringtone",
+ expect.any(Number),
+ );
+ expect(endSoundLooping).not.toHaveBeenCalled();
+ vi.clearAllMocks();
+
+ // End ringing
+ act(() => pickupState$.next("decline"));
+ expect(playSoundLooping).not.toHaveBeenCalled();
+ expect(endSoundLooping).toHaveBeenCalledExactlyOnceWith();
+});
diff --git a/src/room/RingingAudioRenderer.tsx b/src/room/RingingAudioRenderer.tsx
new file mode 100644
index 00000000..c0fe45d5
--- /dev/null
+++ b/src/room/RingingAudioRenderer.tsx
@@ -0,0 +1,72 @@
+/*
+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, type FC } from "react";
+import { logger } from "matrix-js-sdk/lib/logger";
+
+import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
+import { useBehavior } from "../useBehavior";
+import { useInitial } from "../useInitial";
+import { prefetchSounds } from "../soundUtils";
+import ringtoneMp3 from "../sound/ringtone.mp3?url";
+import ringtoneOgg from "../sound/ringtone.ogg?url";
+import { type UseAudioContext, useAudioContext } from "../useAudioContext";
+import { useLatest } from "../useLatest";
+
+interface RingingAudioRendererProps {
+ vm: RingingMediaViewModel | null;
+ muted: boolean;
+}
+
+export const RingingAudioRenderer: FC = ({
+ vm,
+ muted,
+}) => {
+ // Preload a waiting and decline sounds
+ const sounds = useInitial(async () => {
+ return prefetchSounds({
+ ringtone: { mp3: ringtoneMp3, ogg: ringtoneOgg },
+ });
+ });
+ const audio = useAudioContext({
+ sounds,
+ latencyHint: "interactive",
+ muted,
+ });
+
+ return vm && ;
+};
+
+interface ActiveRingingAudioRendererProps {
+ vm: RingingMediaViewModel;
+ audio: UseAudioContext<"ringtone"> | null;
+}
+
+const ActiveRingingAudioRenderer: FC = ({
+ vm,
+ audio,
+}) => {
+ const audio_ = useLatest(audio);
+ const pickupState = useBehavior(vm.pickupState$);
+
+ // While ringing, loop the ringtone
+ useEffect((): void | (() => void) => {
+ if (pickupState === "ringing" && audio_.current) {
+ const endSound = audio_.current.playSoundLooping(
+ "ringtone",
+ audio_.current.soundDuration["ringtone"] ?? 1,
+ );
+ return () => {
+ void endSound().catch((e) => {
+ logger.error("Failed to stop ringing sound", e);
+ });
+ };
+ }
+ }, [pickupState, audio_]);
+
+ return null;
+};
diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css
index d89381eb..67eae10b 100644
--- a/src/room/VideoPreview.module.css
+++ b/src/room/VideoPreview.module.css
@@ -10,6 +10,8 @@ Please see LICENSE in the repository root for full details.
margin-right: var(--content-inset-right);
min-block-size: 0;
block-size: 50vh;
+ aspect-ratio: 16 / 9;
+ max-width: 100%;
border-radius: var(--cpd-space-4x);
position: relative;
overflow: hidden;
@@ -20,6 +22,9 @@ Please see LICENSE in the repository root for full details.
height: 100%;
object-fit: cover;
background-color: var(--video-tile-background);
+ /* In FF if you add a transform: scale/translate/matrix filter on an element,
+ it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
+ border-radius: inherit;
}
video.mirror {
@@ -66,12 +71,6 @@ video.mirror {
);
}
-@media (min-aspect-ratio: 1 / 1) {
- .preview > video {
- aspect-ratio: 16 / 9;
- }
-}
-
@media (max-width: 550px) {
.preview {
margin-inline: 0;
diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx
index 3efcaba1..e6e54370 100644
--- a/src/room/VideoPreview.tsx
+++ b/src/room/VideoPreview.tsx
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
+import videoPlaceholder from "../graphics/video-placeholder.gif";
export type MatrixInfo = {
userId: string;
@@ -74,6 +75,9 @@ export const VideoPreview: FC = ({
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
+ // Set the placeholder to a small transparent image. (On Android web
+ // views the default poster image is particularly ugly.)
+ poster={videoPlaceholder}
/>
{(!videoEnabled || cameraIsStarting) && (
<>
diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
index e7b38078..4239cee1 100644
--- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
+++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
@@ -3,17 +3,17 @@
exports[`ConnectionLostError: Action handling should reset error state 1`] = `
+ Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our
+
+ troubleshooting guide
+
+ or contact your server administrator.
+
+
+ Return to home screen
+
+
+
+
+
+
+`;
+
+exports[`should have a close button in widget mode 1`] = `
+
+
@@ -469,17 +1411,17 @@ exports[`should render the error page with link back to home 1`] = `
exports[`should report correct error for 'Call is not supported' 1`] = `
+ This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.
+