Compare commits

..
Author SHA1 Message Date
Johannes Marbach 5c497cc6a8 Fix build on Windows 2026-09-25 16:15:39 +02:00
Timo K. 8ebb0b5a90 Export component related types. 2026-09-19 08:46:30 +02:00
renovate[bot] f56c74adf9 Update ghcr.io/element-hq/element-web:develop Docker digest to 3993d53 (#4265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 00:51:42 +00:00
Johannes Marbach 3c88d217f2 Merge pull request #4244 from element-hq/matthew/default-output-first
Prefer the browser default audio output when no output has been chosen
2026-09-16 15:39:12 +02:00
renovate[bot] 967a06ba39 Update ghcr.io/element-hq/element-web:develop Docker digest to 506caaa (#4262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 00:29:13 +00:00
renovate[bot] 71593f1a24 Update ghcr.io/element-hq/element-web:develop Docker digest to c76d299 (#4260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 02:40:31 +00:00
renovate[bot]andJohannes Marbach 5d38a13c46 Update ghcr.io/element-hq/element-web:develop Docker digest to 9d8d8fc (#4243)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 1850bf6

* Adapt to latest EW

* Reformat

* Next try

* Fix regexp

* Fix DM path

* Next try

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-09-14 08:51:33 +00:00
Matthew Hodgson 426acd222e Prefer the browser default audio output when no output has been chosen
On browsers without a "default" pseudo-device (Firefox, Safari), the
virtual default output entry was appended after the physical devices, so
with no saved preference EC selected the first physical device and pinned
every remote audio element to it with setSinkId. Pinned sinks are not
re-routed by the browser: on Firefox/Linux a Bluetooth headset switching
from A2DP to HFP when its microphone is opened (i.e. on unmute) destroys
the pinned sink and all remote audio goes silent, with no error and no
fallback (rageshake 17320).

List the virtual default first so it is the fallback both when nothing was
chosen and when the chosen output disappears, and stop labelling it with
the first device's name since the browser default is not necessarily that
device.
2026-09-08 23:22:44 +01:00
26 changed files with 607 additions and 864 deletions
+23 -6
View File
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
## ✨ Key Features
✅ **Decentralized & Federated** – No central authority; works across Matrix
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
in Matrix clients as a widget, or (experimentally) mount it as a React component
inside your own application.
✅ **WebRTC-based** – No additional software required.
inside your own application.
✅ **WebRTC-based** – No additional software required.
✅ **Scalable with LiveKit** – Supports large meetings via SFU
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
organize the flow of the meeting.
organize the flow of the meeting.
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
engagement and interactivity to the conversation.
@@ -309,6 +309,23 @@ root. The host imports the component from
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
them external.
The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before
then is also exported
from `@element-hq/element-call-component/api` (a small entry point that does not
load the component).
- props and the types they are made of
- the handle
- the host bridge
- `UserIntent`
- `HeaderStyle`
- `BackgroundStyle` enums
- `configurationForIntent`, the defaults each intent implies
Import from it wherever a value such as
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
entry point re-exports all of it too.
### Backend
A docker compose file `docker-compose-dev.yml` is provided to start the
+124
View File
@@ -0,0 +1,124 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* EXPERIMENTAL
*
* What a host needs in order to talk about Element Call, without Element Call:
* the component's props and the types they are made of, the handle and the
* host bridge, the intents a call can be started with and the configuration
* each implies.
*
* This is an entry point of its own, `@element-hq/element-call-component/api`,
* so that a host can import it on a path that must stay light — a call model
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
* itself, and everything it brings, only when a call is shown. The main entry
* point re-exports all of it, so a host that does not care may import
* everything from there.
*
* Nothing in here may reach anything that is not a type or a constant: the
* point of this module is what it does not import.
*/
import type { Ref } from "react";
import type { MatrixClient } from "matrix-js-sdk";
import type { UrlProperties } from "../src/UrlParams";
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "../src/UrlConfiguration";
// How the host and Element Call talk to each other, and what they say
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
export type { JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// below, which is per call
export type { ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent, and
// what each intent means by default
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
};
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of the
* `supportedLanguages` the main entry point exports, or something that falls
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
}
+6 -96
View File
@@ -36,14 +36,12 @@ import {
type FC,
type JSX,
type ReactNode,
type Ref,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -62,10 +60,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
configurationForIntent,
componentProperties,
type UrlConfiguration,
type UrlParams,
UrlParamsProvider,
type UrlProperties,
UserIntent,
useUrlParams,
} from "../src/UrlParams";
@@ -79,102 +75,16 @@ import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
import { useComponentHostBridge } from "./host";
import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export {
type DeviceMuteRequest,
type DeviceMuteState,
} from "../src/HostBridge";
export { type JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// above, which is per call
export { type ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent
export {
BackgroundStyle,
HeaderStyle,
UserIntent,
type UrlConfiguration,
} from "../src/UrlParams";
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
* to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
}
// Everything a host needs to talk about Element Call — the props, the handle,
// the host bridge, the intents and what each means — also available without
// the component itself from `@element-hq/element-call-component/api`
export * from "./api";
/**
* Prepares the things Element Call needs before it can be shown: translations,
+4
View File
@@ -25,6 +25,10 @@
"types": "./dist/types/component/index.d.ts",
"default": "./dist/element-call.js"
},
"./api": {
"types": "./dist/types/component/api.d.ts",
"default": "./dist/api.js"
},
"./style.css": "./dist/element-call.css"
},
"sideEffects": [
+3 -3
View File
@@ -13,8 +13,8 @@
"rootDir": "..",
"outDir": "./dist/types"
},
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources it reaches rely on.
"include": ["./index.tsx", "../src/@types/*.d.ts"],
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources they reach rely on.
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
"exclude": []
}
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
+4 -1
View File
@@ -76,4 +76,7 @@ Code that builds in only one is a bug.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
`matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and
`configurationForIntent`, for a host that must not load the component). `api.ts`
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
-1
View File
@@ -273,7 +273,6 @@
"mute_for_me": "Mute for me",
"muted_for_me": "Muted for me",
"screen_share_volume": "Screen share volume",
"unknown_participant": "Unknown participant",
"volume": "Volume",
"waiting_for_media": "Waiting for media..."
}
+1 -1
View File
@@ -8,7 +8,7 @@
"dev:embedded": "vite --config vite-embedded.config.js",
"dev:component": "vite --config vite-component-dev.config.ts",
"build": "pnpm build:full",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full:production": "pnpm build:full",
"build:full:development": "pnpm build:full --mode development",
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
+13 -4
View File
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await expect(
ewPage2.getByRole("option", { name: "Welcome Room" }),
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
).toBeVisible();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("button", { name: "Open room" }).click();
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+27 -4
View File
@@ -11,6 +11,7 @@ import {
type JSHandle,
type Page,
type FrameLocator,
type Locator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
@@ -203,6 +204,16 @@ export class TestHelpers {
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
.getByRole("button", { name: "Expand all sections" })
.click({ timeout: 2000 });
} catch {
// Already expanded or button not present
}
}
public static async createRoom(
name: string,
page: Page,
@@ -253,9 +264,9 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -350,6 +361,17 @@ export class TestHelpers {
.click();
}
/**
* Locates a room in the room list by its name.
*
* Matches on the aria-label prefix because the item's role differs between
* the flat room list (`option`) and the sectioned room list (`button`), and
* the label may carry a suffix such as " invitation.".
*/
public static roomListItem(page: Page, roomName: string): Locator {
return page.locator(`[aria-label^="Open room ${roomName}"]`);
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
@@ -359,7 +381,8 @@ export class TestHelpers {
page: Page,
roomName: string,
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
+242
View File
@@ -0,0 +1,242 @@
/*
Copyright 2022-2024 New Vector Ltd.
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.
*/
/**
* How Element Call can be told to behave, and what each intent means.
*
* Kept apart from {@link UrlParams} on purpose: everything here is constants,
* types and one function of them, so that a host of the component can import
* these — the enums it needs as values, the defaults an intent implies — without
* loading Element Call itself. UrlParams, which reads them from a URL, needs
* the router, the config and the rest of the app; this module must not.
* `component/api.ts` is built from it as an entry point of its own.
*/
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { platform } from "./Platform";
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallVoice = "start_call_voice",
JoinExistingCallVoice = "join_existing_voice",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
export enum BackgroundStyle {
Solid = "solid",
Gradient = "gradient",
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* The style of headers to show. "standard" is the default arrangement, "none"
* hides the header entirely, and "app_bar" produces a header with a back
* button like you might see in mobile apps. The callback for the back button
* is window.controls.onBackButtonPressed.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* Whether the app should use per participant keys for E2EE.
*/
perParticipantE2EE: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
* with the join widget action.
*/
skipLobby: boolean;
/**
* Setting this flag makes element call show the lobby after leaving a call.
* This is useful for video rooms.
*/
returnToLobby: boolean;
/**
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent;
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
/**
* The configuration implied by what the user meant to do — if they pressed a
* Start Call button this would be `start_call`, and if they pressed Join Call,
* `join_existing`.
*
* These are platform-specific defaults, so that a host can start a call by
* saying what the user asked for rather than by setting every parameter itself,
* and so that what each intent means is Element Call's decision, made in one
* place. A host that wants something else states it alongside the intent.
*
* {@link UserIntent.Unknown} means no intent was stated, and gives the
* standalone app's defaults: Element Call owns the whole page, so it offers the
* way out of the room that a hosted call must not.
*/
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
// Only constants and `platform` here, so that this depends on nothing but
// the intent.
let preset: UrlConfiguration = {
confineToRoom: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = true;
preset.autoLeaveWhenOthersLeft = true;
preset.callIntent = preset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
preset = {
confineToRoom: false,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
return preset;
}
+17 -222
View File
@@ -9,47 +9,36 @@ Please see LICENSE in the repository root for full details.
import { createContext, use, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "./UrlConfiguration";
import { redact } from "./utils/redact";
// Moved to their own module, so that a host can import them without the rest
// of this one; re-exported here as they always were
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "./UrlConfiguration";
interface RoomIdentifier {
roomAlias: string | null;
roomId: string | null;
viaServers: string[];
}
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallVoice = "start_call_voice",
JoinExistingCallVoice = "join_existing_voice",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
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
@@ -166,109 +155,6 @@ export interface UrlProperties {
*/
background: BackgroundStyle;
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* The style of headers to show. "standard" is the default arrangement, "none"
* hides the header entirely, and "app_bar" produces a header with a back
* button like you might see in mobile apps. The callback for the back button
* is window.controls.onBackButtonPressed.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* Whether the app should use per participant keys for E2EE.
*/
perParticipantE2EE: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
* with the join widget action.
*/
skipLobby: boolean;
/**
* Setting this flag makes element call show the lobby after leaving a call.
* This is useful for video rooms.
*/
returnToLobby: boolean;
/**
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent;
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams extends UrlProperties, UrlConfiguration {}
class ParamParser {
@@ -354,97 +240,6 @@ export const getUrlParams = (
return params;
};
/**
* The configuration implied by what the user meant to do — if they pressed a
* Start Call button this would be `start_call`, and if they pressed Join Call,
* `join_existing`.
*
* These are platform-specific defaults, so that a host can start a call by
* saying what the user asked for rather than by setting every parameter itself,
* and so that what each intent means is Element Call's decision, made in one
* place. A host that wants something else states it alongside the intent.
*
* {@link UserIntent.Unknown} means no intent was stated, and gives the
* standalone app's defaults: Element Call owns the whole page, so it offers the
* way out of the room that a hosted call must not.
*/
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
// Only constants and `platform` here, so that this depends on nothing but
// the intent.
let preset: UrlConfiguration = {
confineToRoom: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = true;
preset.autoLeaveWhenOthersLeft = true;
preset.callIntent = preset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
preset = {
confineToRoom: false,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
return preset;
}
/**
* The {@link UrlProperties} for Element Call running as a component inside a
* host application.
+61 -1
View File
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
import { ObservableScope } from "./ObservableScope";
import { AudioOutput } from "./MediaDevices";
import { audioOutput as audioOutputSetting } from "../settings/settings";
import { withTestScheduler } from "../utils/test";
const BT_SPEAKER = {
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
beforeEach(() => {
testScope = new ObservableScope();
// Device preferences persist in localStorage across tests
audioOutputSetting.setValue(undefined);
});
afterEach(() => {
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
expectObservable(audioOutput.selected$).toBe("abcde", {
a: undefined,
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
// No "default" pseudo-device (Firefox): the virtual browser default is
// selected until the user picks something.
b: { id: "", virtualEarpiece: false },
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
@@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => {
});
});
it("falls back to the browser default when the selected device disappears", () => {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a---b", {
a: DEVICE_LIST_B,
// The monitor is unplugged (or a Bluetooth sink changes profile)
b: [LAPTOP_SPEAKER],
}),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
schedule("--a", {
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
});
expectObservable(audioOutput.selected$).toBe("a-b-c", {
a: { id: "", virtualEarpiece: false },
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
// Rather than pinning some other physical device, let the browser route
c: { id: "", virtualEarpiece: false },
});
});
});
it("lists the virtual browser default first when there is no default pseudo-device", () => {
withTestScheduler(({ behavior, cold, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a", { a: DEVICE_LIST_B }),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
expectObservable(audioOutput.available$).toBe("a", {
a: new Map([
["", { type: "default", name: null }],
[
LAPTOP_SPEAKER.deviceId,
{ type: "name", name: LAPTOP_SPEAKER.label },
],
[
MONITOR_SPEAKER.deviceId,
{ type: "name", name: MONITOR_SPEAKER.label },
],
]),
});
});
});
it("Test mappings", () => {
// In a real life setup there would be first a blanked list
// then the real one.
+7 -143
View File
@@ -52,10 +52,8 @@ import {
aliceRtcMember,
aliceUserId,
bob,
bobDeviceId,
bobId,
bobRtcMember,
bobUserId,
local,
localId,
localRtcMember,
@@ -120,19 +118,6 @@ const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
const bobParticipant = mockRemoteParticipant({ identity: bobId });
const daveParticipant = mockRemoteParticipant({ identity: daveId });
// A LiveKit participant that no MatrixRTC membership accounts for
const rogueParticipant = mockRemoteParticipant({ identity: "rogue" });
const rogueId = `unknown:${exampleTransport.livekit_service_url}:rogue`;
const otherTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.other.example.org",
livekit_alias: "!alias:other.example.org",
};
const bobOnOtherFocusRtcMember = mockRtcMembership(bobUserId, bobDeviceId, {
fociPreferred: [otherTransport],
});
export interface GridLayoutSummary {
type: "grid";
spotlight?: string[];
@@ -465,113 +450,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
});
});
test("LiveKit participants without a membership get an unknown participant tile", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// A participant nobody's membership accounts for connects on frame 1 and
// disconnects on frame 2
const participantInputMarbles = "aba";
// It gets a tile at the end of the grid for as long as it is connected
const expectedLayoutMarbles = " aba";
withCallViewModel(
{
remoteParticipants$: behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant],
b: [aliceParticipant, bobParticipant, rogueParticipant],
}),
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`, rogueId],
},
},
);
},
);
});
});
test("an unknown participant takes the call out of one-on-one layout", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// A participant nobody's membership accounts for connects on frame 1 and
// disconnects on frame 2
const participantInputMarbles = "aba";
// The one-on-one layout would hide the unknown participant, so we must
// fall back to the grid while they are present
const expectedLayoutMarbles = " aba";
withCallViewModel(
{
remoteParticipants$: behavior(participantInputMarbles, {
a: [aliceParticipant],
b: [aliceParticipant, rogueParticipant],
}),
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "one-on-one-desktop",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
b: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, `${aliceId}:0`, rogueId],
},
},
);
},
);
});
});
test("a member seen on a focus other than their own is not an unknown participant", () => {
withTestScheduler(({ expectObservable }) => {
// Bob publishes to another focus, but also connects to ours to subscribe
// (the mock reports the same participants on every connection). Matching
// by identity rather than per transport keeps him from being flagged.
const expectedLayoutMarbles = "a";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
rtcMembers$: constant([
localRtcMember,
aliceRtcMember,
bobOnOtherFocusRtcMember,
]),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
},
},
);
},
);
});
});
test("one-on-one mobile layout shows local tile when video is enabled", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Local participant enables their video, then disables it
@@ -1333,18 +1211,15 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
});
});
test("participants without a MatrixRTC membership are shown as unknown, without media", () => {
test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership
const participantInputMarbles = "abcd-c";
// Bob even tries to share his screen at the end
const bobSharingInputMarbles = " n---yn";
// Bob gets an unknown participant tile (MSC4143) rather than a tile of
// his own, and his screen share never shows up. His presence also keeps
// the call out of the one-on-one layout, which would hide him.
const expectedLayoutMarbles = " abcd-c";
const bobUnknownId = `unknown:${exampleTransport.livekit_service_url}:${bobId}`;
// Bob should never be visible
const expectedLayoutMarbles = " a-bc-b";
withCallViewModel(
{
@@ -1376,25 +1251,14 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
grid: [`${localId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, bobUnknownId],
type: "one-on-one-desktop",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
// Tiles keep their positions, so Bob stays ahead of the others
c: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, bobUnknownId, `${aliceId}:0`],
},
d: {
type: "grid",
spotlight: undefined,
grid: [
`${localId}:0`,
bobUnknownId,
`${aliceId}:0`,
`${daveId}:0`,
],
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
},
},
);
+19 -92
View File
@@ -95,7 +95,6 @@ import {
layoutShallowEquals,
type Alignment,
type GridLayoutMedia,
type GridMediaViewModel,
type Layout,
type LayoutMedia,
type OneOnOneDesktopLayoutMedia,
@@ -163,10 +162,6 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import {
createUnknownParticipantMedia,
type UnknownParticipantMediaViewModel,
} from "../media/UnknownParticipantMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
//TODO
@@ -874,61 +869,6 @@ export function createCallViewModel$(
),
);
/**
* Media for LiveKit participants that cannot be mapped to any MatrixRTC
* membership. MSC4143 asks us to surface these rather than ignore them, as
* they can signal an eavesdropping or impersonation attack.
*
* Participants are matched to memberships by identity alone rather than per
* transport, since a member legitimately shows up as a subscribe-only
* participant on every focus other than the one they publish to.
*/
const unknownParticipantMedia$ = scope.behavior<
UnknownParticipantMediaViewModel[]
>(
combineLatest([
memberships$,
connectionManager.connectionManagerData$,
]).pipe(
// Memberships and connections derive from the same epoch of session
// state; comparing them across epochs would flag members whose
// connection hasn't caught up yet.
filter(([memberships, data]) => memberships.epoch === data.epoch),
map(([memberships, data]) => {
const knownIdentities = new Set(
memberships.value.map((m) => m.rtcBackendIdentity),
);
return data.value.getConnections().flatMap((connection) =>
data.value
.getParticipantsForTransport(connection.transport)
.filter((p) => !knownIdentities.has(p.identity))
.map((p) => ({
focusUrl: connection.transport.livekit_service_url,
identity: p.identity,
})),
);
}),
generateItems(
"CallViewModel unknownParticipantMedia$",
function* (unknownParticipants) {
for (const { focusUrl, identity } of unknownParticipants)
yield { keys: [focusUrl, identity], data: undefined };
},
(_scope, _data$, focusUrl, identity) => {
logger.warn(
`LiveKit participant ${identity} on ${focusUrl} matches no MatrixRTC membership`,
);
return createUnknownParticipantMedia({
id: `unknown:${focusUrl}:${identity}`,
rtcBackendIdentity: identity,
focusUrl,
});
},
),
),
[],
);
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
ringAttempts$.pipe(
switchMap(({ intent, recipient, outcome$ }) =>
@@ -1075,29 +1015,21 @@ export function createCallViewModel$(
),
);
const sortedUserMedia$: Observable<UserMediaViewModel[]> = userMedia$.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
m.bin$.pipe(map((bin) => [m, bin] as const)),
);
// Sort the media by bin order and generate a tile for each one
return bins.length === 0
? of([])
: combineLatest(bins, (...bins) =>
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
);
const grid$ = scope.behavior<GridMediaViewModel[]>(
combineLatest(
[sortedUserMedia$, unknownParticipantMedia$],
// Unknown participants have no media to sort by, so they go last
(userMedia, unknownParticipants) => [
...userMedia,
...unknownParticipants,
],
).pipe(distinctUntilChanged(shallowArrayEquals)),
const grid$ = scope.behavior<UserMediaViewModel[]>(
userMedia$.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
m.bin$.pipe(map((bin) => [m, bin] as const)),
);
// Sort the media by bin order and generate a tile for each one
return bins.length === 0
? of([])
: combineLatest(bins, (...bins) =>
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowArrayEquals),
),
);
/**
@@ -1257,15 +1189,10 @@ export function createCallViewModel$(
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
} | null> = scope.behavior(
combineLatest([userMedia$, screenShares$, unknownParticipantMedia$]).pipe(
switchMap(([userMedia, screenShares, unknownParticipants]) => {
// One-on-one layout only supports 2 user media, no screen shares, and
// must never hide an unknown participant
if (
userMedia.length <= 2 &&
screenShares.length === 0 &&
unknownParticipants.length === 0
) {
combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => {
// One-on-one layout only supports 2 user media, no screen shares
if (userMedia.length <= 2 && screenShares.length === 0) {
const local = userMedia.find(
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
vm.type === "user" && vm.local,
+14 -7
View File
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
// Create a virtual default audio output for browsers that don't have one
// (Firefox, Safari). Its device ID must be the empty string because
// that's what setSinkId recognizes. It goes first so that it is the
// fallback when no output has been explicitly chosen (or the chosen
// one disappears), rather than pinning the first physical device
// with setSinkId: pinned sinks are not re-routed by the browser, and
// Firefox leaves the audio elements silent when a pinned sink goes
// away (e.g. a Bluetooth headset switching profile when its
// microphone is opened). We can't know which physical device the
// browser default resolves to, so the entry carries no name.
if (available.size && !available.has("") && !available.has("default"))
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
available = new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: null }],
...available,
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {
+12 -13
View File
@@ -8,15 +8,12 @@ Please see LICENSE in the repository root for full details.
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type GridTileMediaViewModel,
GridTileViewModel,
SpotlightTileViewModel,
} from "./TileViewModel";
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
import { fillGaps } from "../utils/iter";
import { debugTileLayout } from "../settings/settings";
import { type MediaViewModel } from "./media/MediaViewModel";
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
type SpotlightBackground = "solid" | "transparent";
@@ -71,8 +68,10 @@ class SpotlightTileData {
}
class GridTileData {
private readonly media$: BehaviorSubject<GridTileMediaViewModel>;
public get media(): GridTileMediaViewModel {
private readonly media$: BehaviorSubject<
UserMediaViewModel | RingingMediaViewModel
>;
public get media(): UserMediaViewModel | RingingMediaViewModel {
return this.media$.value;
}
public set media(value: UserMediaViewModel) {
@@ -81,7 +80,7 @@ class GridTileData {
public readonly vm: GridTileViewModel;
public constructor(media: GridTileMediaViewModel) {
public constructor(media: UserMediaViewModel | RingingMediaViewModel) {
this.media$ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media$);
}
@@ -141,7 +140,7 @@ export class TileStoreBuilder {
: null;
private readonly prevGridByMedia: Map<
GridTileMediaViewModel,
MediaViewModel,
[GridTileData, number]
> = new Map(
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
@@ -206,7 +205,9 @@ export class TileStoreBuilder {
* Sets up a grid tile for the given media. If this is never called for some
* media, then that media will have no grid tile.
*/
public registerGridTile(media: GridTileMediaViewModel): void {
public registerGridTile(
media: UserMediaViewModel | RingingMediaViewModel,
): void {
if (DEBUG_ENABLED)
logger.debug(
`[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
@@ -214,10 +215,8 @@ export class TileStoreBuilder {
if (this.spotlight !== null) {
// We actually *don't* want spotlight speakers to appear in both the
// spotlight and the grid, so they're filtered out here. (Unknown
// participants never make it into the spotlight.)
// spotlight and the grid, so they're filtered out here
if (
media.type !== "unknown participant" &&
!(media.type === "user" && media.local) &&
this.spotlight.media.includes(media)
)
+3 -7
View File
@@ -10,14 +10,8 @@ import { BehaviorSubject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type MediaViewModel } from "./media/MediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel";
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
export type GridTileMediaViewModel =
| UserMediaViewModel
| RingingMediaViewModel
| UnknownParticipantMediaViewModel;
let nextId = 0;
function createId(): string {
return (nextId++).toString();
@@ -29,7 +23,9 @@ export class GridTileViewModel {
public readonly showOutline$: Behavior<boolean> = this._showOutline$;
public constructor(
public readonly media$: Behavior<GridTileMediaViewModel>,
public readonly media$: Behavior<
UserMediaViewModel | RingingMediaViewModel
>,
) {}
public setShowOutline(value: boolean): void {
+3 -8
View File
@@ -10,7 +10,6 @@ import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel.ts";
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
import {
type GridTileViewModel,
@@ -19,29 +18,25 @@ import {
import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
export type GridMediaViewModel =
| UserMediaViewModel
| UnknownParticipantMediaViewModel;
export interface GridLayoutMedia {
type: "grid";
edgeToEdge: false;
spotlight?: MediaViewModel[];
grid: GridMediaViewModel[];
grid: UserMediaViewModel[];
}
export interface SpotlightLandscapeLayoutMedia {
type: "spotlight-landscape";
edgeToEdge: boolean;
spotlight: MediaViewModel[];
grid: GridMediaViewModel[];
grid: UserMediaViewModel[];
}
export interface SpotlightPortraitLayoutMedia {
type: "spotlight-portrait";
edgeToEdge: false;
spotlight: MediaViewModel[];
grid: GridMediaViewModel[];
grid: UserMediaViewModel[];
}
export interface SpotlightExpandedLayoutMedia {
@@ -1,57 +0,0 @@
/*
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 { constant } from "../Behavior";
import { type BaseMediaViewModel } from "./MediaViewModel";
/**
* Media representing a LiveKit participant that cannot be mapped to any
* MatrixRTC member of the session.
*
* MSC4143 asks clients to surface such streams rather than silently ignore
* them, because they can signal an eavesdropping or impersonation attack. We
* therefore give them a tile, but never render their audio or video.
*
* There is no Matrix user behind this media, so `userId` and `displayName$`
* carry the LiveKit identity instead, purely so that debugging output stays
* meaningful.
*/
export interface UnknownParticipantMediaViewModel extends BaseMediaViewModel {
type: "unknown participant";
/**
* The LiveKit identity under which the participant connected. Exposed for
* debugging.
*/
rtcBackendIdentity: string;
/**
* The URL of the LiveKit focus on which the participant was seen. Exposed for
* debugging.
*/
focusUrl: string;
}
export interface UnknownParticipantMediaInputs {
id: string;
rtcBackendIdentity: string;
focusUrl: string;
}
export function createUnknownParticipantMedia({
id,
rtcBackendIdentity,
focusUrl,
}: UnknownParticipantMediaInputs): UnknownParticipantMediaViewModel {
return {
type: "unknown participant",
id,
userId: rtcBackendIdentity,
displayName$: constant(rtcBackendIdentity),
mxcAvatarUrl$: constant(undefined),
rtcBackendIdentity,
focusUrl,
};
}
-98
View File
@@ -1,98 +0,0 @@
/*
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, within } from "storybook/test";
import { type JSX, useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { GridTile } from "./GridTile";
import { GridTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior";
import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel";
interface UnknownParticipantTileStoryProps {
width: number;
height: number;
rtcBackendIdentity: string;
showNameTags: boolean;
showOutline: boolean;
}
/**
* Renders a GridTile for a LiveKit participant that maps to no MatrixRTC
* member, driven by primitive props so that Storybook can document them.
*/
function UnknownParticipantTile({
width,
height,
rtcBackendIdentity,
...props
}: UnknownParticipantTileStoryProps): JSX.Element {
const vm = useMemo(
() =>
new GridTileViewModel(
constant(
createUnknownParticipantMedia({
id: `unknown:https://rtc.example.org:${rtcBackendIdentity}`,
rtcBackendIdentity,
focusUrl: "https://rtc.example.org",
}),
),
),
[rtcBackendIdentity],
);
return (
<GridTile
vm={vm}
onOpenProfile={null}
targetWidth={width}
targetHeight={height}
style={{ width, height }}
showSpeakingIndicators={false}
showRingingStatus={false}
focusable
{...props}
/>
);
}
const meta = {
component: UnknownParticipantTile,
argTypes: {
width: { control: { type: "range", min: 80, max: 800, step: 10 } },
height: { control: { type: "range", min: 80, max: 600, step: 10 } },
},
} satisfies Meta<typeof UnknownParticipantTile>;
export default meta;
type Story = StoryObj<typeof meta>;
export const UnknownParticipant: Story = {
args: {
width: 400,
height: 300,
rtcBackendIdentity: "@rogue:example.org:DEVICE",
showNameTags: true,
showOutline: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Unknown participant")).toBeInTheDocument();
await expect(
canvas.queryByText("@rogue:example.org:DEVICE"),
).not.toBeInTheDocument();
},
};
/** Narrow tiles hide the name tag, so the label goes with it. */
export const UnknownParticipantNarrow: Story = {
args: {
...UnknownParticipant.args,
width: 90,
height: 90,
},
};
-31
View File
@@ -32,7 +32,6 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../state/media/RingingMediaViewModel";
import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -166,33 +165,3 @@ test("GridTile displays ringing media", async () => {
act(() => pickupState$.next("decline"));
screen.getByText("Call ended");
});
test("GridTile displays an unknown participant", async () => {
const vm = createUnknownParticipantMedia({
id: "unknown:https://rtc-example.org:rogue",
rtcBackendIdentity: "rogue",
focusUrl: "https://rtc-example.org",
});
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
);
expect(await axe(container)).toHaveNoViolations();
// The tile is labelled as unknown rather than after its LiveKit identity
screen.getByText("Unknown participant");
expect(screen.queryByText("rogue")).toBeNull();
// There is no user to show an avatar for
expect(container.querySelector("[data-style]")).toBeNull();
});
-48
View File
@@ -51,7 +51,6 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { type UnknownParticipantMediaViewModel } from "../state/media/UnknownParticipantMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface TileProps {
@@ -98,44 +97,6 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
);
};
interface UnknownParticipantTileProps extends Omit<
TileProps,
"displayName" | "mxcAvatarUrl"
> {
vm: UnknownParticipantMediaViewModel;
}
/**
* A blank tile standing in for a LiveKit participant that cannot be mapped to
* any MatrixRTC member. MSC4143 asks clients to surface such streams rather
* than ignore them. No media is rendered for them.
*/
const UnknownParticipantTile: FC<UnknownParticipantTileProps> = ({
vm,
className,
...props
}) => {
const { t } = useTranslation();
return (
<MediaView
className={classNames(className, styles.tile)}
video={undefined}
userId={vm.userId}
unencryptedWarning={false}
videoEnabled={false}
mirror={false}
showAvatar={false}
displayName={t("video_tile.unknown_participant")}
mxcAvatarUrl={undefined}
rtcBackendIdentity={vm.rtcBackendIdentity}
focusUrl={vm.focusUrl}
{...props}
/>
);
};
UnknownParticipantTile.displayName = "UnknownParticipantTile";
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
showSpeakingIndicators: boolean;
@@ -493,15 +454,6 @@ export const GridTile: FC<GridTileProps> = ({
{...props}
/>
);
} else if (media.type === "unknown participant") {
return (
<UnknownParticipantTile
ref={ref}
vm={media}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
} else if (media.local) {
return (
<LocalUserMediaTile
+9 -17
View File
@@ -56,11 +56,6 @@ interface Props extends ComponentProps<typeof animated.div> {
displayName: string;
mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
/**
* Whether to show an avatar when there is no video. Off for media that has no
* Matrix user behind it.
*/
showAvatar?: boolean;
background?: "solid" | "transparent";
focusable: boolean;
primaryButton?: ReactNode;
@@ -100,7 +95,6 @@ export const MediaView: FC<Props> = ({
displayName,
mxcAvatarUrl,
avatarStyle = "solid",
showAvatar = true,
background = "solid",
focusable,
primaryButton,
@@ -182,17 +176,15 @@ export const MediaView: FC<Props> = ({
<div className={styles.speakingBorder} />
</div>
)}
{showAvatar && (
<Avatar
id={userId}
name={displayName}
size={avatarSize}
src={mxcAvatarUrl}
data-style={avatarStyle}
className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }}
/>
)}
<Avatar
id={userId}
name={displayName}
size={avatarSize}
src={mxcAvatarUrl}
data-style={avatarStyle}
className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }}
/>
{video?.publication !== undefined && (
<VideoTrack
trackRef={video}
+13 -2
View File
@@ -49,8 +49,19 @@ export default defineConfig(({ mode }) => {
cssCodeSplit: false,
lib: {
formats: ["es" as const],
entry: "./component/index.tsx",
fileName: "element-call",
entry: {
// The component
"element-call": "./component/index.tsx",
// What a host needs to talk about it, without it (see component/api.ts).
// Built as an entry point of its own so that importing it does not
// load the component: the bundler gives it a chunk that reaches only
// what it imports.
api: "./component/api.ts",
},
fileName: (_format, entryName) => `${entryName}.js`,
// The one stylesheet (see cssCodeSplit) keeps the name it had when the
// component was the only entry
cssFileName: "element-call",
},
rollupOptions: {
// The host already has these, and a second copy of any of them does not