Compare commits

..
Author SHA1 Message Date
Quentin GliechandClaude Fable 5.1 58b9e2d983 Test the standalone knock flow in Playwright
A knock call room, created over the CS API, walks through the lobby's join
states: request to join, the waiting copy with its withdrawal link, a second
request, and an invite from the room's moderator that lands the user in the
call. A decline, a ban with its reason, and a room whose join rule the app
cannot act on are covered too. An invite-only room asserts what happens
today: Synapse serves no summary for it, so the refused join is a full-screen
error rather than a lobby state.

The `spaUser` fixture registers over the admin API and logs in through the
form, and serves `config.devenv.json` itself, since Playwright reuses a dev
server started with another config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1c8930daa0 Join the call as soon as a request to join is accepted
An invite that replaced a request to join was read as somebody else being
mid-join. Nobody always is: a host without auto-join, a join that failed, or
a fresh load already at that membership left the user waiting with nothing to
press but "Cancel request". The lobby now takes such an invite up itself,
showing a joining state, and offers a join of the user's own if that fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 d78c204ef4 Let the lobby ask its host for membership changes
Running as a widget, Element Call has no access token of its own, so a room
it is not in can only be entered by the host: the lobby ended at a
full-screen "Room not found" for every room the user had not already joined.

Membership changes now go through a `ChangeMembership` function, which the
host supplies when it keeps the access token to itself and Element Call
builds from its own client when it does not. Either way the answer is the
membership the request resulted in, which for the client is the one an
accepted request implies: local room state still holds the previous
membership until the next sync. The host takes an `io.element.membership`
action carrying `{action: "join" | "knock" | "cancel_knock", reason?}`. An
error reply carrying `matrix_api_error` is the homeserver's own refusal; one
without it means the host does not implement the action, which leaves the
lobby saying the user cannot get in this way. Element Call also asks to
receive `m.room.join_rules`, and reads the state event rather than
`Room.getJoinRule()`, since a host that grants no such capability is
otherwise indistinguishable from an invite-only room.

What the lobby opens on comes from our membership, the membership it
replaced and the join rule. A restricted or knock-restricted room offers a
join, because the widget cannot evaluate `allowed_room_ids` and the host can.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 3653a7fd05 Give the lobby a join state instead of an enter callback
`LobbyView` took `onEnter`, `enterLabel` and `waitingForInvite`, which cannot
express a declined, banned or invite-only room, so each of those ended at a
full-screen error and took the camera preview with it.

It now takes a `LobbyJoinState`, one of seven kinds, and renders both the
button and a message under the preview from it. `KnockLobbyView` passes
through what the loader offers, which is a single `lobby` state in place of
`canKnock` and `waitForInvite`, so a decline, a ban, or a room that takes
neither joins nor knocks keeps the user in the lobby. Waiting says what it is
waiting for and offers a way to withdraw the request.

The loader drives that state from the lobby's own callbacks rather than from
the load promise, so a refused request leaves the user where they were, and
every membership listener it adds is dropped when the effect is cleaned up.
What it knows of a room nobody has joined is a `PreJoinRoomInfo`, built from
a summary or from a `Room`, so a ban Element Call finds at load time is a
lobby state as well.

Denied and banned reuse the translated `group_call_loader.*` copy, so four
strings are new.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1c8a6487e9 Remove the stray page.pause() calls from the Playwright specs
They halt a headless run until the inspector is closed, which never happens
in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1f7e930137 Name the button the room auth screen's licence caption points at
The caption said "Join call now"; the form's button reads "Continue".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 a7fdade61a Tolerate a registration flow that carries no params
`registerRequest({})` rejects with the registration flows, and a homeserver
may answer with no `params` at all, which threw a TypeError on the landing
and login screens and left them without a privacy policy link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 302f6f7efa Allow overriding the dev server TLS certificate via environment
EC_DEV_TLS_KEY / EC_DEV_TLS_CERT point vite at a key pair the developer's
browser already trusts, instead of the checked-in Element Call Dev CA.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
134 changed files with 4147 additions and 5700 deletions
-3
View File
@@ -10,9 +10,6 @@ public/config.json
backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage
# Transient agent scratch files
/agent-workspace/
config.json
# Yarn
-56
View File
@@ -1,56 +0,0 @@
# AGENTS.md — Element Call
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
Element Web and Element X, as embedded packages, and as a React component in a
host's page. It is the MatrixRTC reference implementation.
## Non-negotiables
- Features and UI changes need a pre-approved issue. No issue, no review.
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
is user-facing. All three.
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
name what you rejected — never add a new one silently.
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
`@media`, global `i18next` or the `widget` global — take it from a provider.
- The change works standalone, as a widget and as a component. Say what you checked.
- Every gate below is green before you push.
- Hand off after the first implementation, before the quality pass. Then commit on
the user's word where the setup allows it, or hand them the message.
## Read before you
| … | … |
| -------------------------------- | ---------------------------------------------------------- |
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
## Gates
```sh
pnpm lint # tsc, oxlint, knip, component externals
pnpm format # oxfmt
pnpm test # vitest: unit and storybook projects
pnpm i18n:check
```
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
`knip.ts` `ignoreFiles` with a reason.
## A good PR
- One slice, under ~400 changed lines, green on its own, linked to its issue.
- View model + marble tests, thin view, a story per state, an e2e spec.
- Any new shared component called out explicitly, with why nothing existing fit.
- Template filled for real: what, why, before/after screenshots, repro steps.
- Comments only where the code can't give the reason — see
[code-style.md](docs/agents/code-style.md#comments).
- Anything that redraws continuously — an animation frame, a stream — says what it
costs, or better, counts the redraws in a test.
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
- Once review starts, fix forward. Never force-push a regeneration over a review.
+6 -23
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,23 +309,6 @@ 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
-17
View File
@@ -1,17 +0,0 @@
id: "lk-jwt-service-othersite"
as_token: "ec_dev_as_token_othersite_0123456789ab"
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver-1" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
-17
View File
@@ -1,17 +0,0 @@
id: "lk-jwt-service"
as_token: "ec_dev_as_token_main_0123456789ab"
hs_token: "ec_dev_hs_token_main_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server:6080"
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
-124
View File
@@ -1,124 +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.
*/
/**
* 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;
}
+96 -6
View File
@@ -36,12 +36,14 @@ 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";
@@ -60,8 +62,10 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
configurationForIntent,
componentProperties,
type UrlConfiguration,
type UrlParams,
UrlParamsProvider,
type UrlProperties,
UserIntent,
useUrlParams,
} from "../src/UrlParams";
@@ -75,16 +79,102 @@ import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import { useComponentHostBridge } from "./host";
import { type ElementCallProps } from "./api";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// 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";
// 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;
}
/**
* Prepares the things Element Call needs before it can be shown: translations,
-4
View File
@@ -25,10 +25,6 @@
"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 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"],
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources it reaches rely on.
"include": ["./index.tsx", "../src/@types/*.d.ts"],
"exclude": []
}
+4 -28
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server
environment:
@@ -15,14 +15,6 @@ services:
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
# Registers this instance as an application service on the main homeserver
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
# location has to be given explicitly.
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
volumes:
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
deploy:
restart_policy:
condition: on-failure
@@ -33,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server-1
environment:
@@ -45,14 +37,6 @@ services:
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
# Registers this instance as an application service on the other homeserver.
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
# location has to be given explicitly.
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
volumes:
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
deploy:
restart_policy:
condition: on-failure
@@ -110,10 +94,7 @@ services:
synapse:
hostname: homeserver
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -126,16 +107,12 @@ services:
volumes:
- ./backend/synapse_tmp:/data:Z
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
networks:
- ecbackend
synapse-1:
hostname: homeserver-1
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -148,7 +125,6 @@ services:
volumes:
- ./backend/synapse_tmp_othersite:/data:Z
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
networks:
- ecbackend
+4 -10
View File
@@ -3,23 +3,17 @@
services:
synapse:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
volumes:
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
synapse-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
volumes:
- ./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:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
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:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
-9
View File
@@ -9,12 +9,3 @@ This folder contains documentation for setup, usage, and development of Element
- [MatrixRTC modes](./matrix_rtc_modes.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)
### Writing code for Element Call (agent-readable conventions)
Entry point: [AGENTS.md](../AGENTS.md).
- [Agent workflow](./agents/workflow.md)
- [Architecture](./agents/architecture.md)
- [Code style](./agents/code-style.md)
- [Testing](./agents/testing.md)
-82
View File
@@ -1,82 +0,0 @@
# Architecture
## Logic lives in view models; components render
- Call logic lives in `src/state/`. A component that derives call state is
misplaced logic.
- Composing behaviors: a factory taking `(scope, ...deps$)`, returning named
behaviors plus callbacks. Copy `src/state/LayoutSwitchViewModel.ts`. Prefer this
for anything new.
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
don't convert one to the other in passing.
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
the default for anything a view reads.
## The view model / view contract
- Components take `vm: ViewModel<Snapshot>` and read state through `useBehavior`.
See `CallFooter`, `InCallView`, `LobbyView`, `SettingsModal`.
- A snapshot is `Actions & State`; every field becomes a `foo$` behavior, and none
is optional.
- An unavailable action is `undefined`, not a separate `canDoThing` flag — the
presence of the callback drives the rendering.
- Subscribe in an effect only to drive a side effect off an event stream, as
`ReactionAudioRenderer` does; never to read state a behavior already holds.
## Scopes own lifetimes
- `ObservableScope` bounds every subscription a view model creates.
- Reference only the scope defined in the same function; one captured from an
enclosing scope outlives its owner, and `no-observablescope-leak` rejects it.
## Nothing reads the page
Element Call can be mounted several times inside a host's React tree, so it owns
neither window, URL, document nor router. Each seam defaults to the old standalone
and widget behaviour.
| Never | Use |
| --------------------------------------------- | ------------------------------------------------------------- |
| the `widget` global | `useHostBridge()` — `src/HostBridge.ts` |
| `getUrlParams()`, `window.location` | `useUrlParams()` |
| `useNavigate("/")`, `<Link to="/">` | `useLeaveToHome()`, `LeaveToHomeLink` |
| `document.body` | `useRootElement()` |
| `window.innerWidth/Height`, `useMediaQuery` | `useRootSizeMatches()` in views, `windowSize$` in view models |
| global `i18next` | the instance in `src/utils/i18n.ts` |
| config or analytics reading their environment | `Config.initWith()`, `PosthogAnalytics.configure()` |
- View models take values as options, never `getUrlParams()`
(`callViewModelOptionsFromParams`, `CallViewModelOptions.hostBridge`).
- The host bridge is the only channel to the host: `createWidgetHostBridge(widget)`,
`nullHostBridge` standalone, `useComponentHostBridge`. State a capability
(`supportsReactions`, `supportsProfileChanges`); never infer it from being a
widget.
- Shortcuts and portals attach to the root element, so two instances don't fight.
- Known debt, not precedent: `Grid` measures `window.innerHeight`; `ErrorView`'s
reload and `getAbsoluteRoomUrl` use `window.location`; recaptcha appends to
`document.body` on the standalone login path.
## Context differences are options, not checks
- Named options with per-intent defaults (`configurationForIntent`), never a
runtime check for who is hosting.
- `controlledAudioDevices`, not the platform, is what makes `MediaDevices` pick
`AndroidControlledAudioOutput` / `IOSControlledAudioOutput` over the web
`AudioOutput`. Intent presets set it on non-desktop; standalone leaves it off.
- URL params are a published contract: change one, update `docs/url_params.md`.
## Build targets
Code that builds in only one is a bug.
- `build:full` — standalone app, also widget mode.
- `build:embedded` — `@element-hq/element-call-embedded`.
- `build:sdk` — SDK library, entry `sdk/main.ts`.
- `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. 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`.
-95
View File
@@ -1,95 +0,0 @@
# Code style
_Clean Code_ (Robert C. Martin): small functions, intention-revealing names, low
noise.
## Reuse before you build
Look before you write, in this order:
1. **Compound Web** — `@vector-im/compound-web` for buttons, tooltips, alerts,
dialogs, menus, form controls, avatars; icons in
`@vector-im/compound-design-tokens/assets/web/icons`.
2. **This repo** — `src/button/`, `src/components/`, `src/input/`, `src/form/`,
`src/tabs/`, and `Modal`, `Avatar`, `Toast`, `Slider`, `ErrorView`,
`FullScreenView` at the root of `src/`.
3. **The nearest existing feature** — grep for the same shape first. A hook is
often what you want to extract, not a component.
If nothing fits, name the new shared component in the PR body and say what you
rejected and why — never add one silently. Extend or parameterise before forking,
and extract shared logic into a hook both callers use before copying it; if you
copy, the PR says so.
## Ordering
- **Newspaper order.** Headline first, detail down. A file opens with what it
exists to provide.
- **Stepdown rule.** Caller above callee. A helper used by one function sits
beneath it, which needs a `function` declaration, not a `const` arrow. Existing
arrow components above their callers are not a pattern to extend — nor to churn.
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
rule in new ones, don't reorder old ones.
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
`computedLayout$`.
## Comments
No comment is the default. One earns its place by giving a reason the code
cannot: a platform quirk, an outside constraint, a trap that looks like a
simplification.
- Say the reason, then stop. Test each sentence: does the reason still stand
without it? If so, cut it. Most reasons fit on a line, so a long comment is
worth a second look — it is usually carrying something that isn't one.
- Write about the line the comment sits on, in terms of what the code does
there — leave layout to the stylesheet and measurements to the design.
- Name the condition ("where the platform can't route audio"), not the browser
or the spec entry.
- Put history — what was tried, removed or rejected, the dev journal — in the
commit or the PR description.
- In tests, comment only structure that looks wrong but isn't: batching, timers,
why this scroll position.
Before keeping one: would it still be true after a CSS change or a refactor of
its caller? If not, cut it.
## TypeScript, React and RxJS idiom win on a clash
- Hooks stay unconditional at the top of a component.
- `useCallback` / `useMemo` dependency arrays sometimes force inlining.
- An RxJS pipeline stays one expression. Name the behavior, not each operator.
- A long factory is fine when it reads as a list of named behaviors.
`CallViewModel.ts` is long because the domain is.
- Marble test tables are dense on purpose.
## Enforced by lint
- Copyright header on every file: `Copyright <current year> Element Creations Ltd.`
plus the AGPL / commercial SPDX line.
- `logger` from `matrix-js-sdk/lib/logger`, never `console`, and no top-level
`logger.getChild()`. The `console` ban is only enforced under `src/*/**`.
- No floating or misused promises; async functions typed `Promise<T>`.
- Inline type imports (`import { type Foo }`), so matrix-js-sdk stays lazily
loadable.
- Deep-import `matrix-js-sdk/lib/<module>` as the codebase does. Banned is the bare
`matrix-js-sdk/lib`, `lib/index` and anything under `src/`.
## CSS
- Compound components and `--cpd-*` tokens in CSS modules. A hardcoded colour or px
spacing is a design question, not a licence to inline a hex.
- Size against the root, not the window: `@container element-call (…)` and
`cqw` / `cqh`, never `@media (width)` or `vw` / `vh`. Media queries stay correct
only in standalone-only views — home, login.
- Style `[data-element-call-root]`, never `body` or `:root`; the component build
makes those stand for the root (`component/build/scopeStylesToRoot.ts`).
## Strings and a11y
- Strings through `t()`: add the key, run `pnpm i18n`, fill `locales/en/app.json`.
Other locales come from Localazy; never hand-edit them.
- `t` from `useTranslation()`, or `src/utils/i18n.ts` outside React. Never the
`i18next` global — several instances share a page.
- Accessible names on controls, `aria-pressed` on toggles, keyboard reachability.
`jsx-a11y` rules are errors; `vitest-axe` is available.
-53
View File
@@ -1,53 +0,0 @@
# Testing
Three layers; a user-facing feature needs all three. Unit proves the logic,
Storybook proves the rendering, e2e proves the feature. Codecov gates 80% on the
lines a PR touches.
## Unit
- View model tests use `withTestScheduler` + `testScope()` from `src/utils/test.ts`
and read as ASCII timelines. Copy `LayoutSwitchViewModel.test.ts`.
- Component tests use Testing Library, queried by role and accessible name.
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
- How often something redraws is testable: drive the frames and count commits, not
render calls. An effect with no dependency array runs once per commit.
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
## Storybook
A deliverable, not documentation: `pnpm test:storybook` runs every story as a real
browser test in the same CI job as the unit suite. **A UI change without a story is
incomplete.** Only three stories exist so far, so copy `CallFooter.stories.tsx`.
- Drive the component from a snapshot via `useStaticViewModel`, never internals.
- One named story per state that matters — loading, error, empty, denied, mobile.
- Assert in a `play` function with `userEvent` / `expect` from `storybook/test`.
- Mobile is `globals: { viewport: { value: "mobile2" } }`, not a second component.
- Expose interesting props through `argTypes`.
`.storybook/preview.tsx` supplies `TooltipProvider`, `src/index.css` and English
translations. The browser is Playwright's, so a fresh clone needs
`pnpm playwright install` once.
## End-to-end
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
- `playwright/widget/` — widget mode, where cross-context bugs surface.
- `playwright/component/` — the component in a host page, via the harness on port
3001 that Playwright starts as a second web server. Catches container-relative
layout, styles escaping the root, two instances on a page, host-bridge reports.
- `playwright/mobile/` — Pixel 7, `mobile` project only.
Test what a user observes: the peer sees the change, it survives a reconnect, it is
right after a reload. Reuse `playwright/spa-helpers.ts`,
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
```sh
pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
pnpm test:playwright # or :open
pnpm dev:component # component harness, port 3001
```
-50
View File
@@ -1,50 +0,0 @@
# Agent workflow
## Scratch files live in `agent-workspace/`
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
`agent-workspace/<slug>/`.
| File | Purpose |
| --------------------------- | -------------------------------------------------------------------------------- |
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
| `implementation-summary.md` | What was built, decisions, trade-offs |
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
- Nothing here is durable. The folder is ignored, so anything worth keeping is
promoted into the PR body, a doc, or a feature spec before the task ends.
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
`config.json_` files are what this folder prevents.
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
section.
- Commit subjects are plain imperative English. No conventional-commits prefixes.
- A `plan.md` and a feature spec sit at different altitudes; neither replaces the
other. A spec in `FEATURES_SPEC/` is durable and deliberately abstract —
behaviour, decisions, acceptance criteria — so the feature can be rebuilt against
a `main` nobody has seen yet. A plan is one slice of it landing on today's
`main`: the paths, symbols and edit order the spec's decisions and criteria must
not name. `FEATURES_SPEC/AGENTS.md` wins wherever the two genuinely overlap.
## Hand off before the quality pass
- Implement the change, run the narrowest check that rules out an obviously broken
handoff, then stop and ask whether the direction is right.
- Full `pnpm lint`, the whole suite, coverage and benchmarks come after the
direction is confirmed.
- Wider refactors, extra tests and documentation polish are follow-up work, not
part of the first handoff.
- If a check is needed before feedback, keep it to the touched code and say why.
## Commit and PR readiness
- Commit once the user confirms direction, or asks for one. Not before. Where the
setup has no git identity, a sandbox included, write the message to
`commit-msg.txt` and hand it over instead.
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
against [code-style.md](./code-style.md) first.
- Re-run the whole checklist after any fix. Only on green, either way.
- Once a human has started reviewing, fix forward — never force-push a
regeneration over a review in progress.
+1 -1
View File
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
token from the Matrix 2.0 JWT endpoint of the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
and identifies the room by a hashed identity instead of the Matrix `room_id`.
and identifies the room by a hashed identity instead of a `livekit_alias`.
(Remote memberships always try the new endpoint first and fall back to the
legacy one, so remote participants can be on either.)
+7 -11
View File
@@ -152,10 +152,15 @@
"layout_switch_label": "Layout",
"lobby": {
"ask_to_join": "Request to join call",
"cancel_request": "Cancel request",
"invite_only_body": "You need an invite to join this call.",
"join_as_guest": "Join as guest",
"join_button": "Join call",
"joining": "Joining…",
"leave_button": "Back to recents",
"waiting_for_invite": "Request sent! Waiting for permission to join…"
"request_accepted": "Your request to join was accepted.",
"request_sent": "Request to join sent",
"request_sent_body": "You will receive an invite to join the call if your request is accepted."
},
"log_in": "Log In",
"logging_in": "Logging in…",
@@ -163,12 +168,6 @@
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"microphone_level": {
"label": "Microphone level",
"no_device": "No microphone found. Connect one, then try again.",
"permission_denied": "Microphone access is blocked. Allow it, then try again.",
"value": "{{level}} of {{max}}"
},
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
@@ -196,7 +195,7 @@
"register_heading": "Create your account",
"return_home_button": "Return to home screen",
"room_auth_view_continue_button": "Continue",
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"room_auth_view_ssla_caption": "By clicking \"Continue\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"screenshare_button_label": "Share screen",
"settings": {
"advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
@@ -221,14 +220,11 @@
"activating": "Activating…",
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"camera_source": "Camera Source",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"default_named_plain": "Default ({{name}})",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"mic_source": "Mic Source",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",
+3 -2
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 --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite 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",
@@ -83,7 +83,7 @@
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^10.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
@@ -107,6 +107,7 @@
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.66.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
-229
View File
@@ -1,229 +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, test, type Locator, type Page } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
import { installFakeDevices } from "./utils/fake-devices.ts";
test.describe("the quick audio menu", () => {
test("lists speakers and microphones with a live level meter", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 3, speakers: 3 });
await joinACall(page, "Menu user", "Audio menu");
await openAudioMenu(page);
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
// By name, not count: the browser adds its own fake output and a Default.
for (const n of [1, 2, 3])
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
).toBeVisible();
await expect(
page.getByRole("menuitemradio", { checked: true }),
).toHaveCount(2);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
// Both browsers here can route audio; the platform that can't is a unit check.
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio")
.first(),
).toHaveAttribute("aria-disabled", "false");
});
test("moves the microphone and the speaker without disturbing the call", async ({
browser,
}) => {
// Two browsers and a real call.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host, { microphones: 3, speakers: 3 });
await joinACall(host, "Host", "Device switch");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
await SpaHelpers.expectVideoTilesCount(guest, 2);
await openAudioMenu(host);
await selectDevice(host, "Microphone", "Fake Microphone 2");
await openAudioMenu(host);
await selectDevice(host, "Speaker", "Fake Speaker 2");
// Not a rejoin: neither side drops, and the guest still has both tiles.
await expect(
host.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await expect(
guest.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await SpaHelpers.expectVideoTilesCount(guest, 2);
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter moving while muted, and sends nothing", async ({
browser,
}) => {
// Two browsers and a real call.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host);
await joinACall(host, "Muted host", "Muted meter");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
await SpaHelpers.expectVideoTilesCount(guest, 2);
const mute = host.getByTestId("incall_mute");
await mute.click();
await expect(mute).toHaveAttribute("aria-checked", "false");
await openAudioMenu(host);
const meter = host.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// By test id: the modal menu hides the rest of the call from the a11y tree.
await expect(mute).toHaveAttribute("aria-checked", "false");
await expect(mute).toBeVisible();
// And the guest is shown the mute.
await expect(
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
).toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter at the foot of the list while it scrolls", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
await joinACall(page, "Scroller", "Long device list");
await openAudioMenu(page);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// Scrolled so the microphones start at the top: only there does a pinned
// meter differ from one that is simply last.
const list = page.locator("[role='menu'] div[role='none']").first();
await list.evaluate((element) => {
const group = element.querySelector("[role='group'][aria-label*='icro']");
element.scrollTop +=
group!.getBoundingClientRect().top -
element.getBoundingClientRect().top;
});
await expect(meter).toBeInViewport();
await expectPinnedInside(meter, list);
await expect(
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
).toBeVisible();
});
test("shows the focus ring only when the keyboard moved the focus", async ({
page,
browserName,
}) => {
test.skip(
browserName === "firefox",
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
);
await installFakeDevices(page);
await joinACall(page, "Keyboard user", "Focus ring");
await openAudioMenu(page);
const first = page.getByRole("menuitemradio").first();
// Opened by pointer: no ring, though Radix has moved focus into the menu.
await expect.poll(async () => outlineWidth(first)).toBe(0);
await page.keyboard.press("ArrowDown");
const focused = page.locator("[role='menuitemradio']:focus");
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
// And the pointer takes it away again.
await first.hover();
await expect.poll(async () => outlineWidth(focused)).toBe(0);
});
});
/** Creates a call and joins it, leaving the page in the call. */
async function joinACall(
page: Page,
userName: string,
callName: string,
): Promise<void> {
await page.goto("/");
await SpaHelpers.createCall(page, userName, callName, true);
await expect(page.getByTestId("name_tag")).toContainText(userName);
// The media controls stay disabled until devices have enumerated.
await expect(page.getByTestId("incall_mute")).toBeEnabled({
timeout: 10_000,
});
}
async function openAudioMenu(page: Page): Promise<void> {
await page.getByRole("button", { name: "Microphone" }).click();
await expect(page.getByRole("menu")).toBeVisible();
}
async function selectDevice(
page: Page,
section: "Speaker" | "Microphone",
name: string,
): Promise<void> {
const item = page
.getByRole("group", { name: section })
.getByRole("menuitemradio", { name });
await item.click();
// Selecting doesn't close the menu, so dismiss it.
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).not.toBeVisible();
}
/** Asserts the meter sits within the scrollport and inside the menu's frame. */
async function expectPinnedInside(
meter: Locator,
list: Locator,
): Promise<void> {
const meterBox = (await meter.boundingBox())!;
const listBox = (await list.boundingBox())!;
const frame = (await meter.page().getByRole("menu").boundingBox())!;
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
listBox.y + listBox.height + 1,
);
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
expect(meterBox.x).toBeGreaterThan(frame.x);
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
}
/** Painted outline width, in px. */
async function outlineWidth(item: Locator): Promise<number> {
if ((await item.count()) === 0) return 0;
return item.first().evaluate((element) => {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
});
}
-135
View File
@@ -1,135 +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, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { installFakeDevices } from "../utils/fake-devices.ts";
/**
* The device menu with Element Call as a component in a host page, where the
* portalled menu must be sized against the call, not the window. Driven from
* the lobby: it builds the same menu, without two connections' worth of flake.
*/
// Sign-in, crypto setup and sync happen twice before anything shows.
test.describe.configure({ timeout: 180_000 });
test("sizes the device list against the call, not the window", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menusize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
// A short call in a much taller page.
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 400 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const callHeight = (await container.boundingBox())!.height;
const windowHeight = page.viewportSize()!.height;
const listHeight = (await list.boundingBox())!.height;
expect(callHeight).toBeLessThan(windowHeight * 0.75);
expect(listHeight).toBeLessThanOrEqual(callHeight);
expect(listHeight).toBeLessThan(windowHeight * 0.6);
});
test("follows the call area when the host resizes it", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menuresize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const whenShort = (await list.boundingBox())!.height;
// The host grows the call while the menu is open.
await resizeContainer(container, { width: 900, height: 700 });
await expect
.poll(async () => (await list.boundingBox())!.height)
.toBeGreaterThan(whenShort);
});
test("keeps every device reachable in a small container", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menureach");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
// More devices than fit, so the list must scroll.
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
true,
);
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport();
// Reachable means clickable: the menu may be drawn outside the call area.
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
test("tracks the focus source of its own call, not the page", async ({
page,
}) => {
await installFakeDevices(page);
const { username, roomId } = await createUserAndRoom("menufocus");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const other = panes.nth(1);
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
await openDeviceList(page, pane);
const menu = page.getByRole("menu");
// Asserted on the attribute: the component build's scoped stylesheet doesn't
// reach the portalled menu, so its ring can't be read here.
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in the other call says nothing about this menu.
await other.evaluate((element) =>
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
),
);
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
});
/** Opens a component's microphone menu and returns its device list. */
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
await pane
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
const list = page.locator("[role='menu'] div[role='none']").first();
await expect(list).toBeVisible();
return list;
}
-3
View File
@@ -114,8 +114,6 @@ test("Should show error screen if call creation is restricted", async ({
}),
);
await page.pause();
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
@@ -125,7 +123,6 @@ test("Should show error screen if call creation is restricted", async ({
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.pause();
// Should fail
await expect(page.getByText("Failed to create call")).toBeVisible();
await expect(
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
import { HOMESERVER_URL } from "../utils/matrix-api.ts";
/** A user of the Playwright backend's homeserver. */
export interface SpaUser {
/** The localpart actually registered, which carries a unique suffix. */
username: string;
mxId: string;
accessToken: string;
}
export interface SpaFixtures {
/** Registers a user over the admin API, without a browser session. */
registerUser: (username: string) => Promise<SpaUser>;
/** A user registered and logged in to Element Call on the test's page. */
spaUser: SpaUser;
}
const PASSWORD = "password1!";
// A dev server that was already running keeps whatever config.json it was
// started with, since Playwright reuses it rather than installing the one it
// wants, so the tests serve the config they assert against.
const CONFIG_PATH = fileURLToPath(
new URL("../../config/config.devenv.json", import.meta.url),
);
export const spaTest = test.extend<SpaFixtures>({
context: async ({ context }, use) => {
await context.route(
"**/config.json",
async (route) => await route.fulfill({ path: CONFIG_PATH }),
);
await use(context);
},
registerUser: async ({ browserName }, use) => {
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
await use(async (username: string): Promise<SpaUser> => {
// Every browser and worker registers against the one homeserver.
const unique = `${username}_${browserName}_${randomUUID().slice(0, 8)}`;
const registered = await admin.registerUser(unique, PASSWORD);
return {
username: unique,
mxId: registered.user_id,
accessToken: registered.access_token,
};
});
},
spaUser: async ({ page, registerUser }, use) => {
const user = await registerUser("caller");
await page.goto("/");
await page.getByTestId("home_login").click();
await page.getByTestId("login_username").fill(user.username);
await page.getByTestId("login_password").fill(PASSWORD);
await page.getByTestId("login_login").click();
await expect(
page.getByRole("heading", { name: "Start new call" }),
).toBeVisible();
await use(user);
},
});
+4 -13
View File
@@ -109,15 +109,10 @@ 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(
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -157,12 +152,8 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("button", { name: "Open room" }).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+177
View File
@@ -0,0 +1,177 @@
/*
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 } from "@playwright/test";
import { spaTest as test, type SpaUser } from "./fixtures/spa-user.ts";
import {
banUser,
createCallRoom,
inviteUser,
kickUser,
membershipOf,
} from "./utils/matrix-api.ts";
/** The app URL for a call known only by its room ID. */
const callUrl = (roomId: string): string =>
`/room/#?roomId=${encodeURIComponent(roomId)}`;
/** Reads the membership the server holds for `user`, as a moderator sees it. */
const membershipPoll =
(moderator: SpaUser, roomId: string, user: SpaUser) =>
async (): Promise<string | undefined> =>
await membershipOf(moderator.accessToken, roomId, user.mxId);
test("Ask to join a call, withdraw, ask again and be let in", async ({
page,
spaUser,
registerUser,
}) => {
test.slow();
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Knock call",
joinRule: "knock",
});
const membership = membershipPoll(moderator, roomId, spaUser);
await page.goto(callUrl(roomId));
const joinButton = page.getByTestId("lobby_joinCall");
await expect(joinButton).toHaveText("Request to join call");
await expect(joinButton).toBeEnabled();
await expect(page.locator("video")).toBeVisible();
await joinButton.click();
await expect(joinButton).toHaveText("Request to join sent");
await expect(joinButton).toBeDisabled();
await expect(page.getByTestId("lobby_joinMessage")).toContainText(
"You will receive an invite to join the call if your request is accepted.",
);
await expect.poll(membership).toBe("knock");
await page.getByTestId("lobby_cancelRequest").click();
await expect(joinButton).toHaveText("Request to join call");
await expect(joinButton).toBeEnabled();
await expect(page.getByTestId("lobby_joinMessage")).toBeHidden();
await expect.poll(membership).toBe("leave");
await joinButton.click();
await expect(joinButton).toHaveText("Request to join sent");
await expect.poll(membership).toBe("knock");
await inviteUser(moderator.accessToken, roomId, spaUser.mxId);
// The accepted request joins the room and skips a second lobby.
await expect(page.getByTestId("incall_leave")).toBeVisible({
timeout: 60_000,
});
await expect(joinButton).toBeHidden();
});
test("A declined request is shown in the lobby", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Declining call",
joinRule: "knock",
});
await page.goto(callUrl(roomId));
await page.getByTestId("lobby_joinCall").click();
await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock");
await kickUser(moderator.accessToken, roomId, spaUser.mxId);
const message = page.getByTestId("lobby_joinMessage");
await expect(message).toContainText("Access denied");
await expect(message).toContainText("Your request to join was declined.");
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
// The camera preview outlives the refusal.
await expect(page.locator("video")).toBeVisible();
});
test("A ban is shown in the lobby, with its reason", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Banning call",
joinRule: "knock",
});
await page.goto(callUrl(roomId));
await page.getByTestId("lobby_joinCall").click();
await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock");
await banUser(moderator.accessToken, roomId, spaUser.mxId, "Wrong call");
const message = page.getByTestId("lobby_joinMessage");
await expect(message).toContainText("Banned");
await expect(message).toContainText("You have been banned from the room.");
await expect(message).toContainText("Reason: Wrong call");
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
});
test("A call that takes no requests says so in the lobby", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
// Synapse serves an MSC3266 summary for knock and knock_restricted rooms
// only, so a `knock_restricted` room, whose rule Element Call cannot act on
// without knowing the user's other rooms, is the one shape that reaches the
// lobby with nothing to offer.
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Space call",
joinRule: "knock_restricted",
});
await page.goto(callUrl(roomId));
await expect(page.getByTestId("lobby_joinMessage")).toContainText(
"You need an invite to join this call.",
);
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
await expect(page.locator("video")).toBeVisible();
});
test("An invite-only call cannot be reached at all", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Private call",
joinRule: "invite",
});
await page.goto(callUrl(roomId));
// Synapse serves no room summary for an invite-only room, so Element Call
// falls back to joining as if the room were public and the refusal lands on
// the error page rather than in the lobby.
await expect(
page.getByRole("heading", { name: "Something went wrong" }),
).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
});
-8
View File
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
@@ -19,12 +17,6 @@ test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
// The reconnecting state is entered via the probablyLeft timer, which
// mirrors the delayed leave event's timeout. With delegation that timeout
// is one hour, putting it out of reach of the clock fast-forward below.
// Keep the leave client-managed so its short timeout applies.
await SpaHelpers.disableLeaveDelegation(page);
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
+1 -11
View File
@@ -19,7 +19,6 @@ async function setupTwoUserSpaCall(
browser: Browser,
page: Page,
browserName: string,
opts: { disableGuestLeaveDelegation?: boolean } = {},
): Promise<{ guestPage: Page }> {
test.skip(
browserName === "firefox",
@@ -53,9 +52,6 @@ async function setupTwoUserSpaCall(
await guestPage.goto("/");
if (opts.disableGuestLeaveDelegation)
await SpaHelpers.disableLeaveDelegation(guestPage);
let pevaraHasSentStickyEvent = false;
const pevaraResolver = Promise.withResolvers<void>();
@@ -106,13 +102,7 @@ test("One to One rejoin after improper leave does not crash EC", async ({
page,
browserName,
}) => {
// With delegation, the backend sends the guest's delayed leave event within
// moments of the improper leave, so the stale membership this test needs
// would be cleaned up before the rejoin. Keep the guest's leave
// client-managed so the stale membership lingers.
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
disableGuestLeaveDelegation: true,
});
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
-19
View File
@@ -111,24 +111,6 @@ async function setRtcModeFromSettings(
await page.getByTestId("modal_close").click();
}
/**
* Makes the delayed-leave delegation support probes fail so that the client
* manages its delayed leave event itself instead of delegating it to the
* backend.
*
* Must be installed before the page joins a call.
*/
async function disableLeaveDelegation(page: Page): Promise<void> {
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
await page.route("**/delegate_delayed_leave", async (route) =>
route.fulfill({
status: 404,
headers: { "Access-Control-Allow-Origin": "*" },
}),
);
}
/**
* Expect a certain number of video tiles to be present and visible.
*/
@@ -151,6 +133,5 @@ export const SpaHelpers = {
createCall,
getCallInviteLink,
joinCallFromInviteLink,
disableLeaveDelegation,
expectVideoTilesCount,
};
-85
View File
@@ -1,85 +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 { type Page } from "@playwright/test";
/**
* Adds synthetic devices beside the browser's one fake microphone and speaker,
* so the menu has a choice to show. They share one capture and `setSinkId` is
* accepted but not honoured: routing still needs hardware and a manual check.
* Call before the page navigates.
*/
export async function installFakeDevices(
page: Page,
{ microphones = 2, speakers = 2 } = {},
): Promise<void> {
await page.addInitScript(
({ microphones, speakers }) => {
const synthetic = (
kind: MediaDeviceKind,
count: number,
name: string,
): MediaDeviceInfo[] =>
Array.from({ length: count }, (_, i) => {
const info = {
deviceId: `${kind}-${i + 1}`,
groupId: `${kind}-group-${i + 1}`,
kind,
label: `${name} ${i + 1}`,
};
return { ...info, toJSON: () => info } as MediaDeviceInfo;
});
const ids = new Set(
[
...synthetic("audioinput", microphones, ""),
...synthetic("audiooutput", speakers, ""),
].map((d) => d.deviceId),
);
const devices = navigator.mediaDevices;
const enumerate = devices.enumerateDevices.bind(devices);
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
...(await enumerate()),
...synthetic("audioinput", microphones, "Fake Microphone"),
...synthetic("audiooutput", speakers, "Fake Speaker"),
];
// Our ids name no hardware, so drop the exact-device constraint.
const getUserMedia = devices.getUserMedia.bind(devices);
devices.getUserMedia = async (
constraints?: MediaStreamConstraints,
): Promise<MediaStream> => {
const audio = constraints?.audio;
if (typeof audio === "object") {
const requested = audio.deviceId;
const id =
typeof requested === "object" && requested !== null
? ((requested as ConstrainDOMStringParameters).exact as string)
: (requested as string | undefined);
if (id !== undefined && ids.has(id))
return getUserMedia({ ...constraints, audio: true });
}
return getUserMedia(constraints);
};
// Accept routing to our ids on both media elements and the AudioContext,
// which the app also routes.
for (const proto of [
HTMLMediaElement.prototype,
AudioContext.prototype,
]) {
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
const setSinkId = sink.setSinkId;
if (setSinkId === undefined) continue;
sink.setSinkId = async function (id: string): Promise<void> {
if (!ids.has(id)) await setSinkId.call(this, id);
};
}
},
{ microphones, speakers },
);
}
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/** The homeserver of the Playwright backend, as `config.devenv.json` names it. */
export const HOMESERVER_URL = "https://synapse.m.localhost";
/** MSC3417: the room type Element Call gives its calls. */
const CALL_ROOM_TYPE = "org.matrix.msc3417.call";
/** The state event every call participant sends to announce their membership. */
const RTC_MEMBER_EVENT_TYPE = "org.matrix.msc3401.call.member";
async function csApiRequest(
accessToken: string,
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<Response> {
const init: RequestInit = {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
};
if (body !== undefined) init.body = JSON.stringify(body);
return await fetch(`${HOMESERVER_URL}/_matrix/client/v3/${path}`, init);
}
async function csApi<T>(
accessToken: string,
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const response = await csApiRequest(accessToken, method, path, body);
if (!response.ok)
throw new Error(
`${method} ${path} failed: ${response.status} ${await response.text()}`,
);
return (await response.json()) as T;
}
/**
* Create a room that Element Call sees as a call, and that anyone who gets in
* may participate in. The creator is its only moderator, and `joinRule` is
* written as `m.room.join_rules` over whatever the preset would set.
*
* @returns The room's ID
*/
export async function createCallRoom({
accessToken,
name,
joinRule,
}: {
accessToken: string;
name: string;
joinRule: string;
}): Promise<string> {
const created = await csApi<{ room_id: string }>(
accessToken,
"POST",
"createRoom",
{
name,
preset: "private_chat",
creation_content: { type: CALL_ROOM_TYPE },
initial_state: [
{
type: "m.room.join_rules",
state_key: "",
content: { join_rule: joinRule },
},
],
power_level_content_override: {
events: { [RTC_MEMBER_EVENT_TYPE]: 0 },
},
},
);
return created.room_id;
}
export async function inviteUser(
accessToken: string,
roomId: string,
userId: string,
): Promise<void> {
await csApi(
accessToken,
"POST",
`rooms/${encodeURIComponent(roomId)}/invite`,
{
user_id: userId,
},
);
}
export async function kickUser(
accessToken: string,
roomId: string,
userId: string,
): Promise<void> {
await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/kick`, {
user_id: userId,
});
}
export async function banUser(
accessToken: string,
roomId: string,
userId: string,
reason?: string,
): Promise<void> {
await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/ban`, {
user_id: userId,
reason,
});
}
/**
* Read a user's membership as the server reports it, from the room state, so
* that knocks and leaves are visible and not just joins.
*
* @returns The membership, or undefined while the user has no member event
*/
export async function membershipOf(
accessToken: string,
roomId: string,
userId: string,
): Promise<string | undefined> {
const response = await csApiRequest(
accessToken,
"GET",
`rooms/${encodeURIComponent(roomId)}/state/m.room.member/${encodeURIComponent(userId)}`,
);
if (response.status === 404) return undefined;
if (!response.ok)
throw new Error(
`Reading the membership of ${userId} failed: ${response.status} ${await response.text()}`,
);
return ((await response.json()) as { membership: string }).membership;
}
-2
View File
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
-2
View File
@@ -129,8 +129,6 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "true");
await carol.page.pause();
// now click on next
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click();
+4 -27
View File
@@ -11,7 +11,6 @@ import {
type JSHandle,
type Page,
type FrameLocator,
type Locator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
@@ -204,16 +203,6 @@ 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,
@@ -264,9 +253,9 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -361,17 +350,6 @@ 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
@@ -381,8 +359,7 @@ export class TestHelpers {
page: Page,
roomName: string,
): Promise<void> {
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click();
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
+101 -71
View File
@@ -39,13 +39,13 @@ importers:
version: 11.7.12
'@livekit/components-core':
specifier: ^0.12.0
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-react':
specifier: ^2.0.0
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
'@livekit/track-processors':
specifier: ^0.7.1
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
'@mediapipe/tasks-vision':
specifier: ^0.10.18
version: 0.10.35
@@ -131,11 +131,11 @@ importers:
specifier: ^10.2.11
version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: ^11.0.0
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
specifier: ^10.0.0
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
specifier: ^10.0.0
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -183,7 +183,7 @@ importers:
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
livekit-client:
specifier: ^2.18.1
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
lodash-es:
specifier: ^4.17.21
version: 4.18.1
@@ -192,7 +192,7 @@ importers:
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
matrix-widget-api:
specifier: ^1.18.0
version: 1.19.0
@@ -202,6 +202,9 @@ importers:
normalize.css:
specifier: ^8.0.1
version: 8.0.1
observable-hooks:
specifier: ^4.2.3
version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2)
oxfmt:
specifier: ^0.66.0
version: 0.66.0
@@ -222,7 +225,7 @@ importers:
version: 10.6.1(postcss@8.5.26)
postcss-selector-parser:
specifier: ^7.1.1
version: 7.1.3
version: 7.1.1
posthog-js:
specifier: 1.408.2
version: 1.408.2
@@ -1194,8 +1197,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -3226,8 +3229,8 @@ packages:
peerDependencies:
react: '>= 16.8.0'
'@vector-im/compound-design-tokens@11.0.0':
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
'@vector-im/compound-design-tokens@10.2.4':
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*'
@@ -3243,8 +3246,8 @@ packages:
react:
optional: true
'@vector-im/compound-web@10.1.0':
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
'@vector-im/compound-web@10.0.1':
resolution: {integrity: sha512-CT3aGMalPSf53o3JUbBF7EA10Rfp+auftIuBwOUSna+hfZ/zPxGLTFcuCuOJ7L8hrzijM+V4I8L0PUkfyOczpg==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
@@ -3659,8 +3662,8 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
content-type@3.1.1:
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
content-type@3.0.0:
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
engines: {node: '>=22'}
convert-source-map@2.0.0:
@@ -4602,8 +4605,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
livekit-client@2.22.3:
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
livekit-client@2.22.2:
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
@@ -4680,9 +4683,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
resolution: {gitHosted: true, integrity: sha512-qF8++74MX36KS1KEw6Lc8HHXgZ6nHD1oUU4yBZUj5vVPvZTIspk1lOGztkFmizE5vmLAHd5tdadL9Xu1Asy4bA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999}
version: 42.3.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
version: 42.2.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.19.0:
@@ -4813,6 +4816,13 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'}
observable-hooks@4.2.4:
resolution: {integrity: sha512-FdTQgyw1h5bG/QHCBIqctdBSnv9VARJCEilgpV6L2qlw1yeLqFIwPm4U15dMtl5kDmNN0hSt+Nl6iYbLFwEcQA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
rxjs: '>=6.0.0'
obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
@@ -4895,8 +4905,8 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-retry@8.0.1:
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
p-retry@8.0.0:
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
engines: {node: '>=22'}
p-try@2.2.0:
@@ -5175,8 +5185,8 @@ packages:
peerDependencies:
postcss: ^8.4
postcss-selector-parser@7.1.3:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
@@ -6130,6 +6140,18 @@ packages:
utf-8-validate:
optional: true
ws@8.21.1:
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
@@ -6415,9 +6437,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies:
@@ -6523,9 +6545,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies:
@@ -6627,7 +6649,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies:
@@ -6671,13 +6693,13 @@ snapshots:
dependencies:
postcss: 8.5.26
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
'@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies:
@@ -7045,7 +7067,7 @@ snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
@@ -7067,23 +7089,23 @@ snapshots:
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.7.6
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
loglevel: 1.9.1
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.9
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
@@ -7095,13 +7117,13 @@ snapshots:
dependencies:
'@bufbuild/protobuf': 1.10.1
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
dependencies:
'@mediapipe/tasks-vision': 0.10.35
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
dependencies:
@@ -8685,12 +8707,12 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.8
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8702,7 +8724,7 @@ snapshots:
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1
react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8743,7 +8765,7 @@ snapshots:
sirv: 3.0.2
tinyrainbow: 3.1.1
vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
ws: 8.21.3
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- msw
@@ -9163,7 +9185,7 @@ snapshots:
constants-browserify@1.0.0: {}
content-type@3.1.1: {}
content-type@3.0.0: {}
convert-source-map@2.0.0: {}
@@ -9234,13 +9256,13 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
@@ -10170,7 +10192,7 @@ snapshots:
lines-and-columns@1.2.4: {}
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.50.4
@@ -10225,7 +10247,7 @@ snapshots:
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.8:
dependencies:
@@ -10249,17 +10271,17 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
dependencies:
'@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 3.1.1
content-type: 3.0.0
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.19.0
p-retry: 8.0.1
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
@@ -10400,6 +10422,12 @@ snapshots:
has-symbols: 1.1.0
object-keys: 1.1.1
observable-hooks@4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
rxjs: 7.8.2
obug@2.1.4: {}
once@1.4.0:
@@ -10584,7 +10612,7 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-retry@8.0.1:
p-retry@8.0.0:
dependencies:
is-network-error: 1.3.2
@@ -10694,7 +10722,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-clamp@4.1.0(postcss@8.5.26):
dependencies:
@@ -10745,12 +10773,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
dependencies:
@@ -10762,12 +10790,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies:
@@ -10799,10 +10827,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26):
dependencies:
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
dependencies:
@@ -10900,7 +10928,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
dependencies:
@@ -10909,9 +10937,9 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.1
postcss-selector-parser@7.1.3:
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -11851,6 +11879,8 @@ snapshots:
ws@8.21.0: {}
ws@8.21.1: {}
ws@8.21.3: {}
wsl-utils@0.1.0:
+87 -1
View File
@@ -6,8 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
import {
type ITransport,
type WidgetApi,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import EventEmitter from "events";
import { KnownMembership, MatrixError } from "matrix-js-sdk";
import { type Observable } from "rxjs";
@@ -17,6 +23,7 @@ import {
nullHostBridge,
} from "./HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
import { MembershipUnsupportedError } from "./room/membership";
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
return {
@@ -217,6 +224,81 @@ describe("createWidgetHostBridge", () => {
});
});
describe("changeMembership", () => {
const bridgeReplying = (
send: ReturnType<typeof vi.fn>,
): ReturnType<typeof createWidgetHostBridge> =>
createWidgetHostBridge(
mockWidget({ transport: { send } as unknown as ITransport }),
);
test("asks the host to knock, and reports what it ended up with", async () => {
const send = vi
.fn()
.mockResolvedValue({ membership: KnownMembership.Knock });
const bridge = bridgeReplying(send);
await expect(
bridge.changeMembership!({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(send).toHaveBeenCalledWith("io.element.membership", {
action: "knock",
reason: "let me in",
});
});
test("turns a refusal by the homeserver into a MatrixError", async () => {
const bridge = bridgeReplying(
vi.fn().mockRejectedValue(
new WidgetApiResponseError("Failed to knock", {
matrix_api_error: {
http_status: 403,
http_headers: {},
url: "/_matrix/client/v3/knock/!call:example.org",
response: {
errcode: "M_FORBIDDEN",
error: "You are not invited",
},
},
}),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toSatisfy(
(error) =>
error instanceof MatrixError && error.errcode === "M_FORBIDDEN",
);
});
test("reports a host that does not implement the action", async () => {
const bridge = bridgeReplying(
vi
.fn()
.mockRejectedValue(
new WidgetApiResponseError(
"Unknown or unsupported from-widget action: io.element.membership",
{},
),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toBeInstanceOf(MembershipUnsupportedError);
});
test("passes a transport failure through", async () => {
const timeout = new Error("Request timed out");
const bridge = bridgeReplying(vi.fn().mockRejectedValue(timeout));
await expect(bridge.changeMembership!({ action: "join" })).rejects.toBe(
timeout,
);
});
});
describe("close", () => {
test("asks the host to close, then stops the transport", async () => {
const transport = {
@@ -296,6 +378,10 @@ describe("nullHostBridge", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined();
});
test("changes no membership, so Element Call uses its own client", () => {
expect(nullHostBridge.changeMembership).toBeUndefined();
});
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
+31
View File
@@ -10,14 +10,22 @@ import { fromEvent, map, NEVER, type Observable } from "rxjs";
import {
type IWidgetApiRequest,
type IWidgetApiRequestData,
type IWidgetApiResponseData,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import { MatrixError, type Membership } from "matrix-js-sdk";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "./widget";
import {
type ChangeMembership,
type MembershipChange,
MembershipUnsupportedError,
} from "./room/membership";
// Note: these are type aliases rather than interfaces so that they satisfy the
// widget API's index-signature payload types.
@@ -122,6 +130,12 @@ export interface HostBridge {
* media itself using its own client.
*/
downloadMedia?(mxcUri: string): Promise<Blob>;
/**
* Changes the user's membership of the room on Element Call's behalf, for
* hosts that do not give it direct access to the homeserver. Absent when
* Element Call should change the membership itself using its own client.
*/
changeMembership?: ChangeMembership;
}
/**
@@ -221,6 +235,23 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
)
);
},
changeMembership: async (change) => {
try {
const { membership } = await widget.api.transport.send<
MembershipChange & IWidgetApiRequestData,
IWidgetApiResponseData & { membership: Membership }
>(ElementWidgetActions.Membership, change);
return membership;
} catch (e) {
if (!(e instanceof WidgetApiResponseError)) throw e;
// A host that made the request on our behalf answers with the
// homeserver's own error. One without means it made no request: it
// does not know the action, or refused it outright.
throw e.data.matrix_api_error
? MatrixError.fromWidgetApiErrorData(e.data.matrix_api_error)
: new MembershipUnsupportedError(e.message);
}
},
downloadMedia: async (mxcUri) => {
const { file } = await widget.api.downloadFile(mxcUri);
if (file instanceof Blob) return file;
+2 -2
View File
@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
*/
import { createContext, use, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type MediaDevices } from "./state/MediaDevices";
import { useBehavior } from "./useBehavior";
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
undefined,
@@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): {
volume: number;
} => {
const devices = useMediaDevices();
const audioOutput = useBehavior(devices.audioOutput.selected$);
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
return {
// We use only the right speaker (pan = 1) for the earpiece.
-242
View File
@@ -1,242 +0,0 @@
/*
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;
}
+222 -17
View File
@@ -9,36 +9,47 @@ 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 {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "./UrlConfiguration";
import { platform } from "./Platform";
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
@@ -155,6 +166,109 @@ 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 {
@@ -240,6 +354,97 @@ 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.
+7 -2
View File
@@ -50,11 +50,16 @@ export const useInteractiveRegistration = (
useEffect(() => {
if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
// A 401 carrying the flow parameters is the expected answer, so this
// rejection is the success path, and some homeservers answer it with no
// params at all.
authClient.current!.registerRequest({}).catch((error) => {
logger.debug("Registration flow parameters", error);
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
error.data?.params?.["m.login.terms"]?.policies?.privacy_policy?.en
?.url,
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
setRecaptchaKey(error.data?.params?.["m.login.recaptcha"]?.public_key);
});
}, [isWidget]);
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_bh_"
aria-labelledby="_r_bb_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
+1 -4
View File
@@ -43,10 +43,7 @@ Please see LICENSE in the repository root for full details.
inset-inline: 0;
}
/* Keep the footer while it is in use: focus inside it, or a menu opened from
it. The menu is portalled out, so the trigger's aria-expanded tracks it. */
.footer.overlay:has(:focus-visible),
.footer.overlay:has([aria-expanded="true"]) {
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
-35
View File
@@ -13,7 +13,6 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import styles from "./CallFooter.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
@@ -138,13 +137,10 @@ export const Default: Story = {
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
audioOutputOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
@@ -162,46 +158,15 @@ export const WithAudioAndVideoOptions: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
audioOutputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "2" },
],
videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1",
},
};
export const StaysWhileAMenuIsOpen: Story = {
...Default,
args: {
...WithAudioAndVideoOptions.args,
// In a short window the footer overlays the call and hides itself.
asOverlay: true,
showFooter: true,
},
play: async ({ canvasElement }): Promise<void> => {
const footer = canvasElement.querySelector<HTMLElement>(
'[data-testid="footer-container"]',
)!;
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Microphone" }),
);
await expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
// The call hides the footer with this class.
footer.classList.add(styles.hidden);
// Read after the 0.15s fade, not mid-transition.
await new Promise((settled) => setTimeout(settled, 400));
await expect(getComputedStyle(footer).opacity).toBe("1");
},
};
export const AudioBusy: Story = {
...Default,
args: {
+2 -10
View File
@@ -99,15 +99,11 @@ export interface FooterState {
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Output (speaker) devices. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
@@ -147,9 +143,6 @@ export const CallFooter: FC<FooterProps> = ({
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
@@ -175,6 +168,7 @@ export const CallFooter: FC<FooterProps> = ({
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
@@ -184,9 +178,6 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>,
);
} else {
@@ -206,6 +197,7 @@ export const CallFooter: FC<FooterProps> = ({
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
@@ -30,11 +30,6 @@ vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
}));
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
@@ -101,65 +96,6 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
});
describe("createCallFooterViewModel", () => {
describe("selectAudioOutputOption", () => {
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
platformMock.mockReturnValue("desktop");
return createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
}
it("is withheld where the platform cannot route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(false);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
});
it("is offered where the platform can route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(true);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
});
});
describe("audioOutputOptions", () => {
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
platformMock.mockReturnValue("desktop");
outputSelectionMock.mockReturnValue(true);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// As Safari: no outputs listed.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty, not undefined: undefined would hide the section.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
+32 -37
View File
@@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, type Observable, switchMap } from "rxjs";
import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { supportsAudioOutputSelection } from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
@@ -68,50 +67,49 @@ function buildDeviceBehaviors(
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
): Observable<MenuOptions[]> =>
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({ id, label })),
),
),
),
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
audioOutputOptions$: scope.behavior(
options$(mediaDevices.audioOutput.available$),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Withheld where the platform can't route audio to a chosen device, which
// disables the speaker section.
selectAudioOutputOption$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
@@ -265,13 +263,10 @@ export function createLobbyFooterViewModel(
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
@@ -35,60 +35,3 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg);
}
}
.menu {
display: flex;
flex-direction: column;
}
/* Only the device lists scroll, bounded by the measured call area: the menu is
portalled out of the root, so CSS can't size it against the call. */
.deviceList {
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0);
}
/* Compound's heading rule heads each section; the design adds space below it. */
.menu h3 {
margin-block-end: var(--cpd-space-5x);
}
/* And more above a heading that follows another section. */
.deviceList [role="group"] + [role="group"] h3 {
margin-block-start: var(--cpd-space-7x);
}
/* Sticky within its own section. Opaque and inset a border width so it doesn't
paint over the menu's outline. */
.sectionHeading {
position: sticky;
inset-block-start: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-start: var(--cpd-border-width-1);
}
/* Sticky at the foot of the microphone section; opaque and inset as above. */
.stickyMeter {
position: sticky;
inset-block-end: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-border-width-1);
}
/* Radix focuses whatever the pointer is over, so the browser's ring would
follow the mouse. Replaced by one shown only for keyboard focus. */
.menu [role^="menuitem"]:focus,
.menu [role^="menuitem"]:focus-visible {
outline: none;
}
.menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
@@ -5,15 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
import { fn, userEvent, within, expect } from "storybook/test";
import { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { RootElementProvider } from "../RootElementContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
@@ -21,74 +18,12 @@ const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
/** Supplies a microphone, a wavering tone: a story's invented device ids match no hardware. */
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
useEffect(() => {
const context = new AudioContext();
const microphone = context.createMediaStreamDestination();
const tone = context.createOscillator();
const loudness = context.createGain();
// Wavers between a third and two thirds of full scale, so it reads as live.
const swing = context.createOscillator();
const depth = context.createGain();
loudness.gain.value = 0.25;
depth.gain.value = 0.2;
swing.frequency.value = 0.6;
tone.frequency.value = 220;
swing.connect(depth).connect(loudness.gain);
tone.connect(loudness).connect(microphone);
tone.start();
swing.start();
const devices = navigator.mediaDevices;
const openedForReal = devices.getUserMedia.bind(devices);
const opened = Promise.resolve(microphone.stream);
// A fresh clone each time, so a caller stopping its tracks doesn't end the next.
devices.getUserMedia = async (): Promise<MediaStream> =>
(await opened).clone();
return (): void => {
devices.getUserMedia = openedForReal;
tone.stop();
swing.stop();
void context.close();
};
}, []);
return <>{children}</>;
};
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
return (
<div
ref={setCallArea}
style={{
// A call's size: anything smaller makes a short device list scroll.
blockSize: 720,
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
{callArea !== null && (
<RootElementProvider value={callArea}>{children}</RootElementProvider>
)}
</div>
);
};
const meta = {
component: MediaMuteAndSwitchButton,
decorators: [
(Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}>
<WithACallArea>
<WithAMicrophone>
<Story />
</WithAMicrophone>
</WithACallArea>
<Story />
</MediaDevicesContext>
),
],
@@ -99,6 +34,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
iconsAndLabels: "audio",
enabled: true,
options: [
@@ -106,13 +42,6 @@ export const Default: Story = {
{ label: { type: "name", name: "Option 2" }, id: "2" },
],
selectedOption: "1",
// The footer always passes an output list to the audio menu.
outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "default",
onSelectOutput: fn(),
onMuteClick: fn(),
onSelect: fn(),
},
@@ -121,6 +50,7 @@ export const Default: Story = {
export const AudioMute: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: false,
options: [
@@ -143,19 +73,21 @@ export const AudioMute: Story = {
export const AudioUnmute: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
selectedOption: "2",
},
};
export const VideoMute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: false,
options: [
@@ -169,6 +101,7 @@ export const VideoMute: Story = {
export const VideoUnmute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: true,
options: [
@@ -180,479 +113,3 @@ export const VideoUnmute: Story = {
selectedOption: "2",
},
};
export const SpeakerAndMicrophoneSections: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "default",
onSelectOutput: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const headset = await within(document.body).findByRole("menuitemradio", {
name: "Headset",
});
await userEvent.click(headset);
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
// A few devices fit, so the list doesn't scroll.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBe(list.clientHeight);
// Each section is headed by its own edge-to-edge rule, with no separator.
const menu = document.body.querySelector("[role='menu']")!;
await expect(
document.body.querySelectorAll("[role='separator']"),
).toHaveLength(0);
const headings = document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
);
await expect(headings).toHaveLength(2);
const frame = menu.getBoundingClientRect();
for (const heading of headings) {
const rule = heading.querySelector("h3")!;
await expect(
Number.parseFloat(getComputedStyle(rule).borderBottomWidth),
).toBeGreaterThan(0);
// Edge to edge, within the frame.
const box = rule.getBoundingClientRect();
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
}
// The first device sits further below the rule than from the menu's edge.
// Asserted as a relationship, not pixels: the asymmetry is the design.
const control = document.body.querySelector("input[type='radio']")!;
const ruleBottom = headings[0]
.querySelector("h3")!
.getBoundingClientRect().bottom;
const box = control.getBoundingClientRect();
await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left);
// And a section stands further from the one above than from its own first
// device.
const groups = document.body.querySelectorAll("[role='group']");
const speakers = groups[0].querySelectorAll("input[type='radio']");
const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect();
const nextHeading = groups[1]!.querySelector("h3")!.getBoundingClientRect();
await expect(nextHeading.top - lastSpeaker.bottom).toBeGreaterThan(
box.top - ruleBottom,
);
},
};
export const OutputCannotBeChosen: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "spk1",
// No callback: the speakers are listed, but none can be picked.
onSelectOutput: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const speakers = await within(document.body).findByRole("menuitemradio", {
name: "Speakers",
});
await expect(speakers).toHaveAttribute("aria-disabled", "true");
},
};
export const OnlyOneDevice: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
selectedOption: "mic1",
outputOptions: [{ label: { type: "name", name: "Speakers" }, id: "spk1" }],
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
// Shown, disabled, rather than hidden.
const only = await within(document.body).findByRole("menuitemradio", {
name: "Microphone 1",
});
await expect(only).toHaveAttribute("aria-disabled", "true");
},
};
/** A requested device hasn't arrived: nothing in either section can be picked. */
export const SelectionSettling: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
// selectedOption never changes, so the request stays in flight.
await userEvent.click(
await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
);
await expect(await menu.findByLabelText("Activating…")).toBeVisible();
for (const item of menu.getAllByRole("menuitemradio"))
await expect(item).toHaveAttribute("aria-disabled", "true");
},
};
/**
* The ring shows for keyboard focus only. Asserted on the painted outline:
* asserting data-focus-source would still pass with the CSS rule deleted.
*/
export const KeyboardFocusRing: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
const first = await menu.findByRole("menuitemradio", {
name: "Microphone 1",
});
// Opened by pointer: no ring, even though Radix has moved focus.
await expect(outlineWidth(first)).toBe(0);
await userEvent.keyboard("{ArrowDown}");
const focused = document.activeElement as HTMLElement;
await expect(focused).toHaveRole("menuitemradio");
await expect(outlineWidth(focused)).toBeGreaterThan(0);
// And the pointer takes it away again.
await userEvent.hover(first);
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
},
};
/** The list scrolls; the meter stays at the foot of the microphone section. */
export const ManyDevices: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 6 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
// The sticky wrapper rather than the meter: without a fake microphone (as on
// WebKit) the meter shows a message instead of a level.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const sticky = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
// Scrolled so the microphone section starts at the top: only there does a
// pinned meter differ from one that is simply last.
const group = await menu.findByRole("group", { name: "Microphone" });
list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top;
await expect(list.scrollTop + list.clientHeight).toBeLessThan(
list.scrollHeight,
);
const scrollport = list.getBoundingClientRect();
const pinned = sticky.getBoundingClientRect();
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
// The whole menu is on screen, not bounded by the document.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(frame.top).toBeGreaterThanOrEqual(0);
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
// The meter is the menu's one opaque part, so it must stay inside the frame.
await expect(pinned.left).toBeGreaterThan(frame.left);
await expect(pinned.right).toBeLessThan(frame.right);
},
};
/** Painted outline width, in px. */
function outlineWidth(element: HTMLElement): number {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
if (outlineStyle === "none") return 0;
return Number.parseFloat(outlineWidth) || 0;
}
/** Safari lists no outputs: a disabled, selected Default stands in. */
export const OutputNotEnumerated: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [],
selectedOutputOption: undefined,
onSelectOutput: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const speakers = await within(document.body).findByRole("menuitemradio", {
name: "Default",
});
await expect(speakers).toHaveAttribute("aria-disabled", "true");
},
};
/** The meter's icon shares the radio controls' centre line; only a real browser lays this out. */
export const MeterAlignsWithTheDeviceRows: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = document.body.querySelector("[role='menu']")!;
const radio = menu.querySelector("input[type='radio']")!;
const icon = await waitFor(() => {
const element = document.body.querySelector(`.${meterStyles.icon}`);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
// A pixel of slack, for subpixel layout.
await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1);
},
};
/** Inline-axis centre of an element. */
function centre(element: Element): number {
const box = element.getBoundingClientRect();
return box.left + box.width / 2;
}
/** Every row the keyboard reaches is fully visible, not under a heading or the meter. */
export const KeyboardReachesEveryDevice: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
// Enough to scroll both ways, so either end can hide a row.
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 4 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const meter = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
// Drawn over the list: the sticky headings and the meter.
const overlays = [
meter,
...document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
),
];
const items = within(document.body).getAllByRole("menuitemradio");
// Down to the last device and back up.
for (const key of ["{ArrowDown}", "{ArrowUp}"])
for (let i = 0; i < items.length; i++) {
await userEvent.keyboard(key);
const focused = document.activeElement as HTMLElement;
await expect(focused).toHaveRole("menuitemradio");
// Checked as overlap: a heading only covers rows while its section is on
// screen.
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
}
},
};
/** A section's heading stays at the top while the section is in view, and leaves with it. */
export const HeadingsStayWhileScrolling: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 4 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const group = await menu.findByRole("group", { name: "Microphone" });
// By class: the heading is aria-hidden, and its group carries the name.
const heading = group.querySelector<HTMLElement>(
`.${styles.sectionHeading}`,
)!;
// Far enough that the heading is only on screen if it is stuck there.
list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80;
const scrollport = list.getBoundingClientRect();
await expect(heading.getBoundingClientRect().top).toBeLessThanOrEqual(
scrollport.top + 2,
);
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
scrollport.top,
);
// Clear of the menu's frame.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
frame.left,
);
},
};
/** How far the most overlapping of `overlays` covers `element`, in px. */
function overlapping(element: Element, overlays: Element[]): number {
const box = element.getBoundingClientRect();
return overlays.reduce((worst, overlay) => {
const over = overlay.getBoundingClientRect();
const shared =
Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
return Math.max(worst, shared);
}, 0);
}
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
export const FocusRingCoversTheBlurToggle: Story = {
args: {
...VideoUnmute.args,
iconsAndLabels: "video",
videoBlurEnabled: false,
videoBlurToggleClick: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
name: /Blur background/,
});
// Arrow down past the cameras to the toggle.
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
await userEvent.keyboard("{ArrowDown}");
await expect(document.activeElement).toBe(toggle);
// The same ring the device rows get.
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
// And the pointer takes it away, with the toggle still focused.
await userEvent.hover(toggle);
await expect(document.activeElement).toBe(toggle);
await expect(outlineWidth(toggle)).toBe(0);
},
};
+38 -515
View File
@@ -5,61 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import { axe } from "vitest-axe";
import {
act,
render,
screen,
within,
type RenderResult,
} from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Profiler, type JSX, useState, type ReactNode } from "react";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
interface RenderOptions {
requestDeviceNames: () => void;
}
function withProviders(
function renderComponent(
component: ReactNode,
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
): JSX.Element {
return (
): RenderResult {
return render(
<TooltipProvider>
<MediaDevicesContext
value={{ requestDeviceNames } as unknown as MediaDevices}
>
{component}
</MediaDevicesContext>
</TooltipProvider>
</TooltipProvider>,
);
}
function renderComponent(
component: ReactNode,
options: Partial<RenderOptions> = {},
): RenderResult {
return render(withProviders(component, options));
}
describe("MediaMuteAndSwitchButton", () => {
// Only one test stubs the capture; don't let it leak into the rest.
afterEach(restoreAudioCapture);
test("renders", () => {
const { container } = renderComponent(
<TooltipProvider>
<MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
@@ -71,7 +50,11 @@ describe("MediaMuteAndSwitchButton", () => {
enabled: boolean,
): RenderResult => {
return renderComponent(
<MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
);
};
const renderAudioEndabled = renderLabels("audio", true);
@@ -98,6 +81,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
@@ -114,6 +98,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
@@ -134,6 +119,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="video"
enabled={true}
@@ -153,7 +139,11 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup();
const requestDeviceNames = vi.fn();
renderComponent(
<MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ requestDeviceNames },
);
@@ -167,6 +157,7 @@ describe("MediaMuteAndSwitchButton", () => {
renderComponent(
<>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
options={[
@@ -176,6 +167,7 @@ describe("MediaMuteAndSwitchButton", () => {
selectedOption="mic1"
/>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled
options={[
@@ -201,6 +193,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
@@ -224,6 +217,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
@@ -252,6 +246,7 @@ describe("MediaMuteAndSwitchButton", () => {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
@@ -311,6 +306,7 @@ describe("MediaMuteAndSwitchButton", () => {
const onVideoBlurToggle = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
videoBlurToggleClick={onVideoBlurToggle}
@@ -331,10 +327,11 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("marks the selected menu item as checked", async () => {
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
@@ -345,493 +342,19 @@ describe("MediaMuteAndSwitchButton", () => {
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
});
test("disables every device while a selection is settling", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
void promise.then(() => setSelectedOption(id));
}}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
}
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// In flight: nothing else can be picked, in either section.
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled",
"true",
);
}
await act(async () => {
resolve();
await promise;
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitemradio", {
name: "Microphone 2",
});
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// Settled: selectable again.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("lets go of a device switch that never arrives", async () => {
const user = userEvent.setup();
// onSelect never reports back, as when a device is removed mid-switch.
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
await user.keyboard("{Escape}");
await user.click(getByRole("button", { name: "Microphone" }));
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute(
"aria-disabled",
"true",
);
}
});
test("lets go of a device switch whose device is unplugged", async () => {
const user = userEvent.setup();
const mics: MenuOptions[] = [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
];
const menu = (options: MenuOptions[]): JSX.Element => (
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={options}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
const { getByRole, rerender } = renderComponent(menu(mics));
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// The second microphone is unplugged before the switch lands.
rerender(withProviders(menu(mics.slice(0, 1))));
// Selectable again without closing the menu.
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("moves the level without re-rendering anything", async () => {
// The level is drawn into the DOM, so a moving level commits nothing.
const capture = stubAudioCapture();
const user = userEvent.setup();
let commits = 0;
const { getByRole } = renderComponent(
<Profiler
id="menu"
onRender={(): void => {
commits++;
}}
>
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
</Profiler>,
);
await user.click(getByRole("button", { name: "Microphone" }));
capture.grant();
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
const meter = await screen.findByRole("meter");
// The capture's arrival is one render: the idle level swapped for its own.
await act(async () => {});
const settled = commits;
// The meter smooths by elapsed time, so hand-driven frames need a clock.
let elapsed = performance.now();
const clock = vi
.spyOn(performance, "now")
.mockImplementation(() => (elapsed += 16));
// One frame per task, as a browser delivers them: one act() would batch them.
for (let step = 1; step <= 8; step++) {
capture.speak(step / 8);
await act(async () => {
capture.drawFrames(1);
await Promise.resolve();
});
}
clock.mockRestore();
expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
expect(commits - settled).toBe(0);
});
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
]}
selectedOption="cam1"
onSelect={vi.fn()}
videoBlurToggleClick={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
});
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const list = screen
.getByRole("menuitemradio", { name: "Microphone 1" })
.closest("[data-focus-source]");
// The menu focuses whatever the pointer is over, so focus alone says nothing.
expect(list).toHaveAttribute("data-focus-source", "pointer");
await user.keyboard("{ArrowDown}");
expect(list).toHaveAttribute("data-focus-source", "keyboard");
await user.pointer({
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
coords: { clientX: 10, clientY: 10 },
});
expect(list).toHaveAttribute("data-focus-source", "pointer");
});
test("marks the selected device with the accent fill", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const selected = screen
.getByRole("menuitemradio", { name: "Microphone 2" })
.querySelector("input[type=radio]");
expect(selected).toBeChecked();
// readOnly would paint the selected radio muted.
expect(selected).not.toHaveAttribute("readonly");
});
test("the open menu has no accessibility violations", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const menu = document.querySelector('[role="menu"]');
expect(await axe(menu as HTMLElement)).toHaveNoViolations();
});
test("puts the speaker section above the microphone section", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const speakers = screen.getByRole("menuitemradio", { name: "Speakers" });
const microphone = screen.getByRole("menuitemradio", {
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitemradio", {
name: "Microphone 1",
});
expect(
speakers.compareDocumentPosition(microphone) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test("lists speaker and microphone sections", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{
label: { type: "default", name: "Built-in Output" },
id: "default",
},
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="default"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", {
name: "Default (Built-in Output)",
checked: true,
});
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
});
test("calls the output select callback on speaker click", async () => {
const user = userEvent.setup();
const onSelectOutput = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={onSelectOutput}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
});
test("shows a single device entry disabled", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// Shown, but not selectable.
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
expect(only).toHaveAttribute("aria-disabled", "true");
});
test("shows a default speaker where the platform lists none", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
// As Safari: no outputs listed.
outputOptions={[]}
selectedOutputOption={undefined}
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const speakers = screen
.getAllByRole("group")
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
const entries = within(speakers).getAllByRole("menuitemradio");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAccessibleName("Default");
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
expect(entries[0]).toHaveAttribute("aria-checked", "true");
expect(
within(entries[0]).getByRole("radio", { hidden: true }),
).toBeChecked();
});
test("shows the speaker section disabled when output selection is unsupported", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
expect(
screen.getByRole("menuitemradio", { name: "Speakers" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});
+106 -304
View File
@@ -5,48 +5,37 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
useCallback,
useState,
type CSSProperties,
type FC,
useEffect,
type ReactElement,
} from "react";
import { type ComponentType, useState, type FC, useEffect } from "react";
import {
Button,
Menu,
MenuItem,
MenuTitle,
RadioInput,
ToggleMenuItem,
} from "@vector-im/compound-web";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOnIcon,
SpinnerIcon,
VideoCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
} from "../state/MediaDevices";
import { type DeviceLabel } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useRootElement } from "../RootElementContext";
import { observeElementSize$ } from "../utils/elementSize";
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
export interface MenuOptions {
label: DeviceLabel | AudioOutputDeviceLabel;
label: DeviceLabel;
id: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
/** If the Mute button is enabled */
enabled?: boolean;
/** Callback if the mute button is clicked */
@@ -58,12 +47,6 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/** Output (speaker) devices. Audio menu only. */
outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */
selectedOutputOption?: string;
/** Picks an output device. Undefined disables the speaker section. */
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/**
@@ -75,240 +58,90 @@ export interface MediaMuteAndSwitchButtonProps {
const BLUR_ID = "blur";
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
const DEFAULT_OUTPUT_ID = "default";
/** Largest share of the call area's height the device list may take. */
const LIST_SHARE_OF_CALL = 0.6;
/** Smallest device list height in px, so a short call still shows more than one device. */
const MIN_LIST_HEIGHT = 160;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
busy,
onMuteClick,
iconsAndLabels,
options,
selectedOption,
outputOptions,
selectedOutputOption,
onSelectOutput,
videoBlurEnabled,
videoBlurToggleClick,
onSelect,
}) => {
// Requested but not yet selected. Keyed by kind too, since Chrome uses
// "default" for both an input and an output.
const [plannedSelection, setPlannedSelection] = useState<{
kind: "input" | "output";
id: string;
} | null>(null);
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((open: boolean): void => {
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
}, []);
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
/**
* Records on the menu whether the keyboard or the pointer moved focus, for
* the focus ring. `:focus-visible` can't tell: Radix focuses whatever the
* pointer is over. Listened for on the menu, not the document, so a second
* Element Call on the page doesn't answer for this one.
*/
const trackFocusSource = useCallback(
(list: HTMLDivElement | null): (() => void) | undefined => {
const menu = list?.closest<HTMLElement>('[role="menu"]');
if (menu === null || menu === undefined) return;
const record = (source: "keyboard" | "pointer"): void => {
menu.dataset.focusSource = source;
};
record("pointer");
const usedKeyboard = (): void => record("keyboard");
const usedPointer = (): void => record("pointer");
menu.addEventListener("keydown", usedKeyboard, true);
menu.addEventListener("pointermove", usedPointer, true);
return (): void => {
menu.removeEventListener("keydown", usedKeyboard, true);
menu.removeEventListener("pointermove", usedPointer, true);
};
},
[],
);
// Measured on the call area: CSS can't size the portalled menu against it.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => {
if (!menuOpen) return;
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ height }) =>
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
),
distinctUntilChanged(),
)
.subscribe(setListMaxHeight);
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
// Kept clear at the list's foot, so a row reached by keyboard isn't under
// the meter.
const [meterHeight, meter] = useMeasuredHeight();
// Likewise at its head, for the sticky headings.
const [headingHeight, heading] = useMeasuredHeight();
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
const button = (
<MuteButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid={
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
}
/>
);
const toggles =
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
? [
let button;
let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) {
case "video":
button = (
<VideoButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
if (videoBlurToggleClick !== undefined) {
toggles = [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false,
id: BLUR_ID,
},
]
: [];
];
}
break;
case "audio":
button = (
<MicButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_mute"
/>
);
break;
}
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
let optionsButtonLabel: string;
let menuTitle: string;
let numberedLabel: (number: number) => string;
switch (iconsAndLabels) {
case "video":
IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera");
menuTitle = t("settings.devices.camera_source");
numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n });
break;
case "audio":
IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone");
menuTitle = t("settings.devices.mic_source");
numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n });
break;
}
const labelText = (
label: MenuOptions["label"],
numbered: (n: number) => string,
): string => {
switch (label.type) {
case "name":
return label.name;
case "number":
return numbered(label.number);
case "default":
return label.name === null
? t("settings.devices.default")
: t("settings.devices.default_named_plain", { name: label.name });
case "speaker":
return t("settings.devices.loudspeaker");
case "earpiece":
return t("settings.devices.handset");
}
};
// Nothing can be picked while a requested device is on offer but not yet
// selected, so a second request can't overtake it.
const plannedOutput = plannedSelection?.kind === "output";
const selectedOfPlannedKind = plannedOutput
? selectedOutputOption
: selectedOption;
const offeredOfPlannedKind = plannedOutput ? outputOptions : options;
const settling =
plannedSelection !== null &&
plannedSelection.id !== selectedOfPlannedKind &&
offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true;
// Safari lists no outputs: show a disabled, selected Default rather than an
// empty section.
const noOutputsListed = outputOptions?.length === 0;
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
: outputOptions;
const selectedSpeaker = noOutputsListed
? DEFAULT_OUTPUT_ID
: selectedOutputOption;
const deviceItems = (
kind: "input" | "output",
items: MenuOptions[] | undefined,
selected: string | undefined,
select: ((id: string) => void) | undefined,
numbered: (n: number) => string,
): ReactElement[] => {
const list = items ?? [];
// Disabled rather than hidden, so the menu keeps its shape.
const disabled = select === undefined || list.length <= 1 || settling;
return list.map(({ label, id }) => (
<MenuItem
// A radio input may not sit inside a button.
as="div"
hideChevron
disabled={disabled}
label={labelText(label, numbered)}
Icon={
// Inert, not aria-hidden: aria-hidden alone leaves it focusable.
<span inert>
<RadioInput
checked={selected === id}
disabled={disabled}
// Not readOnly, which mutes the selected fill. The item handles
// the click.
onChange={(): void => {}}
/>
</span>
}
onSelect={(e) => {
e.preventDefault();
if (id === selected) return;
setPlannedSelection({ kind, id });
select?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selected === id}
>
{selected !== id &&
plannedSelection?.kind === kind &&
plannedSelection.id === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
));
};
return (
<div
className={classNames({
@@ -319,12 +152,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{/* The mute button lives inside */}
{button}
<Menu
className={styles.menu}
// Named for screen readers only: each section has its own heading.
title={menuTitle}
showTitle={false}
title={title}
showTitle={true}
open={menuOpen}
onOpenChange={onOpenChange}
onOpenChange={setMenuOpen}
side="top"
trigger={
<Button
@@ -340,74 +171,67 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
<div
ref={trackFocusSource}
// Keeps the items the menu's own children for assistive tech.
role="none"
className={styles.deviceList}
style={
{
"--device-list-max-height":
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
"--device-list-scroll-padding-end":
meterHeight === undefined ? undefined : `${meterHeight}px`,
"--device-list-scroll-padding-start":
headingHeight === undefined ? undefined : `${headingHeight}px`,
} as CSSProperties
{options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
}
>
{iconsAndLabels === "audio" && speakerOptions && (
<>
{/* A menu may only contain items, separators and groups, so each
heading is a hidden part of a named group. */}
<div role="group" aria-label={t("settings.devices.speaker")}>
<div aria-hidden className={styles.sectionHeading}>
<MenuTitle title={t("settings.devices.speaker")} />
</div>
{deviceItems(
"output",
speakerOptions,
selectedSpeaker,
onSelectOutput,
(n) => t("settings.devices.speaker_numbered", { n }),
)}
</div>
</>
)}
<div role="group" aria-label={optionsButtonLabel}>
<div ref={heading} aria-hidden className={styles.sectionHeading}>
<MenuTitle title={optionsButtonLabel} />
</div>
{/* Apart from the heading, so the sticky meter can't ride over it. */}
<div role="none">
{deviceItems(
"input",
options,
selectedOption,
onSelect,
numberedLabel,
)}
{iconsAndLabels === "audio" && (
<LiveMicrophoneLevelMeter
ref={meter}
deviceId={selectedOption}
// Only while open, so the microphone isn't held all call.
active={menuOpen}
className={styles.stickyMeter}
return (
<MenuItem
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
</div>
</div>
</div>
{toggles.length > 0 && <hr />}
{toggles.map((toggle) => (
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
videoBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled}
checked={toggle.enabled ?? false}
key={toggle.id}
/>
))}
@@ -415,25 +239,3 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</div>
);
};
/** Follows an element's height. */
function useMeasuredHeight(): [
number | undefined,
(element: HTMLElement | null) => (() => void) | undefined,
] {
const [height, setHeight] = useState<number>();
const ref = useCallback(
(element: HTMLElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map((size) => size.height),
distinctUntilChanged(),
)
.subscribe(setHeight);
return (): void => subscription.unsubscribe();
},
[],
);
return [height, ref];
}
@@ -1,65 +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.
*/
.meter {
/* A device row's icon column, so the bars start where the names do. */
--meter-icon-size: 24px;
/* Compound's radio size, whose centre line the icon shares. */
--device-control-size: 20px;
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x);
/* Centres the icon on the radio column: less the row's inset border and half
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) -
(var(--meter-icon-size) - var(--device-control-size)) / 2
);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
}
.icon {
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
inline-size: var(--meter-icon-size);
block-size: var(--meter-icon-size);
}
.segments {
/* Bars keep one size and the width decides how many; the component reads the
sizes back from here. */
flex: 1;
/* So the bars never set the menu's width. */
contain: inline-size;
min-inline-size: 0;
display: flex;
align-items: center;
/* Half again a bar's width, per the design. */
gap: var(--cpd-space-1-5x);
}
.segment {
flex: none;
inline-size: var(--cpd-space-1x);
block-size: var(--cpd-space-4x);
border-radius: var(--cpd-space-1x);
background: var(--cpd-color-bg-subtle-primary);
}
/* The count of lit bars carries the level as well as their colour. */
.segmentLit {
background: var(--cpd-color-bg-accent-rest);
}
.message {
color: var(--cpd-color-text-secondary);
/* A paragraph's margin would lift the text off centre. */
margin-block: 0;
}
@@ -1,161 +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 } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
MicrophoneLevelMeter,
type MicrophoneLevelMeterProps,
} from "./MicrophoneLevelMeter";
import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
/** Roughly the menu's width. It only decides how many bars fit. */
const STORY_WIDTH = 256;
const meta = {
component: MicrophoneLevelMeter,
decorators: [
(Story): JSX.Element => (
<div style={{ inlineSize: STORY_WIDTH }}>
<Story />
</div>
),
],
argTypes: {
state: {
description:
"What the selected microphone can say about itself: a level, or a reason there is none.",
},
},
} satisfies Meta<typeof MicrophoneLevelMeter>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A quiet room: hiss below the noise floor lights nothing. */
export const Silent: Story = {
args: { state: { type: "level", level$: constant(0) } },
play: async ({ canvasElement }) => {
await expect(litSegments(canvasElement)).toBe(0);
},
};
export const QuietSpeech: Story = {
args: { state: { type: "level", level$: constant(5) } },
};
export const NormalSpeech: Story = {
args: { state: { type: "level", level$: constant(12) } },
play: async ({ canvasElement }) => {
// A floor, not a count: the count follows from the design's bar and gap sizes.
await expect(
canvasElement.getElementsByClassName(styles.segment).length,
).toBeGreaterThanOrEqual(15);
},
};
export const LoudSpeech: Story = {
args: { state: { type: "level", level$: constant(LEVEL_SCALE) } },
};
/** The three volumes differ in how many bars are lit, not only in colour. */
export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level$: constant(5) } },
play: async ({ canvasElement, mount }) => {
const lit: number[] = [];
for (const level of [5, 12, LEVEL_SCALE]) {
await mount(
<MicrophoneLevelMeter
state={{ type: "level", level$: constant(level) }}
/>,
);
lit.push(litSegments(canvasElement));
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
"aria-valuenow",
String(level),
);
}
await expect(new Set(lit).size).toBe(lit.length);
await expect(lit).toEqual([...lit].sort((a, b) => a - b));
},
};
/** Permission refused: a message with a next action, not a still meter. */
export const PermissionDenied: Story = {
args: { state: { type: "permission-denied" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(
canvas.getByText(/Microphone access is blocked/),
).toBeVisible();
},
};
/** No input device, told apart from a refusal. */
export const NoDevice: Story = {
args: { state: { type: "no-device" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(canvas.getByText(/No microphone found/)).toBeVisible();
// The icon sits on the middle of the text, however many lines it runs to.
const middle = (element: Element): number => {
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
};
const icon = canvasElement.getElementsByClassName(styles.icon)[0];
const words = canvasElement.getElementsByClassName(styles.message)[0];
await expect(Math.abs(middle(icon) - middle(words))).toBeLessThanOrEqual(1);
},
};
/** The same meter at two widths: the bars keep their size and only their count changes. */
export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level$: constant(12) } },
play: async ({ mount, args }) => {
const narrow = await measureAt(mount, args, 180);
const wide = await measureAt(mount, args, 400);
await expect(narrow.bar).toBe(wide.bar);
await expect(narrow.gap).toBe(wide.gap);
await expect(narrow.count).toBeLessThan(wide.count);
// The bars are still separate, not one run of colour.
await expect(narrow.gap).toBeGreaterThan(0);
},
};
/** How many bars are lit. */
function litSegments(canvasElement: HTMLElement): number {
return canvasElement.getElementsByClassName(styles.segmentLit).length;
}
/** Renders the meter at one width and reports its bars' shape. */
async function measureAt(
mount: (ui: JSX.Element) => Promise<unknown>,
args: MicrophoneLevelMeterProps,
width: number,
): Promise<{ count: number; bar: number; gap: number }> {
await mount(
<div style={{ inlineSize: width }}>
<MicrophoneLevelMeter {...args} />
</div>,
);
const bars = Array.from(document.body.getElementsByClassName(styles.segment));
const first = bars[0].getBoundingClientRect();
const second = bars[1].getBoundingClientRect();
return {
count: bars.length,
bar: first.width,
gap: second.left - first.right,
};
}
@@ -1,42 +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 { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
describe("MicrophoneLevelMeter", () => {
test("announces the level rather than relying on hue", () => {
render(
<MicrophoneLevelMeter state={{ type: "level", level$: constant(6) }} />,
);
const meter = screen.getByRole("meter", { name: "Microphone level" });
expect(meter).toHaveAttribute("aria-valuenow", "6");
expect(meter).toHaveAttribute("aria-valuemax", String(LEVEL_SCALE));
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${LEVEL_SCALE}`);
});
test("shows distinct messages for denied permission and no input device", () => {
const denied = render(
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
);
expect(
denied.getByText(/Microphone access is blocked/),
).toBeInTheDocument();
expect(denied.queryByRole("meter")).toBeNull();
const missing = render(
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
);
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
expect(missing.queryByRole("meter")).toBeNull();
});
});
-146
View File
@@ -1,146 +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 {
useCallback,
useLayoutEffect,
useRef,
useState,
type FC,
type Ref,
} from "react";
import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE, type MicrophoneState } from "../state/MicrophoneLevel";
import { observeElementSize$ } from "../utils/elementSize";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
export interface LiveMicrophoneLevelMeterProps {
/** The microphone to listen to. */
deviceId: string | undefined;
/** Whether to hold a capture at all. */
active: boolean;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/**
* The meter, wired to a live capture. Its own component so a changing level
* re-renders the meter, not its parent.
*/
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
deviceId,
active,
className,
ref,
}) => {
const state = useMicrophoneLevel(deviceId, active);
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
};
export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/** The input level of a microphone, or why there is none. */
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state,
className,
ref,
}) => {
const { t } = useTranslation();
// Bars keep one size, so their count follows the width. Starts full so the
// first paint, and jsdom, draw a whole meter.
const [barCount, setBarCount] = useState(LEVEL_SCALE);
const segments = useRef<HTMLDivElement | null>(null);
const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
segments.current = element;
const subscription = observeElementSize$(element)
.pipe(
map(({ width }) => barsThatFit(element, width)),
distinctUntilChanged(),
)
.subscribe(setBarCount);
return (): void => {
subscription.unsubscribe();
segments.current = null;
};
},
[],
);
// Drawn straight into the DOM: the level changes many times a second, and
// re-rendering for each change is what this avoids.
useLayoutEffect(() => {
const element = segments.current;
if (state.type !== "level" || element === null) return;
const subscription = state.level$.subscribe((level) => {
element.setAttribute("aria-valuenow", String(level));
element.setAttribute(
"aria-valuetext",
t("microphone_level.value", { level, max: LEVEL_SCALE }),
);
// The level is a share of the scale, not a bar count.
const lit = Math.round((level / LEVEL_SCALE) * barCount);
Array.from(element.children).forEach((bar, i) =>
bar.classList.toggle(styles.segmentLit, i < lit),
);
});
return (): void => subscription.unsubscribe();
}, [state, barCount, t]);
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon className={styles.icon} aria-hidden />
{state.type !== "level" ? (
<Text size="sm" className={styles.message}>
{state.type === "permission-denied"
? t("microphone_level.permission_denied")
: t("microphone_level.no_device")}
</Text>
) : (
<div
ref={track}
className={styles.segments}
role="meter"
aria-label={t("microphone_level.label")}
aria-valuemin={0}
aria-valuemax={LEVEL_SCALE}
// The first paint's value; the effect above keeps it current.
aria-valuenow={state.level$.value}
aria-valuetext={t("microphone_level.value", {
level: state.level$.value,
max: LEVEL_SCALE,
})}
>
{Array.from({ length: barCount }, (_, i) => (
<span key={i} aria-hidden className={styles.segment} />
))}
</div>
)}
</div>
);
};
/** How many bars fit across `width`, measured so the stylesheet alone sets their size. */
function barsThatFit(track: HTMLElement, width: number): number {
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
// No layout (jsdom): keep the full count.
if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE;
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
}
@@ -1,41 +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 { afterEach, describe, expect, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
// Capture and release are covered in MicrophoneLevel.test.ts; this covers the bridge.
describe("useMicrophoneLevel", () => {
afterEach(restoreAudioCapture);
test("holds no capture while the meter is not shown", () => {
const capture = stubAudioCapture();
renderHook(() => useMicrophoneLevel("mic1", false));
expect(capture.getUserMedia).not.toHaveBeenCalled();
});
test("starts from nothing rather than the previous device's level", () => {
const capture = stubAudioCapture();
const { result, rerender } = renderHook(
({ id }: { id: string }) => useMicrophoneLevel(id, true),
{ initialProps: { id: "mic1" } },
);
capture.grant();
rerender({ id: "mic2" });
// No level carried over from the previous device.
const state = result.current;
expect(state.type === "level" ? state.level$.value : state.type).toBe(0);
});
});
-34
View File
@@ -1,34 +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 { useEffect, useState } from "react";
import {
type MicrophoneState,
observeMicrophoneState$,
} from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
const IDLE: MicrophoneState = { type: "level", level$: constant(0) };
/** The live level of a microphone, captured only while `active`. */
export function useMicrophoneLevel(
deviceId: string | undefined,
active: boolean,
): MicrophoneState {
const [state, setState] = useState<MicrophoneState>(IDLE);
useEffect(() => {
if (!active) return;
// Idle first, so a new device doesn't start from the previous level.
setState(IDLE);
const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
return (): void => subscription.unsubscribe();
}, [deviceId, active]);
return state;
}
-187
View File
@@ -1,187 +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 { act, render, screen } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { arrangeTiles, type Bounds } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
type GridLayout,
type SpotlightLandscapeLayout,
type SpotlightPortraitLayout,
} from "../state/layout-types";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import {
GridTileViewModel,
SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel";
// Grid supplies these hooks to the layouts it renders; on their own the
// layouts have no grid to report to
vi.mock("./Grid", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useUpdateLayout: (): void => {},
useVisibleTiles: (): void => {},
}));
describe("grid layout", () => {
it("sizes the tiles from the minimum bounds", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
render(<Scrolling model={gridModel()} Slot={Slot} />);
const layer = screen.getByTestId("slot-0").parentElement!;
expect(layer.style.width).toBe("800px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 400, height: 300 }));
expect(layer.style.width).toBe("400px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
{ minBounds$: bounds },
);
const model = landscapeModel();
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
unmount();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
});
});
describe("spotlight portrait layout", () => {
it("sizes the grid tiles from the minimum width", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
minBounds$,
});
const model = portraitModel();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
.parentElement!;
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 500, height: 700 }));
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
);
});
});
const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
);
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
function gridModel(): GridLayout {
return {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
function landscapeModel(): SpotlightLandscapeLayout {
return {
type: "spotlight-landscape",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
function portraitModel(): SpotlightPortraitLayout {
return {
type: "spotlight-portrait",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
function slotIds(): string[] {
return screen
.getAllByTestId(/^slot-/)
.map((slot) => slot.textContent ?? "")
.filter((id) => id !== "spotlight");
}
+2 -2
View File
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Observable } from "rxjs";
import { type ComponentType } from "react";
import { type LayoutProps } from "./Grid";
import { type TileViewModel } from "../state/TileViewModel";
import { type Behavior } from "../state/Behavior";
export interface Bounds {
width: number;
@@ -20,7 +20,7 @@ export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds$: Behavior<Bounds>;
minBounds$: Observable<Bounds>;
}
export interface CallLayoutOutputs<Model> {
+18 -12
View File
@@ -11,12 +11,14 @@ import {
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -37,18 +39,22 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const alignment = useObservableEagerState(
useInitial(() =>
model.spotlightAlignment$.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
),
),
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
({ xRatio, yRatio }) =>
model.spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.spotlightAlignment$],
);
@@ -72,7 +78,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useBehavior(minBounds$);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length],
+2 -1
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
@@ -36,7 +37,7 @@ export const makeOneOnOneDesktopLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
+3 -2
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type CallLayout } from "./CallLayout";
@@ -32,7 +33,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
useBehavior(minBounds$);
useObservableEagerState(minBounds$);
return (
<div ref={ref} className={styles.layer}>
@@ -55,7 +56,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useBehavior(minBounds$);
useObservableEagerState(minBounds$);
return (
<div ref={ref} className={styles.layer}>
+2 -1
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, type CSSProperties } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type CallLayout, arrangeTiles } from "./CallLayout";
@@ -57,7 +58,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width } = useBehavior(minBounds$);
const { width } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = arrangeTiles(
width,
// TODO: We pretend that the minimum height is the width, because the
-4
View File
@@ -25,7 +25,6 @@ import {
import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
LoggerNames as LKLoggerNames,
} from "livekit-client";
import { getUrlParams } from "./UrlParams";
@@ -227,9 +226,6 @@ export class Initializer {
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
setLKLogLevel(enabled ? "trace" : "info");
// ICE candidate types and connection states are what you need to diagnose
// "could not establish pc connection", so always keep those in the rageshake
setLKLogLevel("debug", LKLoggerNames.ICE);
});
window.setLKLogLevel = setLKLogLevel;
-53
View File
@@ -24,12 +24,10 @@ import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import {
mockLocalParticipant,
mockMediaDevices,
mockRemoteParticipant,
mockTrack,
} from "../utils/test";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../widget";
initializeWidget();
export const TestAudioContextConstructor = vi.fn(
@@ -133,57 +131,6 @@ it("should render for member", () => {
expect(queryAllByTestId("audio")).toHaveLength(1);
});
function spyOnWarn(): ReturnType<typeof vi.fn> {
const warn = vi.fn();
vi.spyOn(rootLogger, "getChild").mockReturnValue({
warn,
} as unknown as typeof rootLogger);
return warn;
}
it("should not render or warn for the local participant", () => {
const warn = spyOnWarn();
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
const { queryAllByTestId } = render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
/>
</MediaDevicesProvider>,
);
expect(queryAllByTestId("audio")).toHaveLength(0);
expect(warn).not.toHaveBeenCalled();
});
it("should warn only once per unexpected participant", () => {
const warn = spyOnWarn();
const { rerender } = renderTestComponent(
[{ userId: "@bob", deviceId: "DEV0" }],
["@alice:DEV0"],
[
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.Microphone,
},
],
);
expect(warn).toHaveBeenCalledTimes(1);
rerender(
<MediaDevicesProvider value={mockMediaDevices({})}>
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
/>
</MediaDevicesProvider>,
);
expect(warn).toHaveBeenCalledTimes(1);
});
it("should not render without member", () => {
const { container, queryAllByTestId } = renderTestComponent(
[{ userId: "@bob", deviceId: "DEV0" }],
+9 -17
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { getTrackReferenceId } from "@livekit/components-core";
import { type Room as LivekitRoom } from "livekit-client";
import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
useTracks,
AudioTrack,
@@ -60,9 +60,6 @@ export function LivekitRoomAudioRenderer({
muted,
}: MatrixAudioRendererProps): ReactNode {
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
// Identities we have already warned about, so that re-renders (which happen
// on every active speaker update) don't repeat the warning.
const warnedIdentities = useRef(new Set<string>());
const tracks = useTracks(
[
Track.Source.Microphone,
@@ -75,27 +72,22 @@ export function LivekitRoomAudioRenderer({
room: livekitRoom,
},
)
// Only keep remote audio tracks (we never render our own audio)
.filter(
(ref) =>
ref.publication.kind === Track.Kind.Audio && !ref.participant.isLocal,
)
// Only keep audio tracks
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
// Only keep tracks from participants that are in the validIdentities list
.filter((ref) => {
const { identity } = ref.participant;
const isValid = validIdentities.includes(identity);
if (!isValid && !warnedIdentities.current.has(identity)) {
warnedIdentities.current.add(identity);
const isValid = validIdentities.includes(ref.participant.identity);
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.
logger.warn(
`Audio track ${identity} from ${url} has no matching matrix call member`,
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
);
} else if (isValid) {
warnedIdentities.current.delete(identity);
return false;
}
return isValid;
return true;
});
// This component is also (in addition to the "only play audio for connected members" logic above)
-87
View File
@@ -1,87 +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 { describe, expect, it, vi } from "vitest";
import { type LocalVideoTrack } from "livekit-client";
import {
type BackgroundOptions,
type ProcessorWrapper,
} from "@livekit/track-processors";
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test";
const processor = {} as ProcessorWrapper<BackgroundOptions>;
function mockTrack(
readyState: MediaStreamTrackState,
current?: ProcessorWrapper<BackgroundOptions>,
): LocalVideoTrack {
return {
mediaStreamTrack: { readyState },
getProcessor: vi.fn().mockReturnValue(current),
setProcessor: vi.fn().mockResolvedValue(undefined),
stopProcessor: vi.fn().mockResolvedValue(undefined),
} as unknown as LocalVideoTrack;
}
describe("applyProcessor", () => {
it("attaches the processor to a live track", () => {
const track = mockTrack("live");
applyProcessor(track, processor);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
it("does not attach the processor to an ended track", () => {
const track = mockTrack("ended");
applyProcessor(track, processor);
expect(track.setProcessor).not.toHaveBeenCalled();
});
it("does not surface a rejected setProcessor", async () => {
const track = mockTrack("live");
vi.mocked(track.setProcessor).mockRejectedValue(
new TypeError("Input track cannot be ended"),
);
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, processor);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
it("stops the processor when none is wanted", () => {
const track = mockTrack("live", processor);
applyProcessor(track, undefined);
expect(track.stopProcessor).toHaveBeenCalled();
});
it("does not surface a rejected stopProcessor", async () => {
const track = mockTrack("live", processor);
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, undefined);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
});
describe("trackProcessorSync", () => {
it("applies the processor to the current track", () => {
const track = mockTrack("live");
trackProcessorSync(
testScope(),
constant(track),
constant({ supported: true, processor }),
);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
});
+26 -34
View File
@@ -19,8 +19,8 @@ import {
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest } from "rxjs";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
import {
backgroundBlur as backgroundBlurSettings,
@@ -30,7 +30,6 @@ import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { platform } from "../Platform";
import { useValueBehavior } from "../useValueBehavior";
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
@@ -52,36 +51,18 @@ export function useTrackProcessor(): ProcessorState {
return state;
}
export function useTrackProcessorState$(): Behavior<ProcessorState> {
return useValueBehavior(useTrackProcessor());
}
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
const state = use(ProcessorContext);
if (state === undefined)
throw new Error(
"useTrackProcessor must be used within a ProcessorProvider",
);
const state$ = useObservable(
(init$) => init$.pipe(map(([init]) => init)),
[state],
);
/**
* Attaches or detaches the processor so that the track matches the desired
* state, without throwing.
*/
export function applyProcessor(
videoTrack: LocalVideoTrack,
processor: ProcessorWrapper<BackgroundOptions> | undefined,
): void {
if (processor && !videoTrack.getProcessor()) {
// A MediaStreamTrackProcessor cannot be constructed on an ended track
// (e.g. the camera was stopped while the processor was being applied),
// and setProcessor rejects with a TypeError. The track is going away
// anyway, so there is nothing to attach to.
if (videoTrack.mediaStreamTrack.readyState === "ended") {
logger.debug("Not attaching video processor to an ended track");
return;
}
videoTrack.setProcessor(processor).catch((e) => {
logger.warn("Failed to attach video processor", e);
});
}
if (!processor && videoTrack.getProcessor()) {
videoTrack.stopProcessor().catch((e) => {
logger.warn("Failed to stop video processor", e);
});
}
return state$;
}
/**
@@ -97,7 +78,13 @@ export const trackProcessorSync = (
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
applyProcessor(videoTrack, processorState.processor);
const { processor } = processorState;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
});
};
@@ -107,7 +94,12 @@ export const useTrackProcessorSync = (
const { processor } = useTrackProcessor();
useEffect(() => {
if (!videoTrack) return;
applyProcessor(videoTrack, processor);
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
}, [processor, videoTrack]);
};
-11
View File
@@ -29,8 +29,6 @@ import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -204,15 +202,6 @@ export const ReactionSet: ReactionOption[] = [
mp3: baduntssSoundMp3,
},
},
{
emoji: "🛎️",
name: "bell",
alias: ["ding"],
sound: {
ogg: bellSoundOgg,
mp3: bellSoundMp3,
},
},
];
export interface RaisedHandInfo {
+1 -1
View File
@@ -507,7 +507,7 @@ const LoadedCallView: FC<LoadedProps> = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
joinState={{ kind: "can-join", join: () => setJoined(true) }}
confineToRoom={confineToRoom}
hideHeader={header !== HeaderStyle.Standard}
participantCount={participantCount}
+12 -8
View File
@@ -20,6 +20,8 @@ import {
import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { map } from "rxjs";
import { useObservable } from "observable-hooks";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useTranslation } from "react-i18next";
@@ -75,11 +77,10 @@ import {
useAppBarSubtitle,
} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { useValueBehavior } from "../useValueBehavior.ts";
import { constant } from "../state/Behavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
@@ -120,7 +121,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorState$();
const trackProcessorState$ = useTrackProcessorObservable$();
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
@@ -150,7 +151,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
trackProcessorState$,
scope.behavior(trackProcessorState$),
);
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
@@ -251,7 +252,7 @@ export const InCallView: FC<InCallViewProps> = ({
useWakeLock();
// TODO-MULTI-SFU This is unused now??
// const connectionState = useBehavior(vm.livekitConnectionState$);
// const connectionState = useObservableEagerState(vm.livekitConnectionState$);
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
@@ -373,7 +374,10 @@ export const InCallView: FC<InCallViewProps> = ({
edgeToEdge,
],
);
const gridBounds$ = useValueBehavior(gridBounds);
const gridBoundsObservable$ = useObservable(
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
[gridBounds],
);
useAppBarHidden(!showHeader);
useAppBarSubtitle(
@@ -534,7 +538,7 @@ export const InCallView: FC<InCallViewProps> = ({
);
const layouts = useMemo(() => {
const inputs = { minBounds$: gridBounds$ };
const inputs = { minBounds$: gridBoundsObservable$ };
return {
grid: makeGridLayout(inputs),
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
@@ -543,7 +547,7 @@ export const InCallView: FC<InCallViewProps> = ({
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
};
}, [gridBounds$]);
}, [gridBoundsObservable$]);
const showFooter = useBehavior(footerVm.showFooter$);
const renderContent = (): JSX.Element => {
+19 -14
View File
@@ -9,13 +9,16 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
import { type MatrixClient } from "matrix-js-sdk";
import { KnockLobbyView } from "./KnockLobbyView";
import { type LobbyJoinState } from "./LobbyJoinState";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { mockMediaDevices } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
@@ -44,22 +47,24 @@ const client = {
} as Partial<MatrixClient> as MatrixClient;
// What peeking at a room we are not in tells us about it
const roomSummary = {
room_id: "!room:example.org",
name: "Knock Room",
"im.nheko.summary.encryption": "m.megolm.v1.aes-sha2",
} as Partial<RoomSummary> as RoomSummary;
const room: PreJoinRoomInfo = {
roomId: "!room:example.org",
roomName: "Knock Room",
roomAlias: null,
roomAvatar: null,
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
};
function renderKnockLobby(knock: (() => void) | null): void {
function renderKnockLobby(joinState: LobbyJoinState): void {
render(
<LeaveToHomeProvider value={vi.fn()}>
<MediaDevicesContext value={mockMediaDevices({})}>
<TooltipProvider>
<KnockLobbyView
client={client}
roomSummary={roomSummary}
room={room}
profile={{ displayName: "Test User", avatarUrl: "" }}
knock={knock}
joinState={joinState}
confineToRoom={false}
hideHeader={false}
/>
@@ -71,8 +76,8 @@ function renderKnockLobby(knock: (() => void) | null): void {
describe("KnockLobbyView", () => {
it("offers to ask to join, with what it knows of the room", async () => {
const knock = vi.fn();
renderKnockLobby(knock);
const askToJoin = vi.fn();
renderKnockLobby({ kind: "can-ask-to-join", askToJoin });
// The mute state arrives asynchronously, and the lobby with it
const button = await screen.findByTestId("lobby_joinCall");
@@ -81,14 +86,14 @@ describe("KnockLobbyView", () => {
expect(screen.getByText("Knock Room")).toBeInTheDocument();
await userEvent.setup().click(button);
expect(knock).toHaveBeenCalledOnce();
expect(askToJoin).toHaveBeenCalledOnce();
});
it("waits once it has asked", async () => {
renderKnockLobby(null);
renderKnockLobby({ kind: "waiting-for-approval" });
const button = await screen.findByTestId("lobby_joinCall");
expect(button).toHaveTextContent("Request sent!");
expect(button).toHaveTextContent("Request to join sent");
// Compound's button keeps focusable, saying so through ARIA instead
expect(button).toHaveAttribute("aria-disabled", "true");
});
+12 -37
View File
@@ -5,26 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, type ReactNode } from "react";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type FC, type ReactNode } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useMuteStates } from "../state/useMuteStates";
import { type LobbyJoinState } from "./LobbyJoinState";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
interface Props {
client: MatrixClient;
/** What we know about the room from peeking at it. */
roomSummary: RoomSummary;
/** What we know about the room without being in it. */
room: PreJoinRoomInfo;
/** The user's own name and avatar, to show in their own tile. */
profile: { displayName: string; avatarUrl: string };
/**
* Asks to be let in, if the room allows it. Absent once we have asked and
* are waiting for an answer.
*/
knock: (() => void) | null;
/** How the user may get into the call, and what they may do about it. */
joinState: LobbyJoinState;
confineToRoom: boolean;
hideHeader: boolean;
}
@@ -41,27 +37,16 @@ interface Props {
*/
export const KnockLobbyView: FC<Props> = ({
client,
roomSummary,
room,
profile,
knock,
joinState,
confineToRoom,
hideHeader,
}): ReactNode => {
const { t } = useTranslation();
const muteStates = useMuteStates();
if (muteStates === null) return null;
const waitingForInvite = knock === null;
const enterLabel: string | JSX.Element = waitingForInvite ? (
<>
{t("lobby.waiting_for_invite")}
<CheckIcon />
</>
) : (
t("lobby.ask_to_join")
);
return (
<LobbyView
client={client}
@@ -69,19 +54,9 @@ export const KnockLobbyView: FC<Props> = ({
userId: client.getUserId() ?? "",
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
roomAlias: null,
roomId: roomSummary.room_id,
roomName: roomSummary.name ?? "",
roomAvatar: roomSummary.avatar_url ?? null,
e2eeSystem: {
kind: roomSummary["im.nheko.summary.encryption"]
? E2eeType.PER_PARTICIPANT
: E2eeType.NONE,
},
...room,
}}
onEnter={(): void => knock?.()}
enterLabel={enterLabel}
waitingForInvite={waitingForInvite}
joinState={joinState}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={null}
+40
View File
@@ -0,0 +1,40 @@
/*
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 the local user may enter the call they are looking at, and therefore what
* the lobby offers them. Everything the lobby needs to know about membership.
*/
export type LobbyJoinState =
/**
* The user may enter the call right away. `notice` is set when they got here
* by having a request accepted, but the join it entitles them to failed.
*/
| { kind: "can-join"; join: () => void; notice?: "request_accepted" }
/**
* The room only takes knocks. `error` is set when a previous request failed
* to send.
*/
| {
kind: "can-ask-to-join";
askToJoin: (reason?: string) => void;
error?: "request_failed";
}
/** The request is on its way to the server. */
| { kind: "sending-request" }
/** The user's join is on its way. */
| { kind: "joining" }
/**
* The request is with the room's moderators. `cancelRequest` is absent while
* a withdrawal is on its way, and where withdrawing is not supported.
*/
| { kind: "waiting-for-approval"; cancelRequest?: () => void }
/** The request was declined. There is no way to ask again. */
| { kind: "denied" }
| { kind: "banned"; reason?: string }
/** The room takes neither joins nor knocks from this user. */
| { kind: "not-allowed" };
+16
View File
@@ -39,3 +39,19 @@ Please see LICENSE in the repository root for full details.
gap: var(--cpd-space-10x);
}
}
.joinMessage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-2x);
max-inline-size: 40ch;
text-align: center;
color: var(--cpd-color-text-secondary);
}
.waiting {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
}
+185 -49
View File
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { of } from "rxjs";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
@@ -19,14 +18,9 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { type LobbyJoinState } from "./LobbyJoinState";
import { E2eeType } from "../e2ee/e2eeType";
import {
mockMediaDevices,
mockMuteStates,
restoreAudioCapture,
stubAudioCapture,
} from "../utils/test";
import { type MediaDevices } from "../state/MediaDevices";
import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -85,10 +79,9 @@ function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
withAppBar = false,
platform = "android",
devices: Partial<MediaDevices> = {},
): ReturnType<typeof render> {
platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices(devices);
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
const hideHeader = withAppBar ? true : false;
const lobbyView = (
@@ -96,7 +89,7 @@ function renderLobbyView(
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
joinState={{ kind: "can-join", join: () => {} }}
confineToRoom={false}
hideHeader={hideHeader}
participantCount={3}
@@ -134,18 +127,9 @@ describe("LobbyView", () => {
}
});
it("renders with waiting for invite state", () => {
const { getByTestId } = renderLobbyView({
waitingForInvite: true,
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
it("renders with AppBar android", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
{ joinState: { kind: "waiting-for-approval" } },
true,
"android",
);
@@ -166,9 +150,7 @@ describe("LobbyView", () => {
it("renders with AppBar ios", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
{ joinState: { kind: "waiting-for-approval" } },
true,
"ios",
);
@@ -186,31 +168,185 @@ describe("LobbyView", () => {
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
});
describe("LobbyView microphone level", () => {
afterEach(restoreAudioCapture);
it("shows the microphone level meter", async () => {
const capture = stubAudioCapture();
capture.grant();
const user = userEvent.setup();
const { getByRole } = renderLobbyView({}, false, "desktop", {
requestDeviceNames: (): void => {},
audioInput: {
available$: of(
new Map([["mic1", { type: "name", name: "Microphone 1" }]]),
),
selected$: of({ id: "mic1" }),
select: (): void => {},
describe("join states", () => {
const cases: {
joinState: LobbyJoinState;
button: string | null;
disabled: boolean;
message: string | null;
}[] = [
{
joinState: { kind: "can-join", join: () => {} },
button: "Join call",
disabled: false,
message: null,
},
} as unknown as Partial<MediaDevices>);
{
joinState: { kind: "can-ask-to-join", askToJoin: () => {} },
button: "Request to join call",
disabled: false,
message: null,
},
{
joinState: {
kind: "can-ask-to-join",
askToJoin: () => {},
error: "request_failed",
},
button: "Request to join call",
disabled: false,
message: "Something went wrong",
},
{
joinState: { kind: "sending-request" },
button: "Request to join call",
disabled: true,
message: null,
},
{
joinState: { kind: "joining" },
button: "Joining",
disabled: true,
message: null,
},
{
joinState: {
kind: "can-join",
join: () => {},
notice: "request_accepted",
},
button: "Join call",
disabled: false,
message: "Your request to join was accepted.",
},
{
joinState: { kind: "waiting-for-approval" },
button: "Request to join sent",
disabled: true,
message: "You will receive an invite",
},
{
joinState: { kind: "denied" },
button: null,
disabled: false,
message: "Your request to join was declined.",
},
{
joinState: { kind: "banned" },
button: null,
disabled: false,
message: "You have been banned from the room.",
},
{
joinState: { kind: "not-allowed" },
button: null,
disabled: false,
message: "You need an invite to join this call.",
},
];
// Pre-join reaches the meter through the same chevron as a call.
await user.click(getByRole("button", { name: "Microphone" }));
it.each(cases)(
"renders $joinState.kind",
async ({ joinState, button, disabled, message }) => {
const { container, queryByTestId } = renderLobbyView({ joinState });
const joinButton = queryByTestId("lobby_joinCall");
if (button === null) {
expect(joinButton).toBeNull();
} else {
expect(joinButton).toHaveTextContent(button);
// Compound buttons are soft-disabled: they keep focus and expose
// `aria-disabled` rather than the DOM `disabled` attribute.
if (disabled) {
expect(joinButton).toHaveAttribute("aria-disabled", "true");
} else {
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
}
}
const messageBlock = queryByTestId("lobby_joinMessage");
if (message === null) {
expect(messageBlock).toBeNull();
} else {
expect(messageBlock).toHaveTextContent(message);
}
expect(await axe(container)).toHaveNoViolations();
},
);
expect(
await screen.findByRole("meter", { name: "Microphone level" }),
).toBeInTheDocument();
it("only marks the waiting button as waiting", () => {
const waiting = renderLobbyView({
joinState: { kind: "waiting-for-approval" },
});
expect(waiting.getByTestId("lobby_joinCall")).toHaveClass(
lobbyStyles.wait,
);
waiting.unmount();
const canJoin = renderLobbyView();
expect(canJoin.getByTestId("lobby_joinCall")).not.toHaveClass(
lobbyStyles.wait,
);
});
it("does nothing while the join is on its way", async () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "joining" },
});
const button = getByTestId("lobby_joinCall");
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("joins when the join button is pressed", async () => {
const join = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "can-join", join },
});
await userEvent.click(getByTestId("lobby_joinCall"));
expect(join).toHaveBeenCalled();
});
it("asks to join when the request button is pressed", async () => {
const askToJoin = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "can-ask-to-join", askToJoin },
});
await userEvent.click(getByTestId("lobby_joinCall"));
expect(askToJoin).toHaveBeenCalled();
});
it("does nothing while the request is being sent", async () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "sending-request" },
});
const button = getByTestId("lobby_joinCall");
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("withdraws the request when cancel is pressed", async () => {
const cancelRequest = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "waiting-for-approval", cancelRequest },
});
await userEvent.click(getByTestId("lobby_cancelRequest"));
expect(cancelRequest).toHaveBeenCalled();
});
it("offers no cancel link when withdrawing is unsupported", () => {
const { queryByTestId } = renderLobbyView({
joinState: { kind: "waiting-for-approval" },
});
expect(queryByTestId("lobby_cancelRequest")).toBeNull();
});
it("shows the ban reason", () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "banned", reason: "Not today" },
});
expect(getByTestId("lobby_joinMessage")).toHaveTextContent(
"Reason: Not today",
);
});
});
});
+141 -22
View File
@@ -7,16 +7,20 @@ Please see LICENSE in the repository root for full details.
import {
type FC,
type ReactNode,
useCallback,
useMemo,
useState,
type JSX,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web";
import { Button, Heading, InlineSpinner, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import {
CheckIcon,
SpinnerIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react";
import {
@@ -24,9 +28,11 @@ import {
type LocalVideoTrack,
Track,
} from "livekit-client";
import { useObservableEagerState } from "observable-hooks";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
import buttonStyles from "../button/Button.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "../state/MuteStates";
@@ -50,31 +56,28 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
import { type LobbyJoinState } from "./LobbyJoinState";
interface Props {
client: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
enterLabel?: JSX.Element | string;
joinState: LobbyJoinState;
confineToRoom: boolean;
hideHeader: boolean;
participantCount: number | null;
onShareClick: (() => void) | null;
waitingForInvite?: boolean;
}
export const LobbyView: FC<Props> = ({
client,
matrixInfo,
muteStates,
onEnter,
enterLabel,
joinState,
confineToRoom,
hideHeader,
participantCount,
onShareClick,
waitingForInvite,
}) => {
useEffect(() => {
logger.info("[Lifecycle] LobbyView Component mounted");
@@ -124,7 +127,9 @@ export const LobbyView: FC<Props> = ({
);
const devices = useMediaDevices();
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
const videoInputId = useObservableEagerState(
devices.videoInput.selected$,
)?.id;
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
@@ -206,6 +211,127 @@ export const LobbyView: FC<Props> = ({
};
}, [devices, hangup, hideHeader, muteStates, openSettings]);
const joinButton = ((): ReactNode => {
switch (joinState.kind) {
case "can-join":
return (
<Button
className={styles.join}
size="lg"
onClick={joinState.join}
data-testid="lobby_joinCall"
>
{t("lobby.join_button")}
</Button>
);
case "can-ask-to-join":
return (
<Button
className={styles.join}
size="lg"
onClick={() => joinState.askToJoin()}
data-testid="lobby_joinCall"
>
{t("lobby.ask_to_join")}
</Button>
);
case "sending-request":
case "joining":
return (
<Button
className={classNames(styles.join, buttonStyles.rotate)}
size="lg"
Icon={SpinnerIcon}
disabled
aria-busy
data-testid="lobby_joinCall"
>
{t(
joinState.kind === "joining"
? "lobby.joining"
: "lobby.ask_to_join",
)}
</Button>
);
case "waiting-for-approval":
return (
<Button
className={classNames(styles.join, styles.wait)}
size="md"
disabled
data-testid="lobby_joinCall"
>
{t("lobby.request_sent")}
<CheckIcon />
</Button>
);
case "denied":
case "banned":
case "not-allowed":
return null;
}
})();
const joinMessage = ((): ReactNode => {
switch (joinState.kind) {
case "can-join":
return joinState.notice === undefined ? null : (
<Text size="sm">{t("lobby.request_accepted")}</Text>
);
case "can-ask-to-join":
return joinState.error === undefined ? null : (
<Text size="sm">{t("error.generic")}</Text>
);
case "waiting-for-approval":
return (
<>
<div className={styles.waiting}>
<InlineSpinner aria-label={t("common.loading")} />
<Text size="sm">{t("lobby.request_sent_body")}</Text>
</div>
{joinState.cancelRequest !== undefined && (
<Button
kind="tertiary"
size="md"
onClick={joinState.cancelRequest}
data-testid="lobby_cancelRequest"
>
{t("lobby.cancel_request")}
</Button>
)}
</>
);
case "denied":
return (
<>
<Heading as="h2" weight="semibold" size="sm">
{t("group_call_loader.knock_reject_heading")}
</Heading>
<Text size="sm">{t("group_call_loader.knock_reject_body")}</Text>
</>
);
case "banned":
return (
<>
<Heading as="h2" weight="semibold" size="sm">
{t("group_call_loader.banned_heading")}
</Heading>
<Text size="sm">{t("group_call_loader.banned_body")}</Text>
{joinState.reason !== undefined && (
<Text size="sm">
{t("group_call_loader.reason", { reason: joinState.reason })}
</Text>
)}
</>
);
case "not-allowed":
return <Text size="sm">{t("lobby.invite_only_body")}</Text>;
case "sending-request":
case "joining":
return null;
}
})();
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -233,20 +359,13 @@ export const LobbyView: FC<Props> = ({
videoEnabled={videoEnabled}
videoTrack={videoTrack}
>
<Button
className={classNames(styles.join, {
[styles.wait]: waitingForInvite,
})}
size={waitingForInvite ? "md" : "lg"}
disabled={waitingForInvite}
onClick={() => {
if (!waitingForInvite) onEnter();
}}
data-testid="lobby_joinCall"
>
{enterLabel ?? t("lobby.join_button")}
</Button>
{joinButton}
</VideoPreview>
{joinMessage !== null && (
<div className={styles.joinMessage} data-testid="lobby_joinMessage">
{joinMessage}
</div>
)}
{!recentsButtonInFooter && recentsButton}
</div>
{footerVm !== null && (
+1 -1
View File
@@ -85,7 +85,7 @@ export const RoomAuthView: FC = () => {
</FieldRow>
<Text size="sm">
<Trans i18nKey="room_auth_view_ssla_caption">
By clicking "Join call now", you agree to our{" "}
By clicking "Continue", you agree to our{" "}
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
+12 -16
View File
@@ -56,9 +56,8 @@ export const RoomPage: FC = (): ReactNode => {
);
usePageTitle(
roomName ??
(groupCallState.kind === "canKnock" ||
groupCallState.kind === "waitForInvite"
? groupCallState.roomSummary.name
(groupCallState.kind === "lobby"
? groupCallState.room.roomName
: undefined),
);
@@ -90,10 +89,10 @@ export const RoomPage: FC = (): ReactNode => {
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
const wasInWaitForInviteState = useRef<boolean>(false);
const wasWaitingForApproval = useRef<boolean>(false);
useEffect(() => {
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
if (groupCallState.kind === "loaded" && wasWaitingForApproval.current) {
logger.log("Play join sound 'Not yet implemented'");
}
}, [groupCallState.kind]);
@@ -108,25 +107,22 @@ export const RoomPage: FC = (): ReactNode => {
isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
skipLobby={skipLobby || wasWaitingForApproval.current}
/>
);
case "waitForInvite":
case "canKnock": {
wasInWaitForInviteState.current =
wasInWaitForInviteState.current ||
groupCallState.kind === "waitForInvite";
case "lobby": {
wasWaitingForApproval.current =
wasWaitingForApproval.current ||
groupCallState.joinState.kind === "waiting-for-approval";
return (
<KnockLobbyView
client={client!}
roomSummary={groupCallState.roomSummary}
room={groupCallState.room}
profile={{
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
}}
knock={
groupCallState.kind === "canKnock" ? groupCallState.knock : null
}
joinState={groupCallState.joinState}
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
/>
@@ -139,7 +135,7 @@ export const RoomPage: FC = (): ReactNode => {
</FullScreenView>
);
case "failed":
wasInWaitForInviteState.current = false;
wasWaitingForApproval.current = false;
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
<FullScreenView>
+96 -14
View File
@@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
>
<header>
<button
aria-labelledby="_r_36_"
aria-labelledby="_r_22_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -80,10 +80,51 @@ exports[`LobbyView > renders with AppBar android 1`] = `
role="button"
tabindex="0"
>
Join call
Request to join sent
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
</div>
<div
class="_joinMessage_f9ee84"
data-testid="lobby_joinMessage"
>
<div
class="_waiting_f9ee84"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
You will receive an invite to join the call if your request is accepted.
</p>
</div>
</div>
<a
class="_link_13esb_8"
data-kind="primary"
@@ -102,7 +143,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_3c_"
aria-labelledby="_r_28_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -133,7 +174,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_3h_"
aria-labelledby="_r_2d_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -158,7 +199,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3m_"
aria-labelledby="_r_2i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -183,7 +224,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3r_"
aria-labelledby="_r_2n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -205,7 +246,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_40_"
aria-labelledby="_r_2s_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -239,7 +280,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
>
<header>
<button
aria-labelledby="_r_4a_"
aria-labelledby="_r_36_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -312,10 +353,51 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
role="button"
tabindex="0"
>
Join call
Request to join sent
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
</div>
<div
class="_joinMessage_f9ee84"
data-testid="lobby_joinMessage"
>
<div
class="_waiting_f9ee84"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
You will receive an invite to join the call if your request is accepted.
</p>
</div>
</div>
<a
class="_link_13esb_8"
data-kind="primary"
@@ -334,7 +416,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_4g_"
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -365,7 +447,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_4l_"
aria-labelledby="_r_3h_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -390,7 +472,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4q_"
aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -415,7 +497,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4v_"
aria-labelledby="_r_3r_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -437,7 +519,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_54_"
aria-labelledby="_r_40_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
+95
View File
@@ -0,0 +1,95 @@
/*
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 {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
import { mockMatrixRoom } from "../utils/test";
import { changeMembershipWithClient } from "./membership";
const roomId = "!call:example.org";
const viaServers = ["example.org"];
const mockClient = (membership?: Membership): MatrixClient => {
const room =
membership === undefined
? null
: mockMatrixRoom({ roomId, getMyMembership: () => membership });
return {
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
knockRoom: vi.fn().mockResolvedValue({ room_id: roomId }),
leave: vi.fn().mockResolvedValue({}),
} as Partial<MatrixClient> as MatrixClient;
};
const change = (
client: MatrixClient,
): ReturnType<typeof changeMembershipWithClient> =>
changeMembershipWithClient(client, roomId, viaServers);
describe("changeMembershipWithClient", () => {
it("joins with the servers to try", async () => {
const client = mockClient(KnownMembership.Leave);
await expect(change(client)({ action: "join" })).resolves.toBe(
KnownMembership.Join,
);
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
it("knocks with the reason and the servers to try", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(
change(client)({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(client.knockRoom).toHaveBeenCalledWith(roomId, {
viaServers,
reason: "let me in",
});
});
it("reports a knock the room state has yet to catch up with", async () => {
// A user who withdrew a request and asked again is still `leave` locally
await expect(
change(mockClient(KnownMembership.Leave))({ action: "knock" }),
).resolves.toBe(KnownMembership.Knock);
});
it("withdraws a request by leaving", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(change(client)({ action: "cancel_knock" })).resolves.toBe(
KnownMembership.Leave,
);
expect(client.leave).toHaveBeenCalledWith(roomId);
});
it("retries a join the server has yet to see the invite for", async () => {
vi.useFakeTimers();
try {
const client = mockClient(KnownMembership.Invite);
const joinRoom = vi
.spyOn(client, "joinRoom")
.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN", error: "Not invited" }),
)
.mockResolvedValueOnce(client.getRoom(roomId)!);
const joined = change(client)({ action: "join" });
await vi.runAllTimersAsync();
await expect(joined).resolves.toBe(KnownMembership.Join);
expect(joinRoom).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
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 {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
/** A change to the local user's membership of the room the call is in. */
export type MembershipChange =
| { action: "join" }
| { action: "knock"; reason?: string }
/** Withdraws a request to join. */
| { action: "cancel_knock" };
/**
* Applies a membership change, resolving to the membership it resulted in.
*/
export type ChangeMembership = (
change: MembershipChange,
) => Promise<Membership>;
/**
* The host has no implementation of the requested membership change, so there
* is no point in offering it again.
*/
export class MembershipUnsupportedError extends Error {}
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
/**
* Joins a room the user has been invited to, retrying on M_FORBIDDEN: a
* homeserver that has not yet caught up with an invite from another server
* refuses the join.
*
* @see https://github.com/element-hq/element-call/issues/2634
*/
async function joinRoomAfterInvite(
client: MatrixClient,
roomId: string,
viaServers: string[],
attempt = 1,
): Promise<void> {
try {
await client.joinRoom(roomId, { viaServers });
} catch (error) {
if (
error instanceof MatrixError &&
error.errcode === "M_FORBIDDEN" &&
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
) {
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
await joinRoomAfterInvite(client, roomId, viaServers, attempt + 1);
} else {
throw error;
}
}
}
/**
* Changes the membership over the client-server API, with Element Call's own
* access token.
*/
export function changeMembershipWithClient(
client: MatrixClient,
roomId: string,
viaServers: string[],
): ChangeMembership {
return async (change) => {
// A request the server accepted decides the membership: the local room
// state still holds the previous one until the next sync brings the new
// member event, so reading it back here would report the old membership.
switch (change.action) {
case "join":
if (
client.getRoom(roomId)?.getMyMembership() === KnownMembership.Invite
)
await joinRoomAfterInvite(client, roomId, viaServers);
else await client.joinRoom(roomId, { viaServers });
return KnownMembership.Join;
case "knock":
await client.knockRoom(roomId, { viaServers, reason: change.reason });
return KnownMembership.Knock;
case "cancel_knock":
await client.leave(roomId);
return KnownMembership.Leave;
}
};
}
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import {
EventType,
MatrixEvent,
type Room,
type RoomState,
type RoomSummary,
} from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMatrixRoom } from "../utils/test";
import {
preJoinRoomInfoFromRoom,
preJoinRoomInfoFromSummary,
} from "./preJoinRoomInfo";
const roomId = "!call:example.org";
const summary = (extra: Partial<RoomSummary> = {}): RoomSummary =>
({
room_id: roomId,
name: "Weekly sync",
canonical_alias: "#sync:example.org",
avatar_url: "mxc://example.org/avatar",
world_readable: false,
guest_can_join: false,
num_joined_members: 3,
...extra,
}) as RoomSummary;
const room = (extra: { encryption?: string } = {}): Room =>
mockMatrixRoom({
roomId,
name: "Weekly sync",
getCanonicalAlias: () => "#sync:example.org",
getMxcAvatarUrl: () => "mxc://example.org/avatar",
currentState: {
getStateEvents: (type: string) =>
type === EventType.RoomEncryption && extra.encryption !== undefined
? new MatrixEvent({
type: EventType.RoomEncryption,
state_key: "",
content: { algorithm: extra.encryption },
})
: null,
} as unknown as RoomState,
});
describe("preJoinRoomInfoFromSummary", () => {
it("reads the stable encryption field", () => {
expect(
preJoinRoomInfoFromSummary(
summary({ encryption: "m.megolm.v1.aes-sha2" } as Partial<RoomSummary>),
),
).toEqual({
roomId,
roomName: "Weekly sync",
roomAlias: "#sync:example.org",
roomAvatar: "mxc://example.org/avatar",
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
});
});
it("falls back to the unstable encryption field", () => {
expect(
preJoinRoomInfoFromSummary(
summary({ "im.nheko.summary.encryption": "m.megolm.v1.aes-sha2" }),
).e2eeSystem,
).toEqual({ kind: E2eeType.PER_PARTICIPANT });
});
it("is unencrypted when neither field is set", () => {
expect(preJoinRoomInfoFromSummary(summary()).e2eeSystem).toEqual({
kind: E2eeType.NONE,
});
});
});
describe("preJoinRoomInfoFromRoom", () => {
it("reads the encryption state event", () => {
expect(
preJoinRoomInfoFromRoom(room({ encryption: "m.megolm.v1.aes-sha2" })),
).toEqual({
roomId,
roomName: "Weekly sync",
roomAlias: "#sync:example.org",
roomAvatar: "mxc://example.org/avatar",
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
});
});
it("is unencrypted with no encryption state event", () => {
expect(preJoinRoomInfoFromRoom(room()).e2eeSystem).toEqual({
kind: E2eeType.NONE,
});
});
});
+64
View File
@@ -0,0 +1,64 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType, type Room, type RoomSummary } from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
/**
* The subset of MatrixInfo that is knowable before the room is joined, from
* either a room summary or the room state a widget host pushes.
*/
export interface PreJoinRoomInfo {
roomId: string;
roomName: string;
roomAlias: string | null;
roomAvatar: string | null;
e2eeSystem: EncryptionSystem;
}
// `RoomSummary` in the js-sdk omits `canonical_alias` and has no stable
// `encryption` field.
type WidenedRoomSummary = RoomSummary & {
canonical_alias?: string;
encryption?: string;
};
const e2eeSystem = (encryption: string | undefined): EncryptionSystem =>
encryption === undefined
? { kind: E2eeType.NONE }
: { kind: E2eeType.PER_PARTICIPANT };
export function preJoinRoomInfoFromSummary(
summary: RoomSummary,
): PreJoinRoomInfo {
const widened = summary as WidenedRoomSummary;
return {
roomId: summary.room_id,
roomName: summary.name ?? "",
roomAlias: widened.canonical_alias ?? null,
roomAvatar: summary.avatar_url ?? null,
e2eeSystem: e2eeSystem(
widened.encryption ?? summary["im.nheko.summary.encryption"],
),
};
}
export function preJoinRoomInfoFromRoom(room: Room): PreJoinRoomInfo {
return {
roomId: room.roomId,
roomName: room.name,
roomAlias: room.getCanonicalAlias(),
roomAvatar: room.getMxcAvatarUrl(),
e2eeSystem: e2eeSystem(
room.currentState
.getStateEvents(EventType.RoomEncryption, "")
?.getContent().algorithm,
),
};
}
+662
View File
@@ -0,0 +1,662 @@
/*
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, type Mock } from "vitest";
import { type ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import EventEmitter from "events";
import {
ClientEvent,
EventType,
JoinRule,
KnownMembership,
MatrixEvent,
RoomEvent,
SyncState,
type MatrixClient,
type Membership,
type Room,
type RoomState,
type RoomSummary,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { mockMatrixRoom } from "../utils/test";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge";
import { getUrlParams, UrlParamsProvider } from "../UrlParams";
import { MembershipUnsupportedError } from "./membership";
import { type LobbyJoinState } from "./LobbyJoinState";
import {
useLoadGroupCall,
type GroupCallLobby,
type GroupCallStatus,
} from "./useLoadGroupCall";
const roomId = "!call:example.org";
const userId = "@gwen:example.org";
const viaServers = ["example.org"];
interface RoomSpec {
membership?: Membership;
prevMembership?: Membership;
joinRule?: string;
reason?: string;
}
/** A room whose membership follows `spec`, so a test can change it. */
function mockRoom(spec: RoomSpec = {}): Room {
const state = new Map<string, MatrixEvent>([
[
EventType.RoomMember,
new MatrixEvent({
type: EventType.RoomMember,
state_key: userId,
content: {
membership: spec.membership ?? KnownMembership.Leave,
reason: spec.reason,
},
unsigned:
spec.prevMembership === undefined
? undefined
: { prev_content: { membership: spec.prevMembership } },
}),
],
]);
if (spec.joinRule !== undefined)
state.set(
EventType.RoomJoinRules,
new MatrixEvent({
type: EventType.RoomJoinRules,
state_key: "",
content: { join_rule: spec.joinRule },
}),
);
return mockMatrixRoom({
roomId,
myUserId: userId,
name: "Weekly sync",
getCanonicalAlias: () => null,
getMxcAvatarUrl: () => null,
getMyMembership: () => spec.membership!,
currentState: {
getStateEvents: (type: string) => state.get(type) ?? null,
} as unknown as RoomState,
});
}
type TestClient = MatrixClient & { events: EventEmitter };
function mockClient(overrides: Partial<MatrixClient> = {}): TestClient {
const events = new EventEmitter();
return {
events,
on: events.on.bind(events),
off: events.off.bind(events),
emit: events.emit.bind(events),
listenerCount: events.listenerCount.bind(events),
getUserId: () => userId,
getSyncState: () => SyncState.Syncing,
getRoom: vi.fn().mockReturnValue(null),
getRoomIdForAlias: vi.fn(),
getRoomSummary: vi.fn(),
joinRoom: vi.fn(),
knockRoom: vi.fn().mockResolvedValue({ room_id: roomId }),
leave: vi.fn().mockResolvedValue({}),
waitUntilRoomReadyForGroupCalls: vi.fn().mockResolvedValue(undefined),
matrixRTC: { getRoomSession: (room: Room) => ({ room }) },
...overrides,
} as unknown as TestClient;
}
const summary = (extra: Record<string, unknown>): RoomSummary =>
({
room_id: roomId,
name: "Weekly sync",
world_readable: false,
guest_can_join: false,
num_joined_members: 2,
...extra,
}) as RoomSummary;
/**
* Renders the hook, as a widget of `host` when one is given.
*/
const renderLoad = (
client: MatrixClient,
host?: HostBridge,
): ReturnType<typeof renderHook<GroupCallStatus, unknown>> =>
renderHook(() => useLoadGroupCall(client, roomId, viaServers), {
wrapper: ({ children }): ReactNode =>
host === undefined ? (
children
) : (
<UrlParamsProvider value={{ ...getUrlParams(), isWidget: true }}>
<HostBridgeProvider value={host}>{children}</HostBridgeProvider>
</UrlParamsProvider>
),
});
const lobby = (state: GroupCallStatus): GroupCallLobby => {
expect(state.kind).toBe("lobby");
return state as GroupCallLobby;
};
async function waitForJoinState<K extends LobbyJoinState["kind"]>(
result: { current: GroupCallStatus },
kind: K,
): Promise<Extract<LobbyJoinState, { kind: K }>> {
await waitFor(() => expect(lobby(result.current).joinState.kind).toBe(kind));
return lobby(result.current).joinState as Extract<
LobbyJoinState,
{ kind: K }
>;
}
const emitMembership = (
client: TestClient,
room: Room,
membership: Membership,
prevMembership?: Membership,
): void =>
act(() => {
client.events.emit(
RoomEvent.MyMembership,
room,
membership,
prevMembership,
);
});
describe("useLoadGroupCall in the standalone app", () => {
it("joins a public room", async () => {
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Public })),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
expect(client.waitUntilRoomReadyForGroupCalls).toHaveBeenCalledWith(roomId);
});
it("treats a room with no summary as public", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({
getRoomSummary: vi.fn().mockRejectedValue(new Error("no /summary here")),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
expect(warn).toHaveBeenCalled();
});
it("asks to join a knock room, then joins once accepted", async () => {
const spec: RoomSpec = {};
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Knock })),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
expect(canAsk.error).toBeUndefined();
act(() => canAsk.askToJoin());
spec.membership = KnownMembership.Knock;
await waitForJoinState(result, "waiting-for-approval");
expect(client.knockRoom).toHaveBeenCalledWith(roomId, {
viaServers,
reason: undefined,
});
spec.membership = KnownMembership.Invite;
emitMembership(client, room, KnownMembership.Invite, KnownMembership.Knock);
await waitFor(() =>
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers }),
);
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.waitUntilRoomReadyForGroupCalls).toHaveBeenCalledWith(roomId);
});
it("joins straight away when a request was accepted before this load", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "joining");
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.getRoomSummary).not.toHaveBeenCalled();
});
it("offers a join of our own when an accepted request cannot be taken up", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const room = mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
});
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockRejectedValue(new Error("offline")),
});
const { result } = renderLoad(client);
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(error).toHaveBeenCalled();
});
it("joins an invite that answers no request without a lobby", async () => {
const room = mockRoom({ membership: KnownMembership.Invite });
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
it("shows a declined request in the lobby", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "waiting-for-approval");
spec.membership = KnownMembership.Leave;
emitMembership(client, room, KnownMembership.Leave, KnownMembership.Knock);
await waitForJoinState(result, "denied");
});
it("shows a ban in the lobby", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "waiting-for-approval");
emitMembership(
client,
mockRoom({ membership: KnownMembership.Ban, reason: "spam" }),
KnownMembership.Ban,
);
expect(await waitForJoinState(result, "banned")).toMatchObject({
reason: "spam",
});
});
it("shows a ban found before any request in the lobby", async () => {
const room = mockRoom({ membership: KnownMembership.Ban, reason: "spam" });
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client);
expect(await waitForJoinState(result, "banned")).toMatchObject({
reason: "spam",
});
expect(client.getRoomSummary).not.toHaveBeenCalled();
});
it("keeps the user in the lobby when the request fails to send", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(mockRoom()),
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Knock })),
knockRoom: vi.fn().mockRejectedValue(new Error("offline")),
});
const { result } = renderLoad(client);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin());
await waitFor(() => expect(client.knockRoom).toHaveBeenCalled());
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: "request_failed",
});
});
it("withdraws a request and offers to ask again", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
const waiting = await waitForJoinState(result, "waiting-for-approval");
act(() => waiting.cancelRequest!());
await waitFor(() => expect(client.leave).toHaveBeenCalledWith(roomId));
spec.membership = KnownMembership.Leave;
emitMembership(client, room, KnownMembership.Leave, KnownMembership.Knock);
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: undefined,
});
});
it("offers nothing for an invite-only room", async () => {
const client = mockClient({
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Invite })),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "not-allowed");
});
});
describe("useLoadGroupCall as a widget", () => {
/** A host that answers membership changes with `changeMembership`. */
const host = (
changeMembership: Mock = vi.fn().mockResolvedValue(KnownMembership.Knock),
): HostBridge => ({ ...nullHostBridge, changeMembership });
it("proceeds straight to the call when the host has joined us", async () => {
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host());
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it.each([
["a ban", { membership: KnownMembership.Ban }, "banned"],
[
"a pending request",
{ membership: KnownMembership.Knock },
"waiting-for-approval",
],
[
"an invite that replaced a request",
{
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
},
"joining",
],
["a plain invite", { membership: KnownMembership.Invite }, "can-join"],
[
"a declined request",
{
membership: KnownMembership.Leave,
prevMembership: KnownMembership.Knock,
},
"denied",
],
[
"a public room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Public },
"can-join",
],
[
"a restricted room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Restricted },
"can-join",
],
[
"a knock_restricted room",
{ membership: KnownMembership.Leave, joinRule: "knock_restricted" },
"can-join",
],
[
"a knock room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Knock },
"can-ask-to-join",
],
[
"a knock room with no membership",
{ joinRule: JoinRule.Knock },
"can-ask-to-join",
],
[
"an invite-only room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Invite },
"not-allowed",
],
[
"a room with no join rule",
{ membership: KnownMembership.Leave },
"not-allowed",
],
])("opens the lobby of %s", async (_case, spec, expected) => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(mockRoom(spec)),
});
// A request the host never answers, so that the state a lobby opens on is
// the one under test.
const { result } = renderLoad(
client,
host(vi.fn().mockReturnValue(new Promise(() => {}))),
);
await waitForJoinState(result, expected as LobbyJoinState["kind"]);
});
it("warns when the host has not shared the join rule", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const client = mockClient({
getRoom: vi
.fn()
.mockReturnValue(mockRoom({ membership: KnownMembership.Leave })),
});
const { result } = renderLoad(client, host());
await waitForJoinState(result, "not-allowed");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("m.room.join_rules"),
);
});
it("waits for approval when the host knocked instead of joining", async () => {
const changeMembership = vi.fn().mockResolvedValue(KnownMembership.Knock);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: "knock_restricted",
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canJoin = await waitForJoinState(result, "can-join");
act(() => canJoin.join());
await waitForJoinState(result, "waiting-for-approval");
expect(changeMembership).toHaveBeenCalledWith({ action: "join" });
});
it("returns to the lobby when the host does not answer", async () => {
const changeMembership = vi
.fn()
.mockRejectedValue(new Error("Request timed out"));
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin("let me in"));
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: "request_failed",
});
expect(changeMembership).toHaveBeenCalledWith({
action: "knock",
reason: "let me in",
});
});
it("gives up when the host has no membership action", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result } = renderLoad(
client,
host(vi.fn().mockRejectedValue(new MembershipUnsupportedError())),
);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin());
await waitForJoinState(result, "not-allowed");
});
it("asks the host to join us as soon as a request is accepted", async () => {
const changeMembership = vi.fn().mockReturnValue(new Promise(() => {}));
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host(changeMembership));
await waitForJoinState(result, "waiting-for-approval");
spec.membership = KnownMembership.Invite;
emitMembership(client, room, KnownMembership.Invite, KnownMembership.Knock);
await waitForJoinState(result, "joining");
expect(changeMembership).toHaveBeenCalledWith({ action: "join" });
});
it("enters the call when the host reports us joined", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
// The host joins us as it answers, so no membership event of our own
// follows the reply.
const joinAsHost = vi.fn(async (): Promise<Membership> => {
spec.membership = KnownMembership.Join;
return await Promise.resolve(KnownMembership.Join);
});
const { result } = renderLoad(client, host(joinAsHost));
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it("stays in the call when the host answers our join late", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
let refuse!: (error: Error) => void;
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(
client,
host(
vi.fn().mockReturnValue(
new Promise((_resolve, reject) => {
refuse = reject;
}),
),
),
);
await waitForJoinState(result, "joining");
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
const loaded = result.current;
refuse(new Error("Request timed out"));
await waitFor(() => expect(error).toHaveBeenCalled());
expect(result.current).toBe(loaded);
});
it("offers a join of our own when the host cannot answer", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const changeMembership = vi
.fn()
.mockRejectedValue(new Error("Request timed out"));
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canJoin = await waitForJoinState(result, "can-join");
expect(canJoin).toMatchObject({ notice: "request_accepted" });
expect(error).toHaveBeenCalled();
act(() => canJoin.join());
await waitForJoinState(result, "joining");
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(changeMembership).toHaveBeenCalledTimes(2);
});
it("resolves when the host joined us before we could listen", async () => {
const room = mockRoom({ membership: KnownMembership.Knock });
(room as { getMyMembership: () => Membership }).getMyMembership = vi
.fn()
.mockReturnValueOnce(KnownMembership.Knock)
.mockReturnValue(KnownMembership.Join);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host());
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it("adds no listener as the join state changes, and removes them all on unmount", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result, unmount } = renderLoad(client, host());
const canAsk = await waitForJoinState(result, "can-ask-to-join");
const listeners = client.listenerCount(RoomEvent.MyMembership);
act(() => canAsk.askToJoin());
await waitForJoinState(result, "waiting-for-approval");
expect(client.listenerCount(RoomEvent.MyMembership)).toBe(listeners);
unmount();
expect(client.listenerCount(RoomEvent.MyMembership)).toBe(0);
expect(client.listenerCount(ClientEvent.Sync)).toBe(0);
});
});
+453 -228
View File
@@ -1,5 +1,6 @@
/*
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.
@@ -9,7 +10,6 @@ import {
useState,
useEffect,
useRef,
useCallback,
type ComponentType,
type SVGAttributes,
} from "react";
@@ -19,6 +19,7 @@ import {
SyncState,
MatrixError,
KnownMembership,
type Membership,
ClientEvent,
type MatrixClient,
type RoomSummary,
@@ -30,11 +31,21 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useTranslation } from "react-i18next";
import {
AdminIcon,
CloseIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useUrlParams } from "../UrlParams";
import { useHostBridge } from "../HostBridge";
import { type LobbyJoinState } from "./LobbyJoinState";
import {
preJoinRoomInfoFromRoom,
preJoinRoomInfoFromSummary,
type PreJoinRoomInfo,
} from "./preJoinRoomInfo";
import {
changeMembershipWithClient,
MembershipUnsupportedError,
} from "./membership";
export type GroupCallLoaded = {
kind: "loaded";
@@ -50,59 +61,21 @@ export type GroupCallLoading = {
kind: "loading";
};
export type GroupCallWaitForInvite = {
kind: "waitForInvite";
roomSummary: RoomSummary;
};
export type GroupCallCanKnock = {
kind: "canKnock";
roomSummary: RoomSummary;
knock: () => void;
/**
* The call cannot be entered yet, so the lobby is shown with whatever the user
* can do about it.
*/
export type GroupCallLobby = {
kind: "lobby";
room: PreJoinRoomInfo;
joinState: LobbyJoinState;
};
export type GroupCallStatus =
| GroupCallLoaded
| GroupCallLoadFailed
| GroupCallLoading
| GroupCallWaitForInvite
| GroupCallCanKnock;
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
/**
* Join a room, and retry on M_FORBIDDEN error in order to work
* around a potential race when joining rooms over federation.
*
* Will wait up to to `DELAY_MS_FOR_INVITE_JOIN_FAILURE` per attempt.
* Will try up to `MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE` times.
*
* @see https://github.com/element-hq/element-call/issues/2634
* @param client The matrix client
* @param attempt Number of attempts made.
* @param params Parameters to pass to client.joinRoom
*/
async function joinRoomAfterInvite(
client: MatrixClient,
attempt = 0,
...params: Parameters<MatrixClient["joinRoom"]>
): ReturnType<MatrixClient["joinRoom"]> {
try {
return await client.joinRoom(...params);
} catch (ex) {
if (
ex instanceof MatrixError &&
ex.errcode === "M_FORBIDDEN" &&
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
) {
// If we were invited and got a M_FORBIDDEN, it's highly likely the server hasn't caught up yet.
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
return joinRoomAfterInvite(client, attempt + 1, ...params);
}
throw ex;
}
}
| GroupCallLobby;
export class CallTerminatedMessage extends Error {
/**
@@ -124,6 +97,23 @@ export class CallTerminatedMessage extends Error {
}
}
/** The membership the local user's current one replaced, if any. */
function previousMembership(room: Room): Membership | undefined {
return room.currentState
.getStateEvents(EventType.RoomMember, room.myUserId)
?.getPrevContent().membership as Membership | undefined;
}
/** The join state a lobby opens on, before the user acts on it. */
type LobbyEntry =
| "can-join"
| "can-ask-to-join"
| "joining"
| "waiting-for-approval"
| "denied"
| "banned"
| "not-allowed";
export const useLoadGroupCall = (
client: MatrixClient | undefined,
roomIdOrAlias: string | null,
@@ -133,47 +123,64 @@ export const useLoadGroupCall = (
const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation();
const { isWidget } = useUrlParams();
const bannedError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
),
[t],
);
const knockRejectError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
CloseIcon,
t("group_call_loader.knock_reject_heading"),
t("group_call_loader.knock_reject_body"),
leaveReason(),
),
[t],
);
const removeNoticeError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),
),
[t],
);
const leaveReason = (): string =>
activeRoom.current?.currentState
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
?.getContent().reason;
const hostBridge = useHostBridge();
useEffect(() => {
if (!client || !roomIdOrAlias) {
return;
}
const controller = new AbortController();
const { signal } = controller;
/** Adds a client listener that is removed when the effect is cleaned up. */
const onMyMembership = (
listener: (
room: Room,
membership: Membership,
prevMembership?: Membership,
) => void,
): (() => void) => {
client.on(RoomEvent.MyMembership, listener);
const off = (): void => {
client.off(RoomEvent.MyMembership, listener);
};
signal.addEventListener("abort", off);
return off;
};
const leaveReason = (): string =>
activeRoom.current?.currentState
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
?.getContent().reason;
const bannedError = (): CallTerminatedMessage =>
new CallTerminatedMessage(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
);
const removeNoticeError = (): CallTerminatedMessage =>
new CallTerminatedMessage(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),
);
/**
* Wait for the room to be usable for group calls. Widget mode has no
* equivalent gate, because the host owns the sync.
*/
const readyForGroupCalls = async (room: Room): Promise<Room> => {
logger.info(
`Joined ${room.roomId}, waiting for the room to be ready for group calls`,
);
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
logger.info(`${room.roomId} is ready for group calls`);
return room;
};
const getRoomByAlias = async (alias: string): Promise<Room> => {
// We lowercase the localpart when we create the room, so we must lowercase
// it here too (we just do the whole alias). We can't do the same to room IDs
@@ -181,150 +188,371 @@ export const useLoadGroupCall = (
// Also, we explicitly look up the room alias here. We previously just tried to
// join anyway but the js-sdk recreates the room if you pass the alias for a
// room you're already joined to (which it probably ought not to).
let room: Room | null = null;
const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase());
logger.info(`${alias} resolved to ${lookupResult.room_id}`);
room = client.getRoom(lookupResult.room_id);
if (!room) {
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
room = await client.joinRoom(lookupResult.room_id, {
viaServers: lookupResult.servers,
});
} else {
const room = client.getRoom(lookupResult.room_id);
if (room) {
logger.info(`Already in room ${lookupResult.room_id}, not rejoining.`);
return room;
}
return room;
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
return await readyForGroupCalls(
await client.joinRoom(lookupResult.room_id, {
viaServers: lookupResult.servers,
}),
);
};
const getRoomByKnocking = async (
/**
* Watch for the local user joining the room. `check` re-tests the room as
* it stands, for a join that happened without an event we saw, such as one
* the host had already applied.
*/
const watchForJoin = (
roomId: string,
viaServers: string[],
onKnockSent: () => void,
): Promise<Room> => {
await client.knockRoom(roomId, { viaServers });
onKnockSent();
return await new Promise<Room>((resolve, reject) => {
client.on(
RoomEvent.MyMembership,
(room, membership, prevMembership): void => {
if (roomId !== room.roomId) return;
activeRoom.current = room;
if (
membership === KnownMembership.Invite &&
prevMembership === KnownMembership.Knock
) {
joinRoomAfterInvite(client, 0, room.roomId, { viaServers }).then(
(room) => {
logger.log("Auto-joined %s", room.roomId);
resolve(room);
},
reject,
);
}
if (membership === KnownMembership.Ban) reject(bannedError());
if (membership === KnownMembership.Leave)
reject(knockRejectError());
},
);
): { reached: Promise<Room>; check: () => void } => {
let check = (): void => {};
const reached = new Promise<Room>((resolve) => {
const test = (room: Room | null): boolean => {
if (room?.getMyMembership() !== KnownMembership.Join) return false;
activeRoom.current = room;
resolve(room);
return true;
};
const off = onMyMembership((room) => {
if (room.roomId === roomId && test(room)) off();
});
check = (): void => {
if (test(client.getRoom(roomId))) off();
};
check();
});
return { reached, check };
};
/**
* Show the lobby and resolve once the local user has joined. Denial, ban
* and a room that takes nothing from this user are lobby states rather
* than errors, so the promise never resolves and the render follows the
* join state.
*
* @param room The room as far as it is knowable before joining
* @param entry The join state to open on
*/
const enterFromLobby = async (
room: PreJoinRoomInfo,
entry: LobbyEntry,
): Promise<Room> => {
const roomId = room.roomId;
const changeMembership =
hostBridge.changeMembership ??
changeMembershipWithClient(client, roomId, viaServers);
let requestInFlight = false;
let withdrawing = false;
let entered = false;
// Listening before anything is sent, so that a join landing while a
// request is in flight is not missed.
const { reached, check } = watchForJoin(roomId);
/** Sets the lobby state, unless the user is already through the lobby. */
const setLobby = (joinState: LobbyJoinState): void => {
if (!entered && !signal.aborted)
setState({ kind: "lobby", room, joinState });
};
const waitForApproval = (): void =>
setLobby({ kind: "waiting-for-approval", cancelRequest });
const canAskToJoin = (error?: "request_failed"): void =>
setLobby({ kind: "can-ask-to-join", askToJoin, error });
const canJoin = (): void => setLobby({ kind: "can-join", join });
/** The request was accepted, but the join it entitles us to failed. */
const acceptedButNotJoined = (): void =>
setLobby({
kind: "can-join",
join: joinOnceAccepted,
notice: "request_accepted",
});
const onRequestResult = (
membership: Membership,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A membership the room already holds reaches us in the reply rather
// than in an event of its own, so the join this lobby is parked on is
// re-tested here.
if (membership === KnownMembership.Join) return check();
if (membership === KnownMembership.Knock) return waitForApproval();
logger.error(
`${operation} on ${roomId} left us with membership ${membership}`,
);
onFailure();
};
const onRequestError = (
error: unknown,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A refusal by the server, or a host with no implementation of the
// operation, means this user cannot get in this way at all.
if (
error instanceof MembershipUnsupportedError ||
(error instanceof MatrixError && error.errcode === "M_FORBIDDEN")
) {
logger.warn(`${operation} on ${roomId} was refused`, error);
setLobby({ kind: "not-allowed" });
} else {
logger.error(`${operation} on ${roomId} failed`, error);
onFailure();
}
};
const join = (): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) => onRequestResult(membership, "Joining", canJoin),
(error) => onRequestError(error, "Joining", canJoin),
);
};
/** Takes up the invite an accepted request has earned us. */
const joinOnceAccepted = (): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) =>
onRequestResult(
membership,
"Joining once accepted",
acceptedButNotJoined,
),
(error) =>
onRequestError(
error,
"Joining once accepted",
acceptedButNotJoined,
),
);
};
const askToJoin = (reason?: string): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "sending-request" });
const failed = (): void => canAskToJoin("request_failed");
changeMembership({ action: "knock", reason }).then(
(membership) => onRequestResult(membership, "Asking to join", failed),
(error) => onRequestError(error, "Asking to join", failed),
);
};
const cancelRequest = (): void => {
if (withdrawing) return;
withdrawing = true;
// Drop the link while the withdrawal is on its way, so it cannot be
// pressed twice.
setLobby({ kind: "waiting-for-approval" });
changeMembership({ action: "cancel_knock" }).catch((error: unknown) => {
withdrawing = false;
logger.error("Failed to withdraw the request to join", error);
waitForApproval();
});
};
const offTransitions = onMyMembership(
(changed, membership, prevMembership) => {
if (changed.roomId !== roomId) return;
activeRoom.current = changed;
switch (membership) {
case KnownMembership.Invite:
// An invite that replaced a request is ours to take up: nothing
// else is going to turn it into a join.
if (prevMembership === KnownMembership.Knock) joinOnceAccepted();
else canJoin();
break;
case KnownMembership.Ban:
setLobby({ kind: "banned", reason: leaveReason() });
break;
case KnownMembership.Leave:
// Withdrawing a request produces the same membership as a decline,
// so the two are told apart by who initiated it.
if (withdrawing) {
withdrawing = false;
canAskToJoin();
} else {
setLobby({ kind: "denied" });
}
break;
}
},
);
switch (entry) {
case "can-join":
canJoin();
break;
case "can-ask-to-join":
canAskToJoin();
break;
case "joining":
joinOnceAccepted();
break;
case "waiting-for-approval":
waitForApproval();
break;
case "denied":
setLobby({ kind: "denied" });
break;
case "banned":
setLobby({ kind: "banned", reason: leaveReason() });
break;
case "not-allowed":
setLobby({ kind: "not-allowed" });
break;
}
const joined = await reached;
entered = true;
offTransitions();
return joined;
};
/**
* What the lobby of a room the host has pushed to us offers, from our
* membership, the membership it replaced, and the join rule.
*/
const widgetLobbyEntry = (
room: Room,
membership: Membership | undefined,
): LobbyEntry => {
const prevMembership = previousMembership(room);
if (membership === KnownMembership.Ban) return "banned";
if (membership === KnownMembership.Knock) return "waiting-for-approval";
if (membership === KnownMembership.Invite)
return prevMembership === KnownMembership.Knock
? "joining"
: "can-join";
if (prevMembership === KnownMembership.Knock) return "denied";
// An absent join rule is not `invite`: it means the host granted no
// capability to read the state event.
const joinRules = room.currentState.getStateEvents(
EventType.RoomJoinRules,
"",
);
if (joinRules === null) {
logger.warn(
`No ${EventType.RoomJoinRules} state in ${room.roomId}; the host has not shared the join rule`,
);
return "not-allowed";
}
const joinRule = joinRules.getContent().join_rule as string;
switch (joinRule) {
// The widget cannot evaluate `allowed_room_ids`, since it does not know
// the user's other rooms, so it offers a join and lets the host decide.
case JoinRule.Public:
case JoinRule.Restricted:
// `JoinRule` in the js-sdk has no member for knock_restricted yet.
case "knock_restricted":
return "can-join";
case JoinRule.Knock:
return "can-ask-to-join";
default:
logger.info(
`Room ${room.roomId} takes neither joins nor knocks (join rule ${joinRule})`,
);
return "not-allowed";
}
};
const fetchOrCreateRoom = async (): Promise<Room> => {
let room: Room | null = null;
if (roomIdOrAlias[0] === "#") {
const alias = roomIdOrAlias;
// The call uses a room alias
room = await getRoomByAlias(alias);
const room = await getRoomByAlias(roomIdOrAlias);
activeRoom.current = room;
} else {
// The call uses a room_id
const roomId = roomIdOrAlias;
return room;
}
const roomId = roomIdOrAlias;
// first try if the room already exists
// - in widget mode
// - in SPA mode if the user already joined the room
room = client.getRoom(roomId);
activeRoom.current = room ?? undefined;
const membership = room?.getMyMembership();
if (membership === KnownMembership.Join) {
// room already joined so we are done here already.
return room!;
}
if (isWidget)
// in widget mode we never should reach this point. (getRoom should return the room.)
// The room already exists in widget mode, and in SPA mode if the user
// has joined it before.
const room = client.getRoom(roomId);
activeRoom.current = room ?? undefined;
const membership = room?.getMyMembership();
if (membership === KnownMembership.Join) return room!;
if (isWidget) {
if (!room)
// In widget mode we never should reach this point. (getRoom should return the room.)
throw new Error(
"Room not found. The widget-api did not pass over the relevant room events/information.",
);
if (membership === KnownMembership.Ban) {
throw bannedError();
} else if (membership === KnownMembership.Invite) {
room = await client.joinRoom(roomId, {
viaServers,
});
} else {
// If the room does not exist we first search for it with viaServers
let roomSummary: RoomSummary | undefined = undefined;
try {
roomSummary = await client.getRoomSummary(roomId, viaServers);
} catch (error) {
// If the room summary endpoint is not supported we let it be undefined and treat this case like
// `JoinRule.Public`.
// This is how the logic was done before: "we expect any room id passed to EC
// to be for a public call" Which is definitely not ideal but worth a try if fetching
// the summary crashes.
logger.warn(
`Could not load room summary to decide whether we want to join or knock.
EC will fallback to join as if this would be a public room.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
error,
);
}
if (
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {
viaServers,
});
} else if (roomSummary.join_rule === JoinRule.Knock) {
// bind room summary in this scope so we have it stored in a binding of type `RoomSummary`
// instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept
// the type check from the if condition above.
const _roomSummary = roomSummary;
let knock: () => void = () => {};
const userPressedAskToJoinPromise: Promise<void> = new Promise(
(resolve) => {
if (_roomSummary.membership !== KnownMembership.Knock) {
knock = resolve;
} else {
// resolve immediately if the user already knocked
resolve();
}
},
);
setState({ kind: "canKnock", roomSummary: _roomSummary, knock });
await userPressedAskToJoinPromise;
room = await getRoomByKnocking(
roomSummary.room_id,
viaServers,
() =>
setState({ kind: "waitForInvite", roomSummary: _roomSummary }),
);
} else {
throw new Error(
`Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,
);
}
}
return await enterFromLobby(
preJoinRoomInfoFromRoom(room),
widgetLobbyEntry(room, membership),
);
}
if (room && membership === KnownMembership.Ban)
return await enterFromLobby(preJoinRoomInfoFromRoom(room), "banned");
if (room && membership === KnownMembership.Invite)
return await readyForGroupCalls(
previousMembership(room) === KnownMembership.Knock
? await enterFromLobby(preJoinRoomInfoFromRoom(room), "joining")
: await client.joinRoom(roomId, { viaServers }),
);
// If the room does not exist we first search for it with viaServers
let roomSummary: RoomSummary | undefined = undefined;
try {
roomSummary = await client.getRoomSummary(roomId, viaServers);
} catch (error) {
// If the room summary endpoint is not supported we let it be undefined and treat this case like
// `JoinRule.Public`.
// This is how the logic was done before: "we expect any room id passed to EC
// to be for a public call" Which is definitely not ideal but worth a try if fetching
// the summary crashes.
logger.warn(
`Could not load room summary to decide whether we want to join or knock.
EC will fallback to join as if this would be a public room.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
error,
);
}
if (
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
)
return await readyForGroupCalls(
await client.joinRoom(roomId, { viaServers }),
);
const roomInfo = preJoinRoomInfoFromSummary(roomSummary);
if (roomSummary.membership === KnownMembership.Ban)
return await enterFromLobby(roomInfo, "banned");
if (roomSummary.join_rule === JoinRule.Knock)
return await readyForGroupCalls(
await enterFromLobby(
roomInfo,
roomSummary.membership === KnownMembership.Knock
? "waiting-for-approval"
: "can-ask-to-join",
),
);
logger.info(
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`,
`Room ${roomSummary.room_id} takes neither joins nor knocks (join rule ${roomSummary.join_rule})`,
);
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
logger.info(`${roomIdOrAlias}, is ready for group calls`);
return room;
return await enterFromLobby(roomInfo, "not-allowed");
};
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
@@ -349,6 +577,9 @@ export const useLoadGroupCall = (
}
};
client.on(ClientEvent.Sync, onSync);
signal.addEventListener("abort", () => {
client.off(ClientEvent.Sync, onSync);
});
});
logger.debug("useLoadGroupCall: client is now syncing.");
}
@@ -356,32 +587,26 @@ export const useLoadGroupCall = (
const observeMyMembership = async (): Promise<void> => {
await new Promise((_, reject) => {
client.on(RoomEvent.MyMembership, (_, membership) => {
onMyMembership((_room, membership) => {
if (membership === KnownMembership.Leave) reject(removeNoticeError());
if (membership === KnownMembership.Ban) reject(bannedError());
});
});
};
if (state.kind === "loading") {
logger.log("Start loading group call");
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.then(observeMyMembership)
.catch((error) => setState({ kind: "failed", error }));
}
}, [
bannedError,
client,
isWidget,
knockRejectError,
removeNoticeError,
roomIdOrAlias,
state,
t,
viaServers,
]);
logger.log("Start loading group call");
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((rtcSession) => {
if (!signal.aborted) setState({ kind: "loaded", rtcSession });
})
.then(observeMyMembership)
.catch((error) => {
if (!signal.aborted) setState({ kind: "failed", error });
});
return (): void => controller.abort();
}, [client, hostBridge, isWidget, roomIdOrAlias, viaServers, t]);
return state;
};
-82
View File
@@ -1,82 +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 { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "@vector-im/compound-web";
import { describe, expect, it, vi } from "vitest";
import { DeviceSelection } from "./DeviceSelection";
import { constant } from "../state/Behavior";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedDevice,
} from "../state/MediaDevices";
type Device = MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
function mockDevice(
available: [string, DeviceLabel][],
selectedId: string | undefined,
): Device & { select: ReturnType<typeof vi.fn> } {
return {
available$: constant(new Map(available)),
selected$: constant(
selectedId === undefined ? undefined : { id: selectedId },
),
select: vi.fn(),
} as unknown as Device & { select: ReturnType<typeof vi.fn> };
}
const twoDevices: [string, DeviceLabel][] = [
["a", { type: "name", name: "Mic A" }],
["b", { type: "number", number: 2 }],
];
describe("DeviceSelection", () => {
it("marks the selected device and selects another on click", async () => {
const device = mockDevice(twoDevices, "a");
render(
<Form.Root>
<DeviceSelection
device={device}
title="Microphone"
numberedLabel={(n) => `Microphone ${n}`}
/>
</Form.Root>,
);
const first = screen.getByRole<HTMLInputElement>("radio", {
name: "Mic A",
});
const second = screen.getByRole<HTMLInputElement>("radio", {
name: "Microphone 2",
});
expect(first.checked).toBe(true);
expect(second.checked).toBe(false);
await userEvent.click(second);
expect(device.select).toHaveBeenCalledWith("b");
});
it("shows nothing when there is no choice to make", () => {
const device = mockDevice([twoDevices[0]], "a");
render(
<Form.Root>
<DeviceSelection
device={device}
title="Microphone"
numberedLabel={(n) => `Microphone ${n}`}
/>
</Form.Root>,
);
expect(screen.queryByRole("radio")).toBeNull();
expect(screen.queryByText("Microphone")).toBeNull();
});
});
+3 -3
View File
@@ -21,6 +21,7 @@ import {
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import {
type AudioOutputDeviceLabel,
@@ -29,7 +30,6 @@ import {
type MediaDevice,
} from "../state/MediaDevices";
import styles from "./DeviceSelection.module.css";
import { useBehavior } from "../useBehavior";
interface Props {
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
@@ -44,8 +44,8 @@ export const DeviceSelection: FC<Props> = ({
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useBehavior(device.available$);
const selectedId = useBehavior(device.selected$)?.id;
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
device.select(e.target.value);
-1
View File
@@ -10,7 +10,6 @@ The following sound effects have been licensed from Pixabay, under https://pixab
- `reactions/generic`
- `reactions/lightbulb`
- `reactions/party`
- `reactions/bell`
### Other
Binary file not shown.
Binary file not shown.
+1 -61
View File
@@ -10,7 +10,6 @@ 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 = {
@@ -94,8 +93,6 @@ describe("AudioOutput Tests", () => {
beforeEach(() => {
testScope = new ObservableScope();
// Device preferences persist in localStorage across tests
audioOutputSetting.setValue(undefined);
});
afterEach(() => {
@@ -153,9 +150,7 @@ describe("AudioOutput Tests", () => {
expectObservable(audioOutput.selected$).toBe("abcde", {
a: undefined,
// No "default" pseudo-device (Firefox): the virtual browser default is
// selected until the user picks something.
b: { id: "", virtualEarpiece: false },
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
@@ -163,61 +158,6 @@ 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.
+28 -24
View File
@@ -16,6 +16,7 @@ import {
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
filter,
@@ -38,12 +39,11 @@ import {
throttleTime,
timer,
takeUntil,
from,
} from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
@@ -112,7 +112,7 @@ import {
TransportState,
} from "./localMember/LocalMember.ts";
import {
getLocalTransport,
createLocalTransport$,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
@@ -563,26 +563,20 @@ export function createCallViewModel$(
const ownMembershipIdentity: CallMembershipIdentityParts = {
userId,
deviceId,
// Consumed by the sticky membership manager as `member.id`, *and* stamped
// into every to-device key event by the key transport. A pre-sticky
// membership advertises `${userId}:${deviceId}` as its `membershipID`
// instead, so a uuid there names a member no peer can resolve.
memberId:
matrixRTCMode === MatrixRTCMode.Matrix_2_0
? uuidv4()
: `${userId}:${deviceId}`,
// This will only be consumed by the sticky membership manager. So it has no impact on legacy calls.
memberId: uuidv4(),
};
const localTransport$ = options.localTransport
? constant(options.localTransport)
: from(
getLocalTransport({
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
}),
);
const localTransport =
options.localTransport ??
createLocalTransport$({
scope: scope,
memberships$: memberships$,
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
});
const connectionFactory =
options.connectionFactory ??
@@ -599,7 +593,17 @@ export function createCallViewModel$(
const connectionManager = createConnectionManager$({
scope: scope,
connectionFactory: connectionFactory,
localTransport$,
localTransport$: scope.behavior(
localTransport.active$.pipe(
catchError((e: unknown) => {
logger.info(
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
e,
);
return of(null);
}),
),
),
remoteTransports$: membershipsAndTransports.transports$,
logger: logger,
ownMembershipIdentity,
@@ -624,7 +628,7 @@ export function createCallViewModel$(
),
muteStates,
joinMatrixRTC: (
transport: LivekitTransport,
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => {
return enterRTCSession(
@@ -655,7 +659,7 @@ export function createCallViewModel$(
connectionManager,
client,
matrixRTCSession,
localTransport$,
localTransport,
roomId: matrixRoom.roomId,
hideScreensharing,
hostBridge,
@@ -210,8 +210,11 @@ export function withCallViewModel(mode: MatrixRTCMode) {
connectionState$,
windowSize$,
localTransport: {
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
active$: constant({
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
}),
advertised$: constant(exampleTransport),
},
connectionFactory: {
createConnection(
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
Status as RTCMemberStatus,
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import {
@@ -21,7 +21,7 @@ import {
beforeEach,
afterEach,
} from "vitest";
import { BehaviorSubject, map, of, Subject } from "rxjs";
import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
import fetchMock from "fetch-mock";
@@ -50,7 +50,10 @@ import {
TrackState,
watchScreenShareToggle,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
@@ -58,7 +61,10 @@ import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge";
import { type LocalTransport } from "./LocalTransport";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget();
@@ -121,7 +127,8 @@ const mockedClient = {
};
describe("enterRTCSession", () => {
const transport: LivekitTransport = {
const transport: LivekitTransportConfig = {
livekit_alias: "roomId",
livekit_service_url: "http://my-livekit-service-url.com",
type: "livekit",
};
@@ -138,7 +145,7 @@ describe("enterRTCSession", () => {
client: mockedClient,
},
memberships: [],
join: vi.fn(),
joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession;
beforeEach(() =>
@@ -150,18 +157,19 @@ describe("enterRTCSession", () => {
it("It joins the correct Session", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
{
deviceId: "DEVICE",
memberId: "@alice:example.org:DEVICE",
userId: "@alice:example.org",
},
[transport],
[],
transport,
expect.objectContaining({ manageMediaKeys: true }),
);
});
it("passes keyRotationParticipantLimit from config to join", () => {
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
matrix_rtc_session: {
@@ -174,8 +182,9 @@ describe("enterRTCSession", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.any(Object),
[],
expect.any(Object),
expect.objectContaining({
keyRotationParticipantLimit: 50,
@@ -186,7 +195,8 @@ describe("enterRTCSession", () => {
it("uses the specified delayed leave timings", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
@@ -206,7 +216,7 @@ describe("LocalMembership", () => {
}),
matrixRTCSession: {
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
leave: vi.fn(),
leaveRoomSession: vi.fn(),
} as unknown as MatrixRTCSession,
muteStates: mockMuteStates(),
trackProcessorState$: constant({
@@ -251,8 +261,8 @@ describe("LocalMembership", () => {
});
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransport>(
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
);
@@ -267,15 +277,16 @@ describe("LocalMembership", () => {
),
};
const aLocalTransport: LocalTransport = {
advertised$: localTransport$,
active$: behavior("a", { a: null }),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$: hot(
"1ms #",
{},
new MatrixRTCTransportMissingError("domain.com"),
),
localTransport: aLocalTransport,
});
expectObservable(localMembership.localMemberState$).toBe("ne", {
@@ -285,9 +296,61 @@ describe("LocalMembership", () => {
});
});
it("Should not publish to active transport if advertised has errors", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
null,
);
// Populate a connection for active
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportBConnected, []);
const mockConnectionManager = {
transports$: constant(new Epoch([bTransport])),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
};
const aLocalTransport: LocalTransport = {
advertised$,
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
};
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
return {} as unknown as Publisher;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport: aLocalTransport,
});
expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
});
// Should not have created any publisher
expect(publisherFactory).toHaveBeenCalledTimes(0);
});
});
it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
@@ -300,10 +363,10 @@ describe("LocalMembership", () => {
...defaultCreateLocalMemberValues,
matrixRTCSession: {
updateCallIntent: vi.fn().mockImplementation(reject),
leave: vi.fn(),
leaveRoomSession: vi.fn(),
},
connectionManager: mockConnectionManager,
localTransport$: constant(mockTransport),
localTransport: aLocalTransport,
});
const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)";
@@ -315,19 +378,33 @@ describe("LocalMembership", () => {
scope.end();
});
const mockTransportConfig = {
const aTransport = {
livekit_service_url: "a",
} as LivekitTransport;
} as LivekitTransportConfig;
const mockTransport = {
transport: mockTransportConfig,
const aTransportWithSFUConfig = {
transport: aTransport,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransport;
} as LocalTransportWithSFUConfig;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransportConfig;
const bTransportWithSFUConfig = {
transport: bTransport,
sfuConfig: {
jwt: "foo2",
livekitAlias: "bar2",
livekitIdentity: "baz2",
url: "bro2",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
@@ -337,13 +414,18 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant,
}),
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
} as Connection;
transport: aTransport,
} as unknown as Connection;
const connectionTransportAConnecting = {
...connectionTransportAConnected,
state$: constant(ConnectionState.LivekitConnecting),
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected),
transport: bTransport,
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
@@ -377,7 +459,10 @@ describe("LocalMembership", () => {
),
},
joinMatrixRTC,
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
delayId$,
});
@@ -386,7 +471,7 @@ describe("LocalMembership", () => {
await flushPromises();
// Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith(
mockTransportConfig,
aTransport,
delayedLeaveTimings,
);
@@ -420,7 +505,75 @@ describe("LocalMembership", () => {
},
);
it("only starts tracks if requested", async () => {
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope();
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const publishers: Publisher[] = [];
let seed = 0;
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const a = seed;
seed += 1;
logger.info(`creating [${a}]`);
const p = {
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
// all the cleanup we need.
destroy: vi.fn(),
stopPublishing: vi.fn().mockImplementation(() => {
logger.info(`stopPublishing [${a}]`);
}),
stopTracks: vi.fn(),
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
connectionManagerData.add(connectionTransportBConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport: aLocalTransport,
});
await flushPromises();
activeTransport$.next({
...aTransportWithSFUConfig,
transport: bTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
expect(publishers.length).toBe(2);
// stop the first Publisher and let the second one life.
expect(publishers[0].destroy).toHaveBeenCalled();
expect(publishers[1].destroy).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
scope.end();
await flushPromises();
// stop all tracks after ending scopes
expect(publishers[1].destroy).toHaveBeenCalled();
// expect(publishers[1].stopTracks).toHaveBeenCalled();
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
});
it("only start tracks if requested", async () => {
const scope = new ObservableScope();
const publishers: Publisher[] = [];
@@ -449,6 +602,11 @@ describe("LocalMembership", () => {
typeof vi.fn
>;
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
@@ -458,7 +616,7 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: aLocalTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
@@ -479,8 +637,16 @@ describe("LocalMembership", () => {
//
it("tracks livekit state correctly", async () => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new Subject<LocalTransport>();
const activeTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
@@ -521,14 +687,14 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$,
},
localTransport$,
localTransport: aLocalTransport,
});
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(mockTransport);
activeTransport$.next(aTransportWithSFUConfig);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
@@ -553,7 +719,7 @@ describe("LocalMembership", () => {
});
(
connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
connectionManagerData2.getConnectionForTransport(aTransport)!
.state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({
@@ -656,7 +822,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -693,7 +862,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -741,7 +913,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -782,7 +957,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -848,7 +1026,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
return { scope, localMembership };
};
@@ -858,7 +1039,7 @@ describe("LocalMembership", () => {
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = {
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
transport: aTransport,
livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({
isScreenShareEnabled: false,
+160 -109
View File
@@ -19,6 +19,7 @@ import { type MatrixClient } from "matrix-js-sdk";
import {
Status as RTCSessionStatus,
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
@@ -37,10 +38,6 @@ import {
startWith,
switchMap,
tap,
NEVER,
concat,
race,
Subject,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -80,6 +77,8 @@ import {
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { or$ } from "../../../utils/observable.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
export enum TransportState {
@@ -143,15 +142,18 @@ interface Props {
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (
transport: LivekitTransport,
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => void;
homeserverConnected: HomeserverConnected;
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Observable<LocalTransport>;
localTransport: LocalTransport;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
matrixRTCSession: Pick<MatrixRTCSession, "updateCallIntent" | "leave">;
matrixRTCSession: Pick<
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
>;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */
@@ -192,7 +194,7 @@ interface Props {
export const createLocalMembership$ = ({
scope,
connectionManager,
localTransport$: localTransportWithErrors$,
localTransport,
homeserverConnected,
createPublisherFactory,
joinMatrixRTC,
@@ -250,24 +252,20 @@ export const createLocalMembership$ = ({
const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const fatalTransportError$ = new Subject<ElementCallError>();
const localTransport$ = localTransportWithErrors$.pipe(
catchError((e: unknown) => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error
? e
: new Error("Unknown error from localTransport"),
);
}
fatalTransportError$.next(error);
return NEVER; // Make this Observable swallow the error
}),
);
// We consider error on the transport as fatal.
// Whether it is the active transport or the preferred transport.
const handleTransportError = (e: unknown): Observable<null> => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error from localTransport"),
);
}
setTransportError(error);
return of(null);
};
async function checkDelegationSupport(
endpointUrl: string,
@@ -305,40 +303,58 @@ export const createLocalMembership$ = ({
// The transport that we will advertise in our membership, paired with info as
// to whether delayed event delegation is supported
const joinParams$ = scope.behavior(
localTransport$.pipe(
switchMap(async ({ transport }) => {
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return {
transport,
delegationSupported:
(await homeserverSupportsDelegation) ||
(await transportSupportsDelegation),
};
const joinParams$ = localTransport.advertised$.pipe(
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
switchMap((transport) => {
if (transport === null) return of(null);
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return or$(
from(homeserverSupportsDelegation),
from(transportSupportsDelegation),
).pipe(
map((delegationSupported) => ({ transport, delegationSupported })),
startWith(null),
);
}),
);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const activeTransport$ = scope.behavior(
combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
map(([active, advertised]) => {
// Our policy is to not publish to another transport if our prefered transport is miss-configured
if (advertised == null) return null;
return active?.transport ?? null;
}),
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
),
null,
);
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
activeTransport$,
]).pipe(
map(([{ value: connectionData }, { transport }]) =>
connectionData.getConnectionForTransport(transport),
),
map(([{ value: connectionData }, localTransport]) => {
if (localTransport === null) {
return null;
}
return connectionData.getConnectionForTransport(localTransport);
}),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
null,
);
// Tracks error that happen when creating the local tracks.
@@ -474,6 +490,18 @@ export const createLocalMembership$ = ({
}
};
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
null,
);
const setTransportError = (e: ElementCallError): void => {
if (fatalTransportError$.value !== null) {
logger.error("Multiple Transport Errors:", e);
} else {
fatalTransportError$.next(e);
}
};
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
@@ -481,29 +509,38 @@ export const createLocalMembership$ = ({
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([
localConnectionState$,
activeTransport$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
startWith(false),
),
]).pipe(
map(([localConnectionState, shouldPublish, shouldStartTracks]) => {
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
map(
([
localConnectionState,
localTransport,
shouldPublish,
shouldStartTracks,
]) => {
if (!localTransport) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
}),
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
},
),
distinctUntilChanged(deepCompare),
),
);
@@ -517,36 +554,30 @@ export const createLocalMembership$ = ({
};
const localMemberState$ = scope.behavior<LocalMemberState>(
concat(
// Waiting until…
of(TransportState.Waiting),
race(
// either there is a fatal transport error
fatalTransportError$,
// or the transport is available.
localTransport$.pipe(
switchMap(() =>
// Once available, track session/media state.
combineLatest([
mediaState$,
homeserverConnected.rtsSession$,
fatalMatrixError$,
publishError$,
]).pipe(
map(
([
mediaState,
rtcSessionStatus,
fatalMatrixError,
publishError,
]) => ({
matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
}),
),
),
),
),
combineLatest([
mediaState$,
homeserverConnected.rtsSession$,
fatalMatrixError$,
fatalTransportError$,
publishError$,
]).pipe(
map(
([
mediaState,
rtcSessionStatus,
fatalMatrixError,
fatalTransportError,
publishError,
]) => {
if (fatalTransportError !== null) return fatalTransportError;
// `mediaState` will be 'null' until the transport/connection appears.
if (mediaState && rtcSessionStatus)
return {
matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
};
return TransportState.Waiting;
},
),
),
);
@@ -654,7 +685,7 @@ export const createLocalMembership$ = ({
});
});
// Join and leave the session as needed
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
scope.reconcile(
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
async ([joinParams, shouldConnect]) => {
@@ -679,7 +710,7 @@ export const createLocalMembership$ = ({
return Promise.resolve(async (): Promise<void> => {
try {
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
await matrixRTCSession.leave(1000);
await matrixRTCSession.leaveRoomSession(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
@@ -962,7 +993,7 @@ interface EnterRTCSessionOptions {
export function enterRTCSession(
rtcSession: MatrixRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: LivekitTransport,
transport: LivekitTransportConfig,
{
encryptMedia,
matrixRTCMode,
@@ -983,6 +1014,21 @@ export function enterRTCSession(
matrix_rtc_session: sessionConfig,
} = Config.get();
const retryInterval = sessionConfig.network_error_retry_ms;
const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
// we add the livekit alias to the transport.
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
backwardCompatibleTransport = transport;
} else {
backwardCompatibleTransport = {
livekit_alias: rtcSession.room.roomId,
...transport,
};
}
// Set maximumNetworkErrorRetryCount such that we will consider the client
// disconnected as soon as either it fails to sync for longer than the grace
@@ -995,19 +1041,24 @@ export function enterRTCSession(
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
// TODO where/how do we track errors originating from the ongoing rtcSession?
rtcSession.join(ownMembershipIdentity, [transport], {
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
delayedLeaveTimings.restart_timeout_ms,
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
});
rtcSession.joinRTCSession(
ownMembershipIdentity,
multiSFU ? [] : [backwardCompatibleTransport],
multiSFU ? backwardCompatibleTransport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
delayedLeaveTimings.restart_timeout_ms,
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
},
);
}
@@ -14,10 +14,19 @@ import {
type MockedObject,
vi,
} from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import { mockConfig, ownMemberMock } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport";
import {
mockConfig,
flushPromises,
ownMemberMock,
testScope,
} from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import {
MatrixRTCTransportMissingError,
FailToGetOpenIdToken,
@@ -38,79 +47,118 @@ describe("LocalTransport", () => {
beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => {
await expect(
getLocalTransport({
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
});
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport
const scope = new ObservableScope();
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("no openid")),
const resolver = Promise.withResolvers<void>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
async () => {
await resolver.promise;
throw new FailToGetOpenIdToken(new Error("no openid"));
},
);
const observations: unknown[] = [];
const errors: Error[] = [];
const { advertised$, active$ } = createLocalTransport$({
scope,
roomId: "!example_room_id",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
active$.subscribe(
(o) => observations.push(o),
(e) => errors.push(e),
);
resolver.resolve();
await flushPromises();
await expect(
getLocalTransport({
roomId: "!example_room_id",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
expect(observations).toStrictEqual([null]);
expect(errors).toStrictEqual([expectedError]);
expect(() => advertised$.value).toThrow(expectedError);
expect(() => active$.value).toThrow(expectedError);
});
it("returns preferred transport", async () => {
it("emits preferred transport after OpenID resolves", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
openIdResolver.resolve?.({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect(
await getLocalTransport({
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
// final
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
@@ -120,19 +168,22 @@ describe("LocalTransport", () => {
});
});
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>;
};
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
beforeEach(() => {
mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = {
ownMembershipIdentity: ownMemberMock,
scope: testScope(),
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -141,6 +192,10 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(),
},
};
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
});
afterEach(() => {
@@ -151,15 +206,19 @@ describe("LocalTransport", () => {
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
@@ -171,11 +230,12 @@ describe("LocalTransport", () => {
it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org");
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
await flushPromises();
expect(active$.value).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
@@ -193,15 +253,19 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
@@ -215,31 +279,38 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
expect.any(FailToGetOpenIdToken),
);
await expect(async () =>
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
});
it("throws if no options are available", async () => {
await expect(
getLocalTransport({
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More