mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-23 00:02:26 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5733e8274 | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
81233e5cfb | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
5c497cc6a8 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
8ebb0b5a90 |
@@ -48,6 +48,8 @@ pnpm i18n:check
|
|||||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
- 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.
|
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
- 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
|
- Anything that redraws continuously — an animation frame, a stream — says what it
|
||||||
costs, or better, counts the redraws in a test.
|
costs, or better, counts the redraws in a test.
|
||||||
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
|
|||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||||
homeservers.
|
homeservers.
|
||||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||||
inside your own application.
|
inside your own application.
|
||||||
✅ **WebRTC-based** – No additional software required.
|
✅ **WebRTC-based** – No additional software required.
|
||||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
✅ **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
|
✅ **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
|
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||||
engagement and interactivity to the conversation.
|
engagement and interactivity to the conversation.
|
||||||
|
|
||||||
@@ -309,6 +309,23 @@ root. The host imports the component from
|
|||||||
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
|
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
|
||||||
them external.
|
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
|
### Backend
|
||||||
|
|
||||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPERIMENTAL
|
||||||
|
*
|
||||||
|
* What a host needs in order to talk about Element Call, without Element Call:
|
||||||
|
* the component's props and the types they are made of, the handle and the
|
||||||
|
* host bridge, the intents a call can be started with and the configuration
|
||||||
|
* each implies.
|
||||||
|
*
|
||||||
|
* This is an entry point of its own, `@element-hq/element-call-component/api`,
|
||||||
|
* so that a host can import it on a path that must stay light — a call model
|
||||||
|
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
|
||||||
|
* itself, and everything it brings, only when a call is shown. The main entry
|
||||||
|
* point re-exports all of it, so a host that does not care may import
|
||||||
|
* everything from there.
|
||||||
|
*
|
||||||
|
* Nothing in here may reach anything that is not a type or a constant: the
|
||||||
|
* point of this module is what it does not import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Ref } from "react";
|
||||||
|
import type { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
|
import type { UrlProperties } from "../src/UrlParams";
|
||||||
|
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||||
|
import {
|
||||||
|
BackgroundStyle,
|
||||||
|
configurationForIntent,
|
||||||
|
HeaderStyle,
|
||||||
|
type UrlConfiguration,
|
||||||
|
UserIntent,
|
||||||
|
} from "../src/UrlConfiguration";
|
||||||
|
|
||||||
|
// How the host and Element Call talk to each other, and what they say
|
||||||
|
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||||
|
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
|
||||||
|
export type { JoinCallData } from "../src/widget";
|
||||||
|
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||||
|
// below, which is per call
|
||||||
|
export type { ConfigOptions } from "../src/config/ConfigOptions";
|
||||||
|
// The values that appear in ElementCallConfiguration and in the intent, and
|
||||||
|
// what each intent means by default
|
||||||
|
export {
|
||||||
|
BackgroundStyle,
|
||||||
|
configurationForIntent,
|
||||||
|
HeaderStyle,
|
||||||
|
type UrlConfiguration,
|
||||||
|
UserIntent,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How Element Call should behave. Everything is optional; anything left out
|
||||||
|
* takes the default that {@link ElementCallProps.intent} implies.
|
||||||
|
*
|
||||||
|
* This is the behaviour a widget can be configured with through its URL, plus
|
||||||
|
* the one fact about the call a host has a say in here, the background. The
|
||||||
|
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||||
|
* homeserver, where to report analytics, the shared secret of a room that is
|
||||||
|
* encrypted with one — a component host supplies by other routes, or not at
|
||||||
|
* all; and what can change while the call is running, the theme and the
|
||||||
|
* language, is a prop of its own.
|
||||||
|
*/
|
||||||
|
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||||
|
Partial<Pick<UrlProperties, "background">>;
|
||||||
|
|
||||||
|
export interface ElementCallProps {
|
||||||
|
/**
|
||||||
|
* The client to place the call with. Element Call does not authenticate
|
||||||
|
* anyone or manage a session of its own; this one is the host's.
|
||||||
|
*/
|
||||||
|
client: MatrixClient;
|
||||||
|
/** The room to call in. The host's client must already know about it. */
|
||||||
|
roomId: string;
|
||||||
|
/**
|
||||||
|
* What the user asked for — whether they started the call or joined one that
|
||||||
|
* was already running, and whether it is a call in a group or a DM. Element
|
||||||
|
* Call decides what each of those means: whether to show the lobby first,
|
||||||
|
* whether to ring, and so on.
|
||||||
|
*
|
||||||
|
* Defaults to joining an existing group call, which is the most conservative
|
||||||
|
* reading, but a host that knows which button the user pressed should say so.
|
||||||
|
*/
|
||||||
|
intent?: UserIntent;
|
||||||
|
/**
|
||||||
|
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||||
|
* A host that finds itself setting a lot of these probably wants a different
|
||||||
|
* intent instead.
|
||||||
|
*
|
||||||
|
* Compared by value, so it is fine to write this inline; only a change to
|
||||||
|
* what it says restarts anything.
|
||||||
|
*/
|
||||||
|
config?: ElementCallConfiguration;
|
||||||
|
/**
|
||||||
|
* What Element Call tells the host while the call is running: that the user
|
||||||
|
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||||
|
* Without one, Element Call assumes nobody is listening.
|
||||||
|
*/
|
||||||
|
hostBridge?: ElementCallHostBridge;
|
||||||
|
/**
|
||||||
|
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||||
|
* once the component has rendered.
|
||||||
|
*/
|
||||||
|
ref?: Ref<ElementCallHandle>;
|
||||||
|
/**
|
||||||
|
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||||
|
* Call picks. Changes take effect at once, and cost nothing else.
|
||||||
|
*/
|
||||||
|
theme?: string;
|
||||||
|
/**
|
||||||
|
* The language to show Element Call in, as a BCP 47 tag: one of the
|
||||||
|
* `supportedLanguages` the main entry point exports, or something that falls
|
||||||
|
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
|
||||||
|
*
|
||||||
|
* Translations are one thing shared by every Element Call on the page, so
|
||||||
|
* the most recently set language wins for all of them.
|
||||||
|
*/
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
+6
-96
@@ -36,14 +36,12 @@ import {
|
|||||||
type FC,
|
type FC,
|
||||||
type JSX,
|
type JSX,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Ref,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
@@ -62,10 +60,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
|||||||
import {
|
import {
|
||||||
configurationForIntent,
|
configurationForIntent,
|
||||||
componentProperties,
|
componentProperties,
|
||||||
type UrlConfiguration,
|
|
||||||
type UrlParams,
|
type UrlParams,
|
||||||
UrlParamsProvider,
|
UrlParamsProvider,
|
||||||
type UrlProperties,
|
|
||||||
UserIntent,
|
UserIntent,
|
||||||
useUrlParams,
|
useUrlParams,
|
||||||
} from "../src/UrlParams";
|
} from "../src/UrlParams";
|
||||||
@@ -79,102 +75,16 @@ import { i18n } from "../src/utils/i18n";
|
|||||||
import { useTheme } from "../src/useTheme";
|
import { useTheme } from "../src/useTheme";
|
||||||
import { useStableValue } from "../src/useStableValue";
|
import { useStableValue } from "../src/useStableValue";
|
||||||
import styles from "./ElementCall.module.css";
|
import styles from "./ElementCall.module.css";
|
||||||
import {
|
import { useComponentHostBridge } from "./host";
|
||||||
type ElementCallHandle,
|
import { type ElementCallProps } from "./api";
|
||||||
type ElementCallHostBridge,
|
|
||||||
useComponentHostBridge,
|
|
||||||
} from "./host";
|
|
||||||
import { supportedLanguages, translationsBackend } from "./localization";
|
import { supportedLanguages, translationsBackend } from "./localization";
|
||||||
|
|
||||||
// The languages Element Call can be shown in
|
// The languages Element Call can be shown in
|
||||||
export { supportedLanguages } from "./localization";
|
export { supportedLanguages } from "./localization";
|
||||||
|
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||||
// How the host and Element Call talk to each other, and what they say
|
// the host bridge, the intents and what each means — also available without
|
||||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
// the component itself from `@element-hq/element-call-component/api`
|
||||||
export {
|
export * from "./api";
|
||||||
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,
|
* Prepares the things Element Call needs before it can be shown: translations,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"types": "./dist/types/component/index.d.ts",
|
"types": "./dist/types/component/index.d.ts",
|
||||||
"default": "./dist/element-call.js"
|
"default": "./dist/element-call.js"
|
||||||
},
|
},
|
||||||
|
"./api": {
|
||||||
|
"types": "./dist/types/component/api.d.ts",
|
||||||
|
"default": "./dist/api.js"
|
||||||
|
},
|
||||||
"./style.css": "./dist/element-call.css"
|
"./style.css": "./dist/element-call.css"
|
||||||
},
|
},
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "./dist/types"
|
"outDir": "./dist/types"
|
||||||
},
|
},
|
||||||
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
|
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||||
// `import.meta.env`, …) that the sources it reaches rely on.
|
// `import.meta.env`, …) that the sources they reach rely on.
|
||||||
"include": ["./index.tsx", "../src/@types/*.d.ts"],
|
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
|
||||||
"exclude": []
|
"exclude": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||||
element-web:
|
element-web:
|
||||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||||
element-web-1:
|
element-web-1:
|
||||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||||
|
|||||||
@@ -76,4 +76,7 @@ Code that builds in only one is a bug.
|
|||||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
- `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;
|
(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` /
|
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
`matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry
|
||||||
|
points: `index.tsx` (the component) and `api.ts` (types, enums and
|
||||||
|
`configurationForIntent`, for a host that must not load the component). `api.ts`
|
||||||
|
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
|
||||||
|
|||||||
@@ -31,7 +31,28 @@ copy, the PR says so.
|
|||||||
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
||||||
rule in new ones, don't reorder old ones.
|
rule in new ones, don't reorder old ones.
|
||||||
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
||||||
`computedLayout$`. Comments explain why, never what.
|
`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
|
## TypeScript, React and RxJS idiom win on a clash
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
token from the Matrix 2.0 JWT endpoint of the
|
||||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||||
and identifies the room by a hashed identity instead of a `livekit_alias`.
|
and identifies the room by a hashed identity instead of the Matrix `room_id`.
|
||||||
(Remote memberships always try the new endpoint first and fall back to the
|
(Remote memberships always try the new endpoint first and fall back to the
|
||||||
legacy one, so remote participants can be on either.)
|
legacy one, so remote participants can be on either.)
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,12 @@
|
|||||||
"login_auth_links_prompt": "Not registered yet?",
|
"login_auth_links_prompt": "Not registered yet?",
|
||||||
"login_subheading": "To continue to Element",
|
"login_subheading": "To continue to Element",
|
||||||
"login_title": "Login",
|
"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_off": "Microphone off",
|
||||||
"microphone_on": "Microphone on",
|
"microphone_on": "Microphone on",
|
||||||
"mute_microphone_button_label": "Mute microphone",
|
"mute_microphone_button_label": "Mute microphone",
|
||||||
@@ -215,11 +221,14 @@
|
|||||||
"activating": "Activating…",
|
"activating": "Activating…",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"camera_numbered": "Camera {{n}}",
|
"camera_numbered": "Camera {{n}}",
|
||||||
|
"camera_source": "Camera Source",
|
||||||
"change_device_button": "Change audio device",
|
"change_device_button": "Change audio device",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"default_named": "Default <2>({{name}})</2>",
|
"default_named": "Default <2>({{name}})</2>",
|
||||||
|
"default_named_plain": "Default ({{name}})",
|
||||||
"handset": "Handset",
|
"handset": "Handset",
|
||||||
"loudspeaker": "Loudspeaker",
|
"loudspeaker": "Loudspeaker",
|
||||||
|
"mic_source": "Mic Source",
|
||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
"microphone_numbered": "Microphone {{n}}",
|
"microphone_numbered": "Microphone {{n}}",
|
||||||
"speaker": "Speaker",
|
"speaker": "Speaker",
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,7 @@
|
|||||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||||
"build": "pnpm build:full",
|
"build": "pnpm build:full",
|
||||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||||
"build:full:production": "pnpm build:full",
|
"build:full:production": "pnpm build:full",
|
||||||
"build:full:development": "pnpm build:full --mode development",
|
"build:full:development": "pnpm build:full --mode development",
|
||||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||||
@@ -107,7 +107,6 @@
|
|||||||
"matrix-widget-api": "^1.18.0",
|
"matrix-widget-api": "^1.18.0",
|
||||||
"node-stdlib-browser": "^1.3.1",
|
"node-stdlib-browser": "^1.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"observable-hooks": "^4.2.3",
|
|
||||||
"oxfmt": "^0.66.0",
|
"oxfmt": "^0.66.0",
|
||||||
"oxlint": "^1.70.0",
|
"oxlint": "^1.70.0",
|
||||||
"oxlint-tsgolint": "^0.25.0",
|
"oxlint-tsgolint": "^0.25.0",
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+21
-37
@@ -135,7 +135,7 @@ importers:
|
|||||||
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||||
'@vector-im/compound-web':
|
'@vector-im/compound-web':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
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@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.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)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.2
|
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))
|
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))
|
||||||
@@ -192,7 +192,7 @@ importers:
|
|||||||
version: 1.9.2
|
version: 1.9.2
|
||||||
matrix-js-sdk:
|
matrix-js-sdk:
|
||||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
|
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999
|
||||||
matrix-widget-api:
|
matrix-widget-api:
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.19.0
|
version: 1.19.0
|
||||||
@@ -202,9 +202,6 @@ importers:
|
|||||||
normalize.css:
|
normalize.css:
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
version: 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:
|
oxfmt:
|
||||||
specifier: ^0.66.0
|
specifier: ^0.66.0
|
||||||
version: 0.66.0
|
version: 0.66.0
|
||||||
@@ -1197,8 +1194,8 @@ packages:
|
|||||||
'@types/dom-mediacapture-transform': ^0.1.9
|
'@types/dom-mediacapture-transform': ^0.1.9
|
||||||
livekit-client: ^1.12.0 || ^2.1.0
|
livekit-client: ^1.12.0 || ^2.1.0
|
||||||
|
|
||||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
|
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||||
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
|
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
'@mdx-js/react@3.1.1':
|
'@mdx-js/react@3.1.1':
|
||||||
@@ -3246,8 +3243,8 @@ packages:
|
|||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vector-im/compound-web@10.0.1':
|
'@vector-im/compound-web@10.1.0':
|
||||||
resolution: {integrity: sha512-CT3aGMalPSf53o3JUbBF7EA10Rfp+auftIuBwOUSna+hfZ/zPxGLTFcuCuOJ7L8hrzijM+V4I8L0PUkfyOczpg==}
|
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@fontsource/inconsolata': ^5
|
'@fontsource/inconsolata': ^5
|
||||||
'@fontsource/inter': ^5
|
'@fontsource/inter': ^5
|
||||||
@@ -3662,8 +3659,8 @@ packages:
|
|||||||
constants-browserify@1.0.0:
|
constants-browserify@1.0.0:
|
||||||
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
||||||
|
|
||||||
content-type@3.0.0:
|
content-type@3.1.1:
|
||||||
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
|
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
@@ -4683,9 +4680,9 @@ packages:
|
|||||||
matrix-events-sdk@0.0.1:
|
matrix-events-sdk@0.0.1:
|
||||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||||
|
|
||||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
|
||||||
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
|
resolution: {gitHosted: true, integrity: sha512-qF8++74MX36KS1KEw6Lc8HHXgZ6nHD1oUU4yBZUj5vVPvZTIspk1lOGztkFmizE5vmLAHd5tdadL9Xu1Asy4bA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999}
|
||||||
version: 42.2.0
|
version: 42.3.0
|
||||||
engines: {node: '>=22.0.0'}
|
engines: {node: '>=22.0.0'}
|
||||||
|
|
||||||
matrix-widget-api@1.19.0:
|
matrix-widget-api@1.19.0:
|
||||||
@@ -4816,13 +4813,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
obug@2.1.4:
|
||||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -4905,8 +4895,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
p-retry@8.0.0:
|
p-retry@8.0.1:
|
||||||
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
|
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
p-try@2.2.0:
|
p-try@2.2.0:
|
||||||
@@ -7111,7 +7101,7 @@ snapshots:
|
|||||||
'@types/dom-mediacapture-transform': 0.1.11
|
'@types/dom-mediacapture-transform': 0.1.11
|
||||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||||
|
|
||||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||||
|
|
||||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
|
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8700,7 +8690,7 @@ snapshots:
|
|||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
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@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.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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
'@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)
|
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
@@ -9173,7 +9163,7 @@ snapshots:
|
|||||||
|
|
||||||
constants-browserify@1.0.0: {}
|
constants-browserify@1.0.0: {}
|
||||||
|
|
||||||
content-type@3.0.0: {}
|
content-type@3.1.1: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
@@ -10259,17 +10249,17 @@ snapshots:
|
|||||||
|
|
||||||
matrix-events-sdk@0.0.1: {}
|
matrix-events-sdk@0.0.1: {}
|
||||||
|
|
||||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 8.0.0
|
'@babel/runtime': 8.0.0
|
||||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
|
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||||
another-json: 0.2.0
|
another-json: 0.2.0
|
||||||
bs58: 6.0.0
|
bs58: 6.0.0
|
||||||
content-type: 3.0.0
|
content-type: 3.1.1
|
||||||
loglevel: 1.9.2
|
loglevel: 1.9.2
|
||||||
matrix-events-sdk: 0.0.1
|
matrix-events-sdk: 0.0.1
|
||||||
matrix-widget-api: 1.19.0
|
matrix-widget-api: 1.19.0
|
||||||
p-retry: 8.0.0
|
p-retry: 8.0.1
|
||||||
sdp-transform: 3.0.0
|
sdp-transform: 3.0.0
|
||||||
unhomoglyph: 1.0.6
|
unhomoglyph: 1.0.6
|
||||||
|
|
||||||
@@ -10410,12 +10400,6 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
object-keys: 1.1.1
|
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: {}
|
obug@2.1.4: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
@@ -10600,7 +10584,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
p-retry@8.0.0:
|
p-retry@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-network-error: 1.3.2
|
is-network-error: 1.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, use, useMemo } from "react";
|
import { createContext, use, useMemo } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import { type MediaDevices } from "./state/MediaDevices";
|
import { type MediaDevices } from "./state/MediaDevices";
|
||||||
|
import { useBehavior } from "./useBehavior";
|
||||||
|
|
||||||
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): {
|
|||||||
volume: number;
|
volume: number;
|
||||||
} => {
|
} => {
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
const audioOutput = useBehavior(devices.audioOutput.selected$);
|
||||||
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
||||||
return {
|
return {
|
||||||
// We use only the right speaker (pan = 1) for the earpiece.
|
// We use only the right speaker (pan = 1) for the earpiece.
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022-2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How Element Call can be told to behave, and what each intent means.
|
||||||
|
*
|
||||||
|
* Kept apart from {@link UrlParams} on purpose: everything here is constants,
|
||||||
|
* types and one function of them, so that a host of the component can import
|
||||||
|
* these — the enums it needs as values, the defaults an intent implies — without
|
||||||
|
* loading Element Call itself. UrlParams, which reads them from a URL, needs
|
||||||
|
* the router, the config and the rest of the app; this module must not.
|
||||||
|
* `component/api.ts` is built from it as an entry point of its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RTCCallIntent,
|
||||||
|
type RTCNotificationType,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import { platform } from "./Platform";
|
||||||
|
|
||||||
|
export enum UserIntent {
|
||||||
|
StartNewCall = "start_call",
|
||||||
|
JoinExistingCall = "join_existing",
|
||||||
|
StartNewCallVoice = "start_call_voice",
|
||||||
|
JoinExistingCallVoice = "join_existing_voice",
|
||||||
|
StartNewCallDM = "start_call_dm",
|
||||||
|
StartNewCallDMVoice = "start_call_dm_voice",
|
||||||
|
JoinExistingCallDM = "join_existing_dm",
|
||||||
|
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||||
|
Unknown = "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HeaderStyle {
|
||||||
|
None = "none",
|
||||||
|
Standard = "standard",
|
||||||
|
AppBar = "app_bar",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BackgroundStyle {
|
||||||
|
Solid = "solid",
|
||||||
|
Gradient = "gradient",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration for the app, which can be set via URL parameters.
|
||||||
|
* Those property are different to the UrlProperties, since they are all optional
|
||||||
|
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||||
|
* the same context but with different accounts/users.
|
||||||
|
*
|
||||||
|
* Their defaults can be controlled by the `intent` property.
|
||||||
|
*/
|
||||||
|
export interface UrlConfiguration {
|
||||||
|
/**
|
||||||
|
* Whether the app should keep the user confined to the current call/room.
|
||||||
|
*/
|
||||||
|
confineToRoom: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the app should pause before joining the call until it sees an
|
||||||
|
* io.element.join widget action, allowing it to be preloaded.
|
||||||
|
*/
|
||||||
|
preload: boolean;
|
||||||
|
/**
|
||||||
|
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||||
|
* hides the header entirely, and "app_bar" produces a header with a back
|
||||||
|
* button like you might see in mobile apps. The callback for the back button
|
||||||
|
* is window.controls.onBackButtonPressed.
|
||||||
|
*/
|
||||||
|
header: HeaderStyle;
|
||||||
|
/**
|
||||||
|
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||||
|
*/
|
||||||
|
showControls: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to hide the screen-sharing button.
|
||||||
|
*/
|
||||||
|
hideScreensharing: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||||
|
* user's homeserver doesn't provide any.
|
||||||
|
*/
|
||||||
|
allowIceFallback: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app should use per participant keys for E2EE.
|
||||||
|
*/
|
||||||
|
perParticipantE2EE: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the global JS controls for audio output devices should be enabled,
|
||||||
|
* allowing the list of output devices to be controlled by the app hosting
|
||||||
|
* Element Call.
|
||||||
|
*/
|
||||||
|
controlledAudioDevices: boolean;
|
||||||
|
/**
|
||||||
|
* Setting this flag skips the lobby and brings you in the call directly.
|
||||||
|
* In the widget this can be combined with preload to pass the device settings
|
||||||
|
* with the join widget action.
|
||||||
|
*/
|
||||||
|
skipLobby: boolean;
|
||||||
|
/**
|
||||||
|
* Setting this flag makes element call show the lobby after leaving a call.
|
||||||
|
* This is useful for video rooms.
|
||||||
|
*/
|
||||||
|
returnToLobby: boolean;
|
||||||
|
/**
|
||||||
|
* Whether and what type of notification EC should send, when the user joins the call.
|
||||||
|
*/
|
||||||
|
sendNotificationType?: RTCNotificationType;
|
||||||
|
/**
|
||||||
|
* Whether the app should automatically leave the call when there
|
||||||
|
* is no one left in the call.
|
||||||
|
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||||
|
*/
|
||||||
|
autoLeaveWhenOthersLeft: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||||
|
* This is a no-op if not combined with sendNotificationType.
|
||||||
|
*
|
||||||
|
* This entails:
|
||||||
|
* - show ui that it is awaiting an answer
|
||||||
|
* - play a sound that indicates that it is awaiting an answer
|
||||||
|
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||||
|
*/
|
||||||
|
waitForCallPickup: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable echo cancellation for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
echoCancellation?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to enable noise suppression for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
noiseSuppression?: boolean;
|
||||||
|
|
||||||
|
callIntent?: RTCCallIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you need to add a new flag to this interface, prefer a name that describes
|
||||||
|
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||||
|
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||||
|
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||||
|
// behavior to the needs of specific consumers.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration implied by what the user meant to do — if they pressed a
|
||||||
|
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||||
|
* `join_existing`.
|
||||||
|
*
|
||||||
|
* These are platform-specific defaults, so that a host can start a call by
|
||||||
|
* saying what the user asked for rather than by setting every parameter itself,
|
||||||
|
* and so that what each intent means is Element Call's decision, made in one
|
||||||
|
* place. A host that wants something else states it alongside the intent.
|
||||||
|
*
|
||||||
|
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||||
|
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||||
|
* way out of the room that a hosted call must not.
|
||||||
|
*/
|
||||||
|
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||||
|
// Only constants and `platform` here, so that this depends on nothing but
|
||||||
|
// the intent.
|
||||||
|
let preset: UrlConfiguration = {
|
||||||
|
confineToRoom: true,
|
||||||
|
preload: false,
|
||||||
|
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||||
|
showControls: true,
|
||||||
|
hideScreensharing: false,
|
||||||
|
allowIceFallback: true,
|
||||||
|
perParticipantE2EE: true,
|
||||||
|
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||||
|
skipLobby: true,
|
||||||
|
returnToLobby: false,
|
||||||
|
sendNotificationType: "notification",
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
waitForCallPickup: false,
|
||||||
|
};
|
||||||
|
switch (intent) {
|
||||||
|
case UserIntent.StartNewCall:
|
||||||
|
preset.skipLobby = false;
|
||||||
|
preset.callIntent = "video";
|
||||||
|
break;
|
||||||
|
case UserIntent.JoinExistingCall:
|
||||||
|
// On desktop this will be overridden based on which button was used to join the call
|
||||||
|
preset.skipLobby = false;
|
||||||
|
preset.callIntent = "video";
|
||||||
|
break;
|
||||||
|
case UserIntent.StartNewCallVoice:
|
||||||
|
preset.skipLobby = false;
|
||||||
|
preset.callIntent = "audio";
|
||||||
|
break;
|
||||||
|
case UserIntent.JoinExistingCallVoice:
|
||||||
|
// On desktop this will be overridden based on which button was used to join the call
|
||||||
|
preset.skipLobby = false;
|
||||||
|
preset.callIntent = "audio";
|
||||||
|
break;
|
||||||
|
case UserIntent.StartNewCallDMVoice:
|
||||||
|
preset.callIntent = "audio";
|
||||||
|
// Fall through
|
||||||
|
case UserIntent.StartNewCallDM:
|
||||||
|
preset.skipLobby = true;
|
||||||
|
preset.sendNotificationType = "ring";
|
||||||
|
preset.autoLeaveWhenOthersLeft = true;
|
||||||
|
preset.waitForCallPickup = true;
|
||||||
|
preset.callIntent = preset.callIntent ?? "video";
|
||||||
|
break;
|
||||||
|
case UserIntent.JoinExistingCallDMVoice:
|
||||||
|
preset.callIntent = "audio";
|
||||||
|
// Fall through
|
||||||
|
case UserIntent.JoinExistingCallDM:
|
||||||
|
// On desktop this will be overridden based on which button was used to join the call
|
||||||
|
preset.skipLobby = true;
|
||||||
|
preset.autoLeaveWhenOthersLeft = true;
|
||||||
|
preset.callIntent = preset.callIntent ?? "video";
|
||||||
|
break;
|
||||||
|
// Non widget usecase defaults
|
||||||
|
default:
|
||||||
|
preset = {
|
||||||
|
confineToRoom: false,
|
||||||
|
preload: false,
|
||||||
|
header: HeaderStyle.Standard,
|
||||||
|
showControls: true,
|
||||||
|
hideScreensharing: false,
|
||||||
|
allowIceFallback: false,
|
||||||
|
perParticipantE2EE: false,
|
||||||
|
controlledAudioDevices: false,
|
||||||
|
skipLobby: false,
|
||||||
|
returnToLobby: false,
|
||||||
|
sendNotificationType: undefined,
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
waitForCallPickup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
+17
-222
@@ -9,47 +9,36 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { createContext, use, useMemo } from "react";
|
import { createContext, use, useMemo } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
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 { pickBy } from "lodash-es";
|
||||||
|
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "./e2ee/e2eeType";
|
import { E2eeType } from "./e2ee/e2eeType";
|
||||||
import { platform } from "./Platform";
|
import {
|
||||||
|
BackgroundStyle,
|
||||||
|
configurationForIntent,
|
||||||
|
HeaderStyle,
|
||||||
|
type UrlConfiguration,
|
||||||
|
UserIntent,
|
||||||
|
} from "./UrlConfiguration";
|
||||||
import { redact } from "./utils/redact";
|
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 {
|
interface RoomIdentifier {
|
||||||
roomAlias: string | null;
|
roomAlias: string | null;
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
viaServers: string[];
|
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.
|
* 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
|
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||||
@@ -166,109 +155,6 @@ export interface UrlProperties {
|
|||||||
*/
|
*/
|
||||||
background: BackgroundStyle;
|
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 {}
|
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||||
|
|
||||||
class ParamParser {
|
class ParamParser {
|
||||||
@@ -354,97 +240,6 @@ export const getUrlParams = (
|
|||||||
return params;
|
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
|
* The {@link UrlProperties} for Element Call running as a component inside a
|
||||||
* host application.
|
* host application.
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer.overlay:has(:focus-visible) {
|
/* 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"]) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Link } from "@vector-im/compound-web";
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { CallFooter, type FooterSnapshot } from "./CallFooter";
|
import { CallFooter, type FooterSnapshot } from "./CallFooter";
|
||||||
import inCallViewStyles from "../room/InCallView.module.css";
|
import inCallViewStyles from "../room/InCallView.module.css";
|
||||||
|
import styles from "./CallFooter.module.css";
|
||||||
import { useStaticViewModel } from "../state/ViewModel";
|
import { useStaticViewModel } from "../state/ViewModel";
|
||||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||||
import { type ReactionOption } from "../reactions";
|
import { type ReactionOption } from "../reactions";
|
||||||
@@ -137,10 +138,13 @@ export const Default: Story = {
|
|||||||
debugTileLayout: false,
|
debugTileLayout: false,
|
||||||
tileStoreGeneration: undefined,
|
tileStoreGeneration: undefined,
|
||||||
audioOptions: [],
|
audioOptions: [],
|
||||||
|
audioOutputOptions: [],
|
||||||
videoOptions: [],
|
videoOptions: [],
|
||||||
selectedAudio: undefined,
|
selectedAudio: undefined,
|
||||||
|
selectedAudioOutput: undefined,
|
||||||
selectedVideo: undefined,
|
selectedVideo: undefined,
|
||||||
selectAudioButtonOption: undefined,
|
selectAudioButtonOption: undefined,
|
||||||
|
selectAudioOutputOption: undefined,
|
||||||
selectVideoButtonOption: undefined,
|
selectVideoButtonOption: undefined,
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -158,15 +162,46 @@ export const WithAudioAndVideoOptions: Story = {
|
|||||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
{ 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: [
|
videoOptions: [
|
||||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
selectedAudio: "2",
|
selectedAudio: "2",
|
||||||
|
selectedAudioOutput: "default",
|
||||||
selectedVideo: "1",
|
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 = {
|
export const AudioBusy: Story = {
|
||||||
...Default,
|
...Default,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -99,11 +99,15 @@ export interface FooterState {
|
|||||||
|
|
||||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||||
audioOptions: MenuOptions[];
|
audioOptions: MenuOptions[];
|
||||||
|
/** Output (speaker) devices. */
|
||||||
|
audioOutputOptions: MenuOptions[];
|
||||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||||
videoOptions: MenuOptions[];
|
videoOptions: MenuOptions[];
|
||||||
selectedAudio: string | undefined;
|
selectedAudio: string | undefined;
|
||||||
|
selectedAudioOutput: string | undefined;
|
||||||
selectedVideo: string | undefined;
|
selectedVideo: string | undefined;
|
||||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||||
|
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
const audioOptions = useBehavior(vm.audioOptions$);
|
const audioOptions = useBehavior(vm.audioOptions$);
|
||||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
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 selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||||
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
if ((audioOptions?.length ?? 0) > 0) {
|
if ((audioOptions?.length ?? 0) > 0) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Mic Source"}
|
|
||||||
key="audio"
|
key="audio"
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={audioEnabled ?? false}
|
enabled={audioEnabled ?? false}
|
||||||
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
options={audioOptions}
|
options={audioOptions}
|
||||||
selectedOption={selectedAudio}
|
selectedOption={selectedAudio}
|
||||||
onSelect={selectAudioButtonOption}
|
onSelect={selectAudioButtonOption}
|
||||||
|
outputOptions={audioOutputOptions}
|
||||||
|
selectedOutputOption={selectedAudioOutput}
|
||||||
|
onSelectOutput={selectAudioOutputOption}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
if ((videoOptions?.length ?? 0) > 0) {
|
if ((videoOptions?.length ?? 0) > 0) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Camera Source"}
|
|
||||||
key="video"
|
key="video"
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled={videoEnabled ?? false}
|
enabled={videoEnabled ?? false}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
|
|||||||
supportsBackgroundProcessors: (): boolean => false,
|
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
|
* Returns the minimum set of CallViewModel fields required by
|
||||||
* createCallFooterViewModel, with all other properties stubbed to
|
* createCallFooterViewModel, with all other properties stubbed to
|
||||||
@@ -96,6 +101,65 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createCallFooterViewModel", () => {
|
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", () => {
|
describe("audioOptions and videoOptions", () => {
|
||||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||||
platformMock.mockReturnValue(platform);
|
platformMock.mockReturnValue(platform);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { combineLatest, map, switchMap } from "rxjs";
|
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||||
|
import { supportsAudioOutputSelection } from "livekit-client";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||||
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
|
|||||||
| "audioOptions$"
|
| "audioOptions$"
|
||||||
| "selectedAudio$"
|
| "selectedAudio$"
|
||||||
| "selectAudioButtonOption$"
|
| "selectAudioButtonOption$"
|
||||||
|
| "audioOutputOptions$"
|
||||||
|
| "selectedAudioOutput$"
|
||||||
|
| "selectAudioOutputOption$"
|
||||||
| "videoOptions$"
|
| "videoOptions$"
|
||||||
| "selectedVideo$"
|
| "selectedVideo$"
|
||||||
| "selectVideoButtonOption$"
|
| "selectVideoButtonOption$"
|
||||||
| "toggleBlur$"
|
| "toggleBlur$"
|
||||||
| "videoBlurEnabled$"
|
| "videoBlurEnabled$"
|
||||||
> {
|
> {
|
||||||
return {
|
const options$ = (
|
||||||
audioOptions$: scope.behavior(
|
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||||
disableSwitcher$.pipe(
|
): Observable<MenuOptions[]> =>
|
||||||
switchMap((disable) =>
|
disableSwitcher$.pipe(
|
||||||
disable
|
switchMap((disable) =>
|
||||||
? constant([] as MenuOptions[])
|
disable
|
||||||
: mediaDevices.audioInput.available$.pipe(
|
? constant([] as MenuOptions[])
|
||||||
map((available) =>
|
: available$.pipe(
|
||||||
[...available.entries()].map(([id, label]) => ({
|
map((available) =>
|
||||||
id,
|
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||||
label,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||||
selectedAudio$: scope.behavior(
|
selectedAudio$: scope.behavior(
|
||||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||||
),
|
),
|
||||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||||
videoOptions$: scope.behavior(
|
audioOutputOptions$: scope.behavior(
|
||||||
disableSwitcher$.pipe(
|
options$(mediaDevices.audioOutput.available$),
|
||||||
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(
|
selectedVideo$: scope.behavior(
|
||||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||||
),
|
),
|
||||||
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
|
|||||||
reactionData: undefined,
|
reactionData: undefined,
|
||||||
tileStoreGeneration: undefined,
|
tileStoreGeneration: undefined,
|
||||||
audioOptions: undefined,
|
audioOptions: undefined,
|
||||||
|
audioOutputOptions: undefined,
|
||||||
videoOptions: undefined,
|
videoOptions: undefined,
|
||||||
selectedAudio: undefined,
|
selectedAudio: undefined,
|
||||||
|
selectedAudioOutput: undefined,
|
||||||
selectedVideo: undefined,
|
selectedVideo: undefined,
|
||||||
selectAudioButtonOption: undefined,
|
selectAudioButtonOption: undefined,
|
||||||
|
selectAudioOutputOption: undefined,
|
||||||
selectVideoButtonOption: undefined,
|
selectVideoButtonOption: undefined,
|
||||||
}),
|
}),
|
||||||
...buildMuteBehaviors(scope, muteStates),
|
...buildMuteBehaviors(scope, muteStates),
|
||||||
|
|||||||
@@ -35,3 +35,60 @@ Please see LICENSE in the repository root for full details.
|
|||||||
transform: rotate(360deg);
|
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,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fn, userEvent, within, expect } from "storybook/test";
|
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
|
||||||
import { type JSX } from "react";
|
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||||
|
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||||
|
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
|
import { RootElementProvider } from "../RootElementContext";
|
||||||
import { MediaDevices } from "../state/MediaDevices";
|
import { MediaDevices } from "../state/MediaDevices";
|
||||||
import { globalScope } from "../state/ObservableScope";
|
import { globalScope } from "../state/ObservableScope";
|
||||||
|
|
||||||
@@ -18,12 +21,74 @@ const mediaDevices = new MediaDevices(globalScope, {
|
|||||||
controlledAudioDevices: false,
|
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 = {
|
const meta = {
|
||||||
component: MediaMuteAndSwitchButton,
|
component: MediaMuteAndSwitchButton,
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story): JSX.Element => (
|
(Story): JSX.Element => (
|
||||||
<MediaDevicesContext value={mediaDevices}>
|
<MediaDevicesContext value={mediaDevices}>
|
||||||
<Story />
|
<WithACallArea>
|
||||||
|
<WithAMicrophone>
|
||||||
|
<Story />
|
||||||
|
</WithAMicrophone>
|
||||||
|
</WithACallArea>
|
||||||
</MediaDevicesContext>
|
</MediaDevicesContext>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -34,7 +99,6 @@ type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
title: "SomeMenu",
|
|
||||||
iconsAndLabels: "audio",
|
iconsAndLabels: "audio",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
options: [
|
options: [
|
||||||
@@ -42,6 +106,13 @@ export const Default: Story = {
|
|||||||
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
selectedOption: "1",
|
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(),
|
onMuteClick: fn(),
|
||||||
onSelect: fn(),
|
onSelect: fn(),
|
||||||
},
|
},
|
||||||
@@ -50,7 +121,6 @@ export const Default: Story = {
|
|||||||
export const AudioMute: Story = {
|
export const AudioMute: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
title: "Microphone",
|
|
||||||
iconsAndLabels: "audio",
|
iconsAndLabels: "audio",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
options: [
|
options: [
|
||||||
@@ -73,21 +143,19 @@ export const AudioMute: Story = {
|
|||||||
|
|
||||||
export const AudioUnmute: Story = {
|
export const AudioUnmute: Story = {
|
||||||
args: {
|
args: {
|
||||||
title: "Microphone",
|
...Default.args,
|
||||||
iconsAndLabels: "audio",
|
iconsAndLabels: "audio",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
options: [
|
options: [
|
||||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
|
|
||||||
selectedOption: "2",
|
selectedOption: "2",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoMute: Story = {
|
export const VideoMute: Story = {
|
||||||
args: {
|
args: {
|
||||||
title: "Camera",
|
|
||||||
iconsAndLabels: "video",
|
iconsAndLabels: "video",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
options: [
|
options: [
|
||||||
@@ -101,7 +169,6 @@ export const VideoMute: Story = {
|
|||||||
|
|
||||||
export const VideoUnmute: Story = {
|
export const VideoUnmute: Story = {
|
||||||
args: {
|
args: {
|
||||||
title: "Camera",
|
|
||||||
iconsAndLabels: "video",
|
iconsAndLabels: "video",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
options: [
|
options: [
|
||||||
@@ -113,3 +180,479 @@ export const VideoUnmute: Story = {
|
|||||||
selectedOption: "2",
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,40 +5,61 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
import { axe } from "vitest-axe";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
type RenderResult,
|
||||||
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { type JSX, useState, type ReactNode } from "react";
|
import { Profiler, type JSX, useState, type ReactNode } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
import {
|
||||||
|
MediaMuteAndSwitchButton,
|
||||||
|
type MenuOptions,
|
||||||
|
} from "./MediaMuteAndSwitchButton";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { type MediaDevices } from "../state/MediaDevices";
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
|
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||||
|
|
||||||
interface RenderOptions {
|
interface RenderOptions {
|
||||||
requestDeviceNames: () => void;
|
requestDeviceNames: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponent(
|
function withProviders(
|
||||||
component: ReactNode,
|
component: ReactNode,
|
||||||
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
||||||
): RenderResult {
|
): JSX.Element {
|
||||||
return render(
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<MediaDevicesContext
|
<MediaDevicesContext
|
||||||
value={{ requestDeviceNames } as unknown as MediaDevices}
|
value={{ requestDeviceNames } as unknown as MediaDevices}
|
||||||
>
|
>
|
||||||
{component}
|
{component}
|
||||||
</MediaDevicesContext>
|
</MediaDevicesContext>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderComponent(
|
||||||
|
component: ReactNode,
|
||||||
|
options: Partial<RenderOptions> = {},
|
||||||
|
): RenderResult {
|
||||||
|
return render(withProviders(component, options));
|
||||||
|
}
|
||||||
|
|
||||||
describe("MediaMuteAndSwitchButton", () => {
|
describe("MediaMuteAndSwitchButton", () => {
|
||||||
|
// Only one test stubs the capture; don't let it leak into the rest.
|
||||||
|
afterEach(restoreAudioCapture);
|
||||||
|
|
||||||
test("renders", () => {
|
test("renders", () => {
|
||||||
const { container } = renderComponent(
|
const { container } = renderComponent(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
|
<MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -50,11 +71,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
): RenderResult => {
|
): RenderResult => {
|
||||||
return renderComponent(
|
return renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
|
||||||
title={"Switcher"}
|
|
||||||
iconsAndLabels={type}
|
|
||||||
enabled={enabled}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const renderAudioEndabled = renderLabels("audio", true);
|
const renderAudioEndabled = renderLabels("audio", true);
|
||||||
@@ -81,7 +98,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onMute = vi.fn();
|
const onMute = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Switcher"}
|
|
||||||
onMuteClick={onMute}
|
onMuteClick={onMute}
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
@@ -98,7 +114,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onMute = vi.fn();
|
const onMute = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Switcher"}
|
|
||||||
onMuteClick={onMute}
|
onMuteClick={onMute}
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
@@ -119,7 +134,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onMute = vi.fn();
|
const onMute = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Switcher"}
|
|
||||||
onMuteClick={onMute}
|
onMuteClick={onMute}
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
@@ -139,11 +153,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requestDeviceNames = vi.fn();
|
const requestDeviceNames = vi.fn();
|
||||||
renderComponent(
|
renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
|
||||||
enabled
|
|
||||||
/>,
|
|
||||||
{ requestDeviceNames },
|
{ requestDeviceNames },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,7 +167,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
renderComponent(
|
renderComponent(
|
||||||
<>
|
<>
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled
|
enabled
|
||||||
options={[
|
options={[
|
||||||
@@ -167,7 +176,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
selectedOption="mic1"
|
selectedOption="mic1"
|
||||||
/>
|
/>
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled
|
enabled
|
||||||
options={[
|
options={[
|
||||||
@@ -193,7 +201,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
options={[
|
options={[
|
||||||
@@ -217,7 +224,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
options={[
|
options={[
|
||||||
@@ -246,7 +252,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||||
return (
|
return (
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
options={[
|
options={[
|
||||||
@@ -306,7 +311,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
const onVideoBlurToggle = vi.fn();
|
const onVideoBlurToggle = vi.fn();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
videoBlurToggleClick={onVideoBlurToggle}
|
videoBlurToggleClick={onVideoBlurToggle}
|
||||||
@@ -327,11 +331,10 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders check icon to mark the selected menu item", async () => {
|
test("marks the selected menu item as checked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title="Switcher"
|
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
options={[
|
options={[
|
||||||
@@ -342,19 +345,493 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// open menu
|
|
||||||
await user.click(getByRole("button", { name: "Microphone" }));
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
|
||||||
const mic1Item = screen.getByRole("menuitemradio", {
|
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
|
||||||
name: "Microphone 2",
|
});
|
||||||
});
|
|
||||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
|
||||||
|
|
||||||
// The unselected item (mic1) only renders its IconOptions SVG
|
test("disables every device while a selection is settling", async () => {
|
||||||
const mic2Item = screen.getByRole("menuitemradio", {
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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", {
|
||||||
name: "Microphone 1",
|
name: "Microphone 1",
|
||||||
});
|
});
|
||||||
expect(mic2Item.querySelectorAll("svg").length).toBe(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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,37 +5,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ComponentType, useState, type FC, useEffect } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type FC,
|
||||||
|
useEffect,
|
||||||
|
type ReactElement,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
MenuTitle,
|
||||||
|
RadioInput,
|
||||||
ToggleMenuItem,
|
ToggleMenuItem,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
MicOnIcon,
|
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
VideoCallIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { distinctUntilChanged, map } from "rxjs";
|
||||||
|
|
||||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||||
import { MicButton, VideoButton } from "../button";
|
import { MicButton, VideoButton } from "../button";
|
||||||
import { type DeviceLabel } from "../state/MediaDevices";
|
import {
|
||||||
|
type AudioOutputDeviceLabel,
|
||||||
|
type DeviceLabel,
|
||||||
|
} from "../state/MediaDevices";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
|
import { useRootElement } from "../RootElementContext";
|
||||||
|
import { observeElementSize$ } from "../utils/elementSize";
|
||||||
|
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||||
|
|
||||||
export interface MenuOptions {
|
export interface MenuOptions {
|
||||||
label: DeviceLabel;
|
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaMuteAndSwitchButtonProps {
|
export interface MediaMuteAndSwitchButtonProps {
|
||||||
/** The title used in the Switcher modal. */
|
|
||||||
title: string;
|
|
||||||
/** If the Mute button is enabled */
|
/** If the Mute button is enabled */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Callback if the mute button is clicked */
|
/** Callback if the mute button is clicked */
|
||||||
@@ -47,6 +58,12 @@ export interface MediaMuteAndSwitchButtonProps {
|
|||||||
options?: MenuOptions[];
|
options?: MenuOptions[];
|
||||||
/** The option that will currently be rendered as the selected option */
|
/** The option that will currently be rendered as the selected option */
|
||||||
selectedOption?: string;
|
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;
|
videoBlurToggleClick?: () => void;
|
||||||
videoBlurEnabled?: boolean;
|
videoBlurEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -58,90 +75,240 @@ export interface MediaMuteAndSwitchButtonProps {
|
|||||||
|
|
||||||
const BLUR_ID = "blur";
|
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> = ({
|
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||||
title,
|
|
||||||
enabled,
|
enabled,
|
||||||
busy,
|
busy,
|
||||||
onMuteClick,
|
onMuteClick,
|
||||||
iconsAndLabels,
|
iconsAndLabels,
|
||||||
options,
|
options,
|
||||||
selectedOption,
|
selectedOption,
|
||||||
|
outputOptions,
|
||||||
|
selectedOutputOption,
|
||||||
|
onSelectOutput,
|
||||||
videoBlurEnabled,
|
videoBlurEnabled,
|
||||||
videoBlurToggleClick,
|
videoBlurToggleClick,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
// 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 [menuOpen, setMenuOpen] = useState(false);
|
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 isBusy = busy ?? false;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const devices = useMediaDevices();
|
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(() => {
|
useEffect(() => {
|
||||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||||
}, [menuOpen, devices]);
|
}, [menuOpen, devices]);
|
||||||
|
|
||||||
let button;
|
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||||
let toggles: { label: string; enabled: boolean; id: string }[] = [];
|
const button = (
|
||||||
switch (iconsAndLabels) {
|
<MuteButton
|
||||||
case "video":
|
enabled={enabled ?? false}
|
||||||
button = (
|
busy={isBusy}
|
||||||
<VideoButton
|
onClick={(e) => {
|
||||||
enabled={enabled ?? false}
|
onMuteClick?.();
|
||||||
busy={isBusy}
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
onMuteClick?.();
|
}}
|
||||||
e.preventDefault();
|
disabled={isBusy || onMuteClick === undefined}
|
||||||
e.stopPropagation();
|
data-testid={
|
||||||
}}
|
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||||
disabled={isBusy || onMuteClick === undefined}
|
}
|
||||||
data-testid="incall_videomute"
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
if (videoBlurToggleClick !== undefined) {
|
const toggles =
|
||||||
toggles = [
|
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
label: t("action.blur_background"),
|
label: t("action.blur_background"),
|
||||||
enabled: videoBlurEnabled ?? false,
|
enabled: videoBlurEnabled ?? false,
|
||||||
id: BLUR_ID,
|
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 optionsButtonLabel: string;
|
||||||
|
let menuTitle: string;
|
||||||
let numberedLabel: (number: number) => string;
|
let numberedLabel: (number: number) => string;
|
||||||
switch (iconsAndLabels) {
|
switch (iconsAndLabels) {
|
||||||
case "video":
|
case "video":
|
||||||
IconOptions = VideoCallIcon;
|
|
||||||
optionsButtonLabel = t("settings.devices.camera");
|
optionsButtonLabel = t("settings.devices.camera");
|
||||||
|
menuTitle = t("settings.devices.camera_source");
|
||||||
numberedLabel = (n): string =>
|
numberedLabel = (n): string =>
|
||||||
t("settings.devices.camera_numbered", { n });
|
t("settings.devices.camera_numbered", { n });
|
||||||
break;
|
break;
|
||||||
case "audio":
|
case "audio":
|
||||||
IconOptions = MicOnIcon;
|
|
||||||
optionsButtonLabel = t("settings.devices.microphone");
|
optionsButtonLabel = t("settings.devices.microphone");
|
||||||
|
menuTitle = t("settings.devices.mic_source");
|
||||||
numberedLabel = (n): string =>
|
numberedLabel = (n): string =>
|
||||||
t("settings.devices.microphone_numbered", { n });
|
t("settings.devices.microphone_numbered", { n });
|
||||||
break;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
@@ -152,10 +319,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
{/* The mute button lives inside */}
|
{/* The mute button lives inside */}
|
||||||
{button}
|
{button}
|
||||||
<Menu
|
<Menu
|
||||||
title={title}
|
className={styles.menu}
|
||||||
showTitle={true}
|
// Named for screen readers only: each section has its own heading.
|
||||||
|
title={menuTitle}
|
||||||
|
showTitle={false}
|
||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onOpenChange={setMenuOpen}
|
onOpenChange={onOpenChange}
|
||||||
side="top"
|
side="top"
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -171,67 +340,74 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{options?.map(({ label, id }) => {
|
<div
|
||||||
let labelText: string;
|
ref={trackFocusSource}
|
||||||
switch (label.type) {
|
// Keeps the items the menu's own children for assistive tech.
|
||||||
case "name":
|
role="none"
|
||||||
labelText = label.name;
|
className={styles.deviceList}
|
||||||
break;
|
style={
|
||||||
case "number":
|
{
|
||||||
labelText = numberedLabel(label.number);
|
"--device-list-max-height":
|
||||||
break;
|
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
|
||||||
}
|
}
|
||||||
return (
|
>
|
||||||
<MenuItem
|
{iconsAndLabels === "audio" && speakerOptions && (
|
||||||
hideChevron
|
<>
|
||||||
label={labelText}
|
{/* A menu may only contain items, separators and groups, so each
|
||||||
Icon={
|
heading is a hidden part of a named group. */}
|
||||||
IconOptions && (
|
<div role="group" aria-label={t("settings.devices.speaker")}>
|
||||||
<IconOptions
|
<div aria-hidden className={styles.sectionHeading}>
|
||||||
width={24}
|
<MenuTitle title={t("settings.devices.speaker")} />
|
||||||
height={24}
|
</div>
|
||||||
className={styles.itemIcon}
|
{deviceItems(
|
||||||
aria-hidden
|
"output",
|
||||||
/>
|
speakerOptions,
|
||||||
)
|
selectedSpeaker,
|
||||||
}
|
onSelectOutput,
|
||||||
onSelect={(e) => {
|
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||||
e.preventDefault();
|
)}
|
||||||
if (id === selectedOption) return;
|
</div>
|
||||||
setPlannedSelection(id);
|
</>
|
||||||
onSelect?.(id);
|
)}
|
||||||
}}
|
<div role="group" aria-label={optionsButtonLabel}>
|
||||||
key={id}
|
<div ref={heading} aria-hidden className={styles.sectionHeading}>
|
||||||
role="menuitemradio"
|
<MenuTitle title={optionsButtonLabel} />
|
||||||
aria-checked={selectedOption === id}
|
</div>
|
||||||
>
|
{/* Apart from the heading, so the sticky meter can't ride over it. */}
|
||||||
{selectedOption === id && (
|
<div role="none">
|
||||||
<CheckIcon
|
{deviceItems(
|
||||||
width={24}
|
"input",
|
||||||
height={24}
|
options,
|
||||||
aria-hidden // A label would be redundant to aria-checked above
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedOption !== id && plannedSelection === id && (
|
</div>
|
||||||
<SpinnerIcon
|
</div>
|
||||||
width={24}
|
</div>
|
||||||
height={24}
|
{toggles.length > 0 && <hr />}
|
||||||
className={styles.rotate}
|
{toggles.map((toggle) => (
|
||||||
aria-label={t("settings.devices.activating")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
|
||||||
{toggles?.map((toggle) => (
|
|
||||||
<ToggleMenuItem
|
<ToggleMenuItem
|
||||||
label={toggle.label}
|
label={toggle.label}
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
videoBlurToggleClick?.();
|
videoBlurToggleClick?.();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
checked={toggle.enabled ?? false}
|
checked={toggle.enabled}
|
||||||
key={toggle.id}
|
key={toggle.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -239,3 +415,25 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
</div>
|
</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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -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.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Observable } from "rxjs";
|
|
||||||
import { type ComponentType } from "react";
|
import { type ComponentType } from "react";
|
||||||
|
|
||||||
import { type LayoutProps } from "./Grid";
|
import { type LayoutProps } from "./Grid";
|
||||||
import { type TileViewModel } from "../state/TileViewModel";
|
import { type TileViewModel } from "../state/TileViewModel";
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
export interface Bounds {
|
export interface Bounds {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -20,7 +20,7 @@ export interface CallLayoutInputs {
|
|||||||
/**
|
/**
|
||||||
* The minimum bounds of the layout area.
|
* The minimum bounds of the layout area.
|
||||||
*/
|
*/
|
||||||
minBounds$: Observable<Bounds>;
|
minBounds$: Behavior<Bounds>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallLayoutOutputs<Model> {
|
export interface CallLayoutOutputs<Model> {
|
||||||
|
|||||||
+12
-18
@@ -11,14 +11,12 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { distinctUntilChanged } from "rxjs";
|
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||||
import styles from "./GridLayout.module.css";
|
import styles from "./GridLayout.module.css";
|
||||||
import { useInitial } from "../useInitial";
|
|
||||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||||
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
|
||||||
interface GridCSSProperties extends CSSProperties {
|
interface GridCSSProperties extends CSSProperties {
|
||||||
"--gap": string;
|
"--gap": string;
|
||||||
@@ -39,22 +37,18 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
// lives
|
// lives
|
||||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const alignment = useObservableEagerState(
|
const alignment = useBehavior(model.spotlightAlignment$);
|
||||||
useInitial(() =>
|
|
||||||
model.spotlightAlignment$.pipe(
|
|
||||||
distinctUntilChanged(
|
|
||||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDragSpotlight: DragCallback = useCallback(
|
const onDragSpotlight: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) => {
|
||||||
model.spotlightAlignment$.next({
|
const block = yRatio < 0.5 ? "start" : "end";
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
const inline = xRatio < 0.5 ? "start" : "end";
|
||||||
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 });
|
||||||
|
},
|
||||||
[model.spotlightAlignment$],
|
[model.spotlightAlignment$],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,7 +72,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
|
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
const { width, height: minHeight } = useObservableEagerState(minBounds$);
|
const { width, height: minHeight } = useBehavior(minBounds$);
|
||||||
const { gap, tileWidth, tileHeight } = useMemo(
|
const { gap, tileWidth, tileHeight } = useMemo(
|
||||||
() => arrangeTiles(width, minHeight, model.grid.length),
|
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||||
[width, minHeight, model.grid.length],
|
[width, minHeight, model.grid.length],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ReactNode, useCallback, useMemo } from "react";
|
import { type ReactNode, useCallback, useMemo } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||||
@@ -37,7 +36,7 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
|||||||
Slot,
|
Slot,
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const { width, height } = useObservableEagerState(minBounds$);
|
const { width, height } = useBehavior(minBounds$);
|
||||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||||
const { tileWidth, tileHeight } = useMemo(
|
const { tileWidth, tileHeight } = useMemo(
|
||||||
() => arrangeTiles(width, height, 1),
|
() => arrangeTiles(width, height, 1),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type FC, type ReactNode } from "react";
|
import { type FC, type ReactNode } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { type CallLayout } from "./CallLayout";
|
import { type CallLayout } from "./CallLayout";
|
||||||
@@ -33,7 +32,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
Slot,
|
Slot,
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useObservableEagerState(minBounds$);
|
useBehavior(minBounds$);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
@@ -56,7 +55,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
useObservableEagerState(minBounds$);
|
useBehavior(minBounds$);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ReactNode, type CSSProperties } from "react";
|
import { type ReactNode, type CSSProperties } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||||
@@ -58,7 +57,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
|||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
const { width } = useObservableEagerState(minBounds$);
|
const { width } = useBehavior(minBounds$);
|
||||||
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||||
width,
|
width,
|
||||||
// TODO: We pretend that the minimum height is the width, because the
|
// TODO: We pretend that the minimum height is the width, because the
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { type LocalVideoTrack } from "livekit-client";
|
import { type LocalVideoTrack } from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { combineLatest, map, type Observable } from "rxjs";
|
import { combineLatest } from "rxjs";
|
||||||
import { useObservable } from "observable-hooks";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
backgroundBlur as backgroundBlurSettings,
|
backgroundBlur as backgroundBlurSettings,
|
||||||
@@ -31,6 +30,7 @@ import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
|||||||
import { type Behavior } from "../state/Behavior";
|
import { type Behavior } from "../state/Behavior";
|
||||||
import { type ObservableScope } from "../state/ObservableScope";
|
import { type ObservableScope } from "../state/ObservableScope";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
|
import { useValueBehavior } from "../useValueBehavior";
|
||||||
|
|
||||||
//TODO-MULTI-SFU: This is not yet fully there.
|
//TODO-MULTI-SFU: This is not yet fully there.
|
||||||
// it is a combination of exposing observable and react hooks.
|
// it is a combination of exposing observable and react hooks.
|
||||||
@@ -52,18 +52,8 @@ export function useTrackProcessor(): ProcessorState {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
export function useTrackProcessorState$(): Behavior<ProcessorState> {
|
||||||
const state = use(ProcessorContext);
|
return useValueBehavior(useTrackProcessor());
|
||||||
if (state === undefined)
|
|
||||||
throw new Error(
|
|
||||||
"useTrackProcessor must be used within a ProcessorProvider",
|
|
||||||
);
|
|
||||||
const state$ = useObservable(
|
|
||||||
(init$) => init$.pipe(map(([init]) => init)),
|
|
||||||
[state],
|
|
||||||
);
|
|
||||||
|
|
||||||
return state$;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-12
@@ -20,8 +20,6 @@ import {
|
|||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { map } from "rxjs";
|
|
||||||
import { useObservable } from "observable-hooks";
|
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -77,10 +75,11 @@ import {
|
|||||||
useAppBarSubtitle,
|
useAppBarSubtitle,
|
||||||
} from "../AppBar.tsx";
|
} from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
import { useValueBehavior } from "../useValueBehavior.ts";
|
||||||
import { constant } from "../state/Behavior.ts";
|
import { constant } from "../state/Behavior.ts";
|
||||||
import { Toast } from "../Toast.tsx";
|
import { Toast } from "../Toast.tsx";
|
||||||
import overlayStyles from "../Overlay.module.css";
|
import overlayStyles from "../Overlay.module.css";
|
||||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
import { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx";
|
||||||
import { type Layout } from "../state/layout-types.ts";
|
import { type Layout } from "../state/layout-types.ts";
|
||||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||||
@@ -121,7 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
const hostBridge = useHostBridge();
|
const hostBridge = useHostBridge();
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
const trackProcessorState$ = useTrackProcessorState$();
|
||||||
// The element we have to draw the call in: the page, or the container a host
|
// 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.
|
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
@@ -151,7 +150,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
},
|
},
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
scope.behavior(trackProcessorState$),
|
trackProcessorState$,
|
||||||
);
|
);
|
||||||
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||||
vm.join();
|
vm.join();
|
||||||
@@ -252,7 +251,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
useWakeLock();
|
useWakeLock();
|
||||||
// TODO-MULTI-SFU This is unused now??
|
// TODO-MULTI-SFU This is unused now??
|
||||||
// const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
// const connectionState = useBehavior(vm.livekitConnectionState$);
|
||||||
|
|
||||||
// annoyingly we don't get the disconnection reason this way,
|
// annoyingly we don't get the disconnection reason this way,
|
||||||
// only by listening for the emitted event
|
// only by listening for the emitted event
|
||||||
@@ -374,10 +373,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
edgeToEdge,
|
edgeToEdge,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const gridBoundsObservable$ = useObservable(
|
const gridBounds$ = useValueBehavior(gridBounds);
|
||||||
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
|
|
||||||
[gridBounds],
|
|
||||||
);
|
|
||||||
|
|
||||||
useAppBarHidden(!showHeader);
|
useAppBarHidden(!showHeader);
|
||||||
useAppBarSubtitle(
|
useAppBarSubtitle(
|
||||||
@@ -538,7 +534,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const layouts = useMemo(() => {
|
const layouts = useMemo(() => {
|
||||||
const inputs = { minBounds$: gridBoundsObservable$ };
|
const inputs = { minBounds$: gridBounds$ };
|
||||||
return {
|
return {
|
||||||
grid: makeGridLayout(inputs),
|
grid: makeGridLayout(inputs),
|
||||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||||
@@ -547,7 +543,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
||||||
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
||||||
};
|
};
|
||||||
}, [gridBoundsObservable$]);
|
}, [gridBounds$]);
|
||||||
|
|
||||||
const showFooter = useBehavior(footerVm.showFooter$);
|
const showFooter = useBehavior(footerVm.showFooter$);
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { of } from "rxjs";
|
||||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
@@ -18,7 +20,13 @@ import {
|
|||||||
|
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { mockMediaDevices, mockMuteStates } from "../utils/test";
|
import {
|
||||||
|
mockMediaDevices,
|
||||||
|
mockMuteStates,
|
||||||
|
restoreAudioCapture,
|
||||||
|
stubAudioCapture,
|
||||||
|
} from "../utils/test";
|
||||||
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
@@ -77,9 +85,10 @@ function renderLobbyView(
|
|||||||
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
||||||
withAppBar = false,
|
withAppBar = false,
|
||||||
platform = "android",
|
platform = "android",
|
||||||
|
devices: Partial<MediaDevices> = {},
|
||||||
): ReturnType<typeof render> {
|
): ReturnType<typeof render> {
|
||||||
platformMock.mockReturnValue(platform);
|
platformMock.mockReturnValue(platform);
|
||||||
const mediaDevices = mockMediaDevices({});
|
const mediaDevices = mockMediaDevices(devices);
|
||||||
const muteStates = mockMuteStates();
|
const muteStates = mockMuteStates();
|
||||||
const hideHeader = withAppBar ? true : false;
|
const hideHeader = withAppBar ? true : false;
|
||||||
const lobbyView = (
|
const lobbyView = (
|
||||||
@@ -178,3 +187,30 @@ describe("LobbyView", () => {
|
|||||||
expect(await axe(container)).toHaveNoViolations();
|
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 => {},
|
||||||
|
},
|
||||||
|
} as unknown as Partial<MediaDevices>);
|
||||||
|
|
||||||
|
// Pre-join reaches the meter through the same chevron as a call.
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("meter", { name: "Microphone level" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
type LocalVideoTrack,
|
type LocalVideoTrack,
|
||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
@@ -125,9 +124,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
const videoInputId = useObservableEagerState(
|
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
|
||||||
devices.videoInput.selected$,
|
|
||||||
)?.id;
|
|
||||||
|
|
||||||
// Capture the audio options as they were when we first mounted, because
|
// 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
|
// we're not doing anything with the audio anyway so we don't need to
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Separator,
|
Separator,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AudioOutputDeviceLabel,
|
type AudioOutputDeviceLabel,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
type MediaDevice,
|
type MediaDevice,
|
||||||
} from "../state/MediaDevices";
|
} from "../state/MediaDevices";
|
||||||
import styles from "./DeviceSelection.module.css";
|
import styles from "./DeviceSelection.module.css";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
|
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
|
||||||
@@ -44,8 +44,8 @@ export const DeviceSelection: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const groupId = useId();
|
const groupId = useId();
|
||||||
const available = useObservableEagerState(device.available$);
|
const available = useBehavior(device.available$);
|
||||||
const selectedId = useObservableEagerState(device.selected$)?.id;
|
const selectedId = useBehavior(device.selected$)?.id;
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
device.select(e.target.value);
|
device.select(e.target.value);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
type RTCCallIntent,
|
type RTCCallIntent,
|
||||||
type RTCNotificationType,
|
type RTCNotificationType,
|
||||||
@@ -624,7 +624,7 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
muteStates,
|
muteStates,
|
||||||
joinMatrixRTC: (
|
joinMatrixRTC: (
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||||
) => {
|
) => {
|
||||||
return enterRTCSession(
|
return enterRTCSession(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Status as RTCMemberStatus,
|
Status as RTCMemberStatus,
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
@@ -121,8 +121,7 @@ const mockedClient = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("enterRTCSession", () => {
|
describe("enterRTCSession", () => {
|
||||||
const transport: LivekitTransportConfig = {
|
const transport: LivekitTransport = {
|
||||||
livekit_alias: "roomId",
|
|
||||||
livekit_service_url: "http://my-livekit-service-url.com",
|
livekit_service_url: "http://my-livekit-service-url.com",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
};
|
};
|
||||||
@@ -139,7 +138,7 @@ describe("enterRTCSession", () => {
|
|||||||
client: mockedClient,
|
client: mockedClient,
|
||||||
},
|
},
|
||||||
memberships: [],
|
memberships: [],
|
||||||
joinRTCSession: vi.fn(),
|
join: vi.fn(),
|
||||||
}) as unknown as MatrixRTCSession;
|
}) as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
beforeEach(() =>
|
beforeEach(() =>
|
||||||
@@ -151,19 +150,18 @@ describe("enterRTCSession", () => {
|
|||||||
it("It joins the correct Session", () => {
|
it("It joins the correct Session", () => {
|
||||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||||
|
|
||||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||||
{
|
{
|
||||||
deviceId: "DEVICE",
|
deviceId: "DEVICE",
|
||||||
memberId: "@alice:example.org:DEVICE",
|
memberId: "@alice:example.org:DEVICE",
|
||||||
userId: "@alice:example.org",
|
userId: "@alice:example.org",
|
||||||
},
|
},
|
||||||
[],
|
[transport],
|
||||||
transport,
|
|
||||||
expect.objectContaining({ manageMediaKeys: true }),
|
expect.objectContaining({ manageMediaKeys: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
|
it("passes keyRotationParticipantLimit from config to join", () => {
|
||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||||
matrix_rtc_session: {
|
matrix_rtc_session: {
|
||||||
@@ -176,9 +174,8 @@ describe("enterRTCSession", () => {
|
|||||||
|
|
||||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||||
|
|
||||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
[],
|
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
keyRotationParticipantLimit: 50,
|
keyRotationParticipantLimit: 50,
|
||||||
@@ -189,8 +186,7 @@ describe("enterRTCSession", () => {
|
|||||||
it("uses the specified delayed leave timings", () => {
|
it("uses the specified delayed leave timings", () => {
|
||||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||||
|
|
||||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -210,7 +206,7 @@ describe("LocalMembership", () => {
|
|||||||
}),
|
}),
|
||||||
matrixRTCSession: {
|
matrixRTCSession: {
|
||||||
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
||||||
leaveRoomSession: vi.fn(),
|
leave: vi.fn(),
|
||||||
} as unknown as MatrixRTCSession,
|
} as unknown as MatrixRTCSession,
|
||||||
muteStates: mockMuteStates(),
|
muteStates: mockMuteStates(),
|
||||||
trackProcessorState$: constant({
|
trackProcessorState$: constant({
|
||||||
@@ -256,7 +252,7 @@ describe("LocalMembership", () => {
|
|||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
it("throws error on missing RTC config error", () => {
|
||||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||||
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
const localTransport$ = scope.behavior<null | LivekitTransport>(
|
||||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -304,7 +300,7 @@ describe("LocalMembership", () => {
|
|||||||
...defaultCreateLocalMemberValues,
|
...defaultCreateLocalMemberValues,
|
||||||
matrixRTCSession: {
|
matrixRTCSession: {
|
||||||
updateCallIntent: vi.fn().mockImplementation(reject),
|
updateCallIntent: vi.fn().mockImplementation(reject),
|
||||||
leaveRoomSession: vi.fn(),
|
leave: vi.fn(),
|
||||||
},
|
},
|
||||||
connectionManager: mockConnectionManager,
|
connectionManager: mockConnectionManager,
|
||||||
localTransport$: constant(mockTransport),
|
localTransport$: constant(mockTransport),
|
||||||
@@ -321,7 +317,7 @@ describe("LocalMembership", () => {
|
|||||||
|
|
||||||
const mockTransportConfig = {
|
const mockTransportConfig = {
|
||||||
livekit_service_url: "a",
|
livekit_service_url: "a",
|
||||||
} as LivekitTransportConfig;
|
} as LivekitTransport;
|
||||||
|
|
||||||
const mockTransport = {
|
const mockTransport = {
|
||||||
transport: mockTransportConfig,
|
transport: mockTransportConfig,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { type MatrixClient } from "matrix-js-sdk";
|
|||||||
import {
|
import {
|
||||||
Status as RTCSessionStatus,
|
Status as RTCSessionStatus,
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type LivekitTransportConfig,
|
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
type RTCCallIntent,
|
type RTCCallIntent,
|
||||||
type RTCNotificationType,
|
type RTCNotificationType,
|
||||||
@@ -144,7 +143,7 @@ interface Props {
|
|||||||
connectionManager: IConnectionManager;
|
connectionManager: IConnectionManager;
|
||||||
createPublisherFactory: (connection: Connection) => Publisher;
|
createPublisherFactory: (connection: Connection) => Publisher;
|
||||||
joinMatrixRTC: (
|
joinMatrixRTC: (
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||||
) => void;
|
) => void;
|
||||||
homeserverConnected: HomeserverConnected;
|
homeserverConnected: HomeserverConnected;
|
||||||
@@ -152,10 +151,7 @@ interface Props {
|
|||||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
localTransport$: Observable<LocalTransport>;
|
localTransport$: Observable<LocalTransport>;
|
||||||
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
|
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
|
||||||
matrixRTCSession: Pick<
|
matrixRTCSession: Pick<MatrixRTCSession, "updateCallIntent" | "leave">;
|
||||||
MatrixRTCSession,
|
|
||||||
"updateCallIntent" | "leaveRoomSession"
|
|
||||||
>;
|
|
||||||
/** Whether to hide the screen-sharing button. */
|
/** Whether to hide the screen-sharing button. */
|
||||||
hideScreensharing: boolean;
|
hideScreensharing: boolean;
|
||||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||||
@@ -683,7 +679,7 @@ export const createLocalMembership$ = ({
|
|||||||
return Promise.resolve(async (): Promise<void> => {
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
||||||
await matrixRTCSession.leaveRoomSession(1000);
|
await matrixRTCSession.leave(1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error leaving RTC session", e);
|
logger.error("Error leaving RTC session", e);
|
||||||
}
|
}
|
||||||
@@ -966,7 +962,7 @@ interface EnterRTCSessionOptions {
|
|||||||
export function enterRTCSession(
|
export function enterRTCSession(
|
||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
{
|
{
|
||||||
encryptMedia,
|
encryptMedia,
|
||||||
matrixRTCMode,
|
matrixRTCMode,
|
||||||
@@ -987,21 +983,6 @@ export function enterRTCSession(
|
|||||||
matrix_rtc_session: sessionConfig,
|
matrix_rtc_session: sessionConfig,
|
||||||
} = Config.get();
|
} = Config.get();
|
||||||
const retryInterval = sessionConfig.network_error_retry_ms;
|
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
|
// Set maximumNetworkErrorRetryCount such that we will consider the client
|
||||||
// disconnected as soon as either it fails to sync for longer than the grace
|
// disconnected as soon as either it fails to sync for longer than the grace
|
||||||
@@ -1014,24 +995,19 @@ export function enterRTCSession(
|
|||||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
// 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?
|
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||||
|
|
||||||
rtcSession.joinRTCSession(
|
rtcSession.join(ownMembershipIdentity, [transport], {
|
||||||
ownMembershipIdentity,
|
notificationType,
|
||||||
multiSFU ? [] : [backwardCompatibleTransport],
|
callIntent,
|
||||||
multiSFU ? backwardCompatibleTransport : undefined,
|
manageMediaKeys: encryptMedia,
|
||||||
{
|
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
||||||
notificationType,
|
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
||||||
callIntent,
|
delayedLeaveEventRestartLocalTimeoutMs:
|
||||||
manageMediaKeys: encryptMedia,
|
delayedLeaveTimings.restart_timeout_ms,
|
||||||
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
||||||
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
||||||
delayedLeaveEventRestartLocalTimeoutMs:
|
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
||||||
delayedLeaveTimings.restart_timeout_ms,
|
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
||||||
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||||
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
});
|
||||||
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
|
||||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
|
||||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
@@ -34,34 +34,13 @@ interface Props {
|
|||||||
matrixRTCMode: MatrixRTCMode;
|
matrixRTCMode: MatrixRTCMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO livekit_alias-cleanup
|
|
||||||
// 1. We need to move away from transports map to connections!!!
|
|
||||||
//
|
|
||||||
// 2. We need to stop sending livekit_alias all together
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// 1.
|
|
||||||
// Transports are just the jwt service adress but do not contain the information which room on this transport to use.
|
|
||||||
// That requires slot and roomId.
|
|
||||||
//
|
|
||||||
// We need one connection per room on the transport.
|
|
||||||
//
|
|
||||||
// We need an object that contains:
|
|
||||||
// transport
|
|
||||||
// roomId
|
|
||||||
// slotId
|
|
||||||
//
|
|
||||||
// To map to the connections. Prosposal: `ConnectionIdentifier`
|
|
||||||
//
|
|
||||||
// 2.
|
|
||||||
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
|
|
||||||
export interface LocalTransport {
|
export interface LocalTransport {
|
||||||
transport: LivekitTransportConfig;
|
transport: LivekitTransport;
|
||||||
sfuConfig: SFUConfig;
|
sfuConfig: SFUConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocalTransport(
|
export function isLocalTransport(
|
||||||
obj: LivekitTransportConfig | LocalTransport,
|
obj: LivekitTransport | LocalTransport,
|
||||||
): obj is LocalTransport {
|
): obj is LocalTransport {
|
||||||
return "transport" in obj && "sfuConfig" in obj;
|
return "transport" in obj && "sfuConfig" in obj;
|
||||||
}
|
}
|
||||||
@@ -90,7 +69,7 @@ export async function getLocalTransport({
|
|||||||
const customUrl = customLivekitUrl.value$.value;
|
const customUrl = customLivekitUrl.value$.value;
|
||||||
|
|
||||||
// Respect the user's custom URL, if set
|
// Respect the user's custom URL, if set
|
||||||
const transport: LivekitTransportConfig | null = customUrl
|
const transport: LivekitTransport | null = customUrl
|
||||||
? { type: "livekit", livekit_service_url: customUrl }
|
? { type: "livekit", livekit_service_url: customUrl }
|
||||||
: await discovery.discoverPreferredTransport();
|
: await discovery.discoverPreferredTransport();
|
||||||
|
|
||||||
@@ -131,7 +110,7 @@ export async function getLocalTransport({
|
|||||||
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
||||||
*/
|
*/
|
||||||
async function doOpenIdAndJWTFromUrl(
|
async function doOpenIdAndJWTFromUrl(
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
matrixRTCMode: MatrixRTCMode,
|
matrixRTCMode: MatrixRTCMode,
|
||||||
membership: CallMembershipIdentityParts,
|
membership: CallMembershipIdentityParts,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { MatrixError } from "matrix-js-sdk";
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
type Transport,
|
type Transport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
@@ -28,12 +28,12 @@ import {
|
|||||||
|
|
||||||
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
||||||
|
|
||||||
const backendTransport: LivekitTransportConfig = {
|
const backendTransport: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://backend.example.org",
|
livekit_service_url: "https://backend.example.org",
|
||||||
};
|
};
|
||||||
|
|
||||||
const configTransport: LivekitTransportConfig = {
|
const configTransport: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://config.example.org",
|
livekit_service_url: "https://config.example.org",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
isLivekitTransportConfig,
|
isLivekitTransport,
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
@@ -40,7 +40,7 @@ export class RtcTransportAutoDiscovery {
|
|||||||
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async discoverPreferredTransport(): Promise<LivekitTransportConfig | null> {
|
public async discoverPreferredTransport(): Promise<LivekitTransport | null> {
|
||||||
// 1) backend transports
|
// 1) backend transports
|
||||||
const backendTransport = await this.tryBackendTransports();
|
const backendTransport = await this.tryBackendTransports();
|
||||||
if (backendTransport) {
|
if (backendTransport) {
|
||||||
@@ -67,7 +67,7 @@ export class RtcTransportAutoDiscovery {
|
|||||||
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
|
private async tryBackendTransports(): Promise<LivekitTransport | null> {
|
||||||
const client = this.client;
|
const client = this.client;
|
||||||
// MSC4143: Attempt to fetch transports from backend.
|
// MSC4143: Attempt to fetch transports from backend.
|
||||||
this.logger.info("First try to use getRTCTransports end point ...");
|
this.logger.info("First try to use getRTCTransports end point ...");
|
||||||
@@ -75,7 +75,7 @@ export class RtcTransportAutoDiscovery {
|
|||||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||||
client._unstable_getRTCTransports(),
|
client._unstable_getRTCTransports(),
|
||||||
);
|
);
|
||||||
const first = transportList.find(isLivekitTransportConfig);
|
const first = transportList.find(isLivekitTransport);
|
||||||
if (first) {
|
if (first) {
|
||||||
return first;
|
return first;
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +90,7 @@ export class RtcTransportAutoDiscovery {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tryConfigTransport(): LivekitTransportConfig | null {
|
private tryConfigTransport(): LivekitTransport | null {
|
||||||
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
||||||
if (url) {
|
if (url) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import fetchMock from "fetch-mock";
|
|||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { type IOpenIDToken } from "matrix-js-sdk";
|
import { type IOpenIDToken } from "matrix-js-sdk";
|
||||||
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Connection,
|
Connection,
|
||||||
@@ -57,7 +57,7 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
|||||||
|
|
||||||
const ROOM_ID = "!roomID:example.org";
|
const ROOM_ID = "!roomID:example.org";
|
||||||
|
|
||||||
const livekitFocus: LivekitTransportConfig = {
|
const livekitFocus: LivekitTransport = {
|
||||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
};
|
};
|
||||||
@@ -228,9 +228,7 @@ describe("Start connection states", () => {
|
|||||||
capturedState = capturedStates.pop();
|
capturedState = capturedStates.pop();
|
||||||
if (capturedState instanceof Error) {
|
if (capturedState instanceof Error) {
|
||||||
expect(capturedState.message).toEqual("Something went wrong");
|
expect(capturedState.message).toEqual("Something went wrong");
|
||||||
expect(connection.transport.livekit_alias).toEqual(
|
expect(connection.transport).toEqual(livekitFocus);
|
||||||
livekitFocus.livekit_alias,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||||
}
|
}
|
||||||
@@ -290,9 +288,7 @@ describe("Start connection states", () => {
|
|||||||
expect(capturedState.cause.message).toContain(
|
expect(capturedState.cause.message).toContain(
|
||||||
"Failed to look up user info from homeserver",
|
"Failed to look up user info from homeserver",
|
||||||
);
|
);
|
||||||
expect(connection.transport.livekit_alias).toEqual(
|
expect(connection.transport).toEqual(livekitFocus);
|
||||||
livekitFocus.livekit_alias,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||||
}
|
}
|
||||||
@@ -357,9 +353,7 @@ describe("Start connection states", () => {
|
|||||||
expect(capturedState.cause.message).toContain(
|
expect(capturedState.cause.message).toContain(
|
||||||
"Failed to connect to livekit",
|
"Failed to connect to livekit",
|
||||||
);
|
);
|
||||||
expect(connection.transport.livekit_alias).toEqual(
|
expect(connection.transport).toEqual(livekitFocus);
|
||||||
livekitFocus.livekit_alias,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
expect.fail(
|
expect.fail(
|
||||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
type TrackPublication,
|
type TrackPublication,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
@@ -52,7 +52,7 @@ export interface ConnectionOpts {
|
|||||||
/** The identity parts to use on this connection */
|
/** The identity parts to use on this connection */
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
/** The media transport to connect to. */
|
/** The media transport to connect to. */
|
||||||
transport: LivekitTransportConfig;
|
transport: LivekitTransport;
|
||||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||||
client: OpenIDClientParts;
|
client: OpenIDClientParts;
|
||||||
/** The room ID this connection is associated with. */
|
/** The room ID this connection is associated with. */
|
||||||
@@ -107,7 +107,7 @@ export class Connection {
|
|||||||
/**
|
/**
|
||||||
* The media transport to connect to.
|
* The media transport to connect to.
|
||||||
*/
|
*/
|
||||||
public readonly transport: LivekitTransportConfig;
|
public readonly transport: LivekitTransport;
|
||||||
|
|
||||||
public readonly livekitRoom: LivekitRoom;
|
public readonly livekitRoom: LivekitRoom;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
|||||||
// imported as inline to support worker when loaded from a cdn (cross domain)
|
// imported as inline to support worker when loaded from a cdn (cross domain)
|
||||||
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { Connection } from "./Connection.ts";
|
import { Connection } from "./Connection.ts";
|
||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
export interface ConnectionFactory {
|
export interface ConnectionFactory {
|
||||||
createConnection(
|
createConnection(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
sfuConfig?: SFUConfig,
|
sfuConfig?: SFUConfig,
|
||||||
@@ -103,7 +103,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
*/
|
*/
|
||||||
public createConnection(
|
public createConnection(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
sfuConfig?: SFUConfig,
|
sfuConfig?: SFUConfig,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { BehaviorSubject, NEVER } from "rxjs";
|
import { BehaviorSubject, NEVER } from "rxjs";
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type RemoteParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
@@ -24,12 +24,12 @@ import { type Behavior } from "../../Behavior.ts";
|
|||||||
|
|
||||||
// Some test constants
|
// Some test constants
|
||||||
|
|
||||||
const TRANSPORT_1: LivekitTransportConfig = {
|
const TRANSPORT_1: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRANSPORT_2: LivekitTransportConfig = {
|
const TRANSPORT_2: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.sample.com",
|
livekit_service_url: "https://lk.sample.com",
|
||||||
};
|
};
|
||||||
@@ -47,7 +47,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipants$: new BehaviorSubject([]),
|
remoteParticipants$: new BehaviorSubject([]),
|
||||||
@@ -225,7 +225,7 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
// Used in test to control fake connections' remoteParticipants$ streams
|
// Used in test to control fake connections' remoteParticipants$ streams
|
||||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||||
|
|
||||||
function keyForTransport(transport: LivekitTransportConfig): string {
|
function keyForTransport(transport: LivekitTransport): string {
|
||||||
return `${transport.livekit_service_url}`;
|
return `${transport.livekit_service_url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
fakeRemoteParticipantsStreams = new Map();
|
fakeRemoteParticipantsStreams = new Map();
|
||||||
|
|
||||||
function getRemoteParticipantsFor(
|
function getRemoteParticipantsFor(
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
): Behavior<RemoteParticipant[]> {
|
): Behavior<RemoteParticipant[]> {
|
||||||
return (
|
return (
|
||||||
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||||
@@ -245,7 +245,7 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||||
const fakeRemoteParticipants$ = new BehaviorSubject<
|
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||||
RemoteParticipant[]
|
RemoteParticipant[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
combineLatest,
|
combineLatest,
|
||||||
map,
|
map,
|
||||||
@@ -56,7 +56,7 @@ export class ConnectionManagerData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getKey(transport: LivekitTransportConfig): string {
|
private getKey(transport: LivekitTransport): string {
|
||||||
// This is enough as a key because the ConnectionManager is already scoped by room.
|
// This is enough as a key because the ConnectionManager is already scoped by room.
|
||||||
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
|
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
|
||||||
return transport.livekit_service_url;
|
return transport.livekit_service_url;
|
||||||
@@ -67,13 +67,13 @@ export class ConnectionManagerData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionForTransport(
|
public getConnectionForTransport(
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
): Connection | null {
|
): Connection | null {
|
||||||
return this.store.get(this.getKey(transport))?.connection ?? null;
|
return this.store.get(this.getKey(transport))?.connection ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getParticipantsForTransport(
|
public getParticipantsForTransport(
|
||||||
transport: LivekitTransportConfig,
|
transport: LivekitTransport,
|
||||||
): RemoteParticipant[] {
|
): RemoteParticipant[] {
|
||||||
const key = this.getKey(transport);
|
const key = this.getKey(transport);
|
||||||
const existing = this.store.get(key);
|
const existing = this.store.get(key);
|
||||||
@@ -88,7 +88,7 @@ interface Props {
|
|||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
connectionFactory: ConnectionFactory;
|
connectionFactory: ConnectionFactory;
|
||||||
localTransport$: Observable<LocalTransport>;
|
localTransport$: Observable<LocalTransport>;
|
||||||
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||||
|
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
@@ -147,7 +147,7 @@ export function createConnectionManager$({
|
|||||||
* externally this is modified via `registerTransports()`.
|
* externally this is modified via `registerTransports()`.
|
||||||
*/
|
*/
|
||||||
const localAndRemoteTransports$: Behavior<
|
const localAndRemoteTransports$: Behavior<
|
||||||
Epoch<(LivekitTransportConfig | LocalTransport)[]>
|
Epoch<(LivekitTransport | LocalTransport)[]>
|
||||||
> = scope.behavior(
|
> = scope.behavior(
|
||||||
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
|
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
|
||||||
// Combine local and remote transports into one transport array
|
// Combine local and remote transports into one transport array
|
||||||
@@ -271,7 +271,7 @@ export function createConnectionManager$({
|
|||||||
return { connectionManagerData$ };
|
return { connectionManagerData$ };
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDuplicateTransports<T extends LivekitTransportConfig>(
|
function removeDuplicateTransports<T extends LivekitTransport>(
|
||||||
transports: T[],
|
transports: T[],
|
||||||
): T[] {
|
): T[] {
|
||||||
return transports.reduce((acc, transport) => {
|
return transports.reduce((acc, transport) => {
|
||||||
|
|||||||
@@ -42,13 +42,11 @@ const fallbackMemberId = (userId: string, deviceId: string): string =>
|
|||||||
const transportA: LivekitTransport = {
|
const transportA: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_alias: "!alias:example.org",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const transportB: LivekitTransport = {
|
const transportB: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.sample.com",
|
livekit_service_url: "https://lk.sample.com",
|
||||||
livekit_alias: "!alias:sample.com",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", {
|
const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", {
|
||||||
@@ -126,7 +124,7 @@ function createEpochedMemberships$(m$: Observable<CallMembership[]>): {
|
|||||||
const membershipsWithTransport$ = memberships$.pipe(
|
const membershipsWithTransport$ = memberships$.pipe(
|
||||||
mapEpoch((members) => {
|
mapEpoch((members) => {
|
||||||
return members.map((m) => {
|
return members.map((m) => {
|
||||||
const tr = m.getTransport(m);
|
const tr = m.getTransport();
|
||||||
return {
|
return {
|
||||||
membership: m,
|
membership: m,
|
||||||
transport:
|
transport:
|
||||||
@@ -152,7 +150,7 @@ test("should signal participant on a connection that is publishing", async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const connection = {
|
const connection = {
|
||||||
transport: bobMembership.getTransport(bobMembership),
|
transport: bobMembership.getTransport(),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const dataWithPublisher = new ConnectionManagerData();
|
const dataWithPublisher = new ConnectionManagerData();
|
||||||
dataWithPublisher.add(connection, [
|
dataWithPublisher.add(connection, [
|
||||||
@@ -195,7 +193,7 @@ test("should signal participant on a connection that is not publishing", async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const connection = {
|
const connection = {
|
||||||
transport: bobMembership.getTransport(bobMembership),
|
transport: bobMembership.getTransport(),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
const dataWithPublisher = new ConnectionManagerData();
|
const dataWithPublisher = new ConnectionManagerData();
|
||||||
dataWithPublisher.add(connection, []);
|
dataWithPublisher.add(connection, []);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { combineLatest, filter, map } from "rxjs";
|
import { combineLatest, filter, map } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
@@ -60,7 +60,7 @@ export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
|||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
membershipsWithTransport$: Behavior<
|
membershipsWithTransport$: Behavior<
|
||||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||||
>;
|
>;
|
||||||
connectionManager: IConnectionManager;
|
connectionManager: IConnectionManager;
|
||||||
localUser: { deviceId: string; userId: string };
|
localUser: { deviceId: string; userId: string };
|
||||||
@@ -165,7 +165,7 @@ export function createRemoteMatrixLivekitMembers$({
|
|||||||
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
||||||
|
|
||||||
// TODO add this to the JS-SDK
|
// TODO add this to the JS-SDK
|
||||||
export function areLivekitTransportsEqual<T extends LivekitTransportConfig>(
|
export function areLivekitTransportsEqual<T extends LivekitTransport>(
|
||||||
t1: T | null,
|
t1: T | null,
|
||||||
t2: T | null,
|
t2: T | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { BehaviorSubject, NEVER } from "rxjs";
|
|||||||
import { type Room as LivekitRoom } from "livekit-client";
|
import { type Room as LivekitRoom } from "livekit-client";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -149,7 +149,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
a: expect.toSatisfy((co) =>
|
a: expect.toSatisfy((co) =>
|
||||||
areLivekitTransportsEqual(
|
areLivekitTransportsEqual(
|
||||||
co.transport,
|
co.transport,
|
||||||
bobMembership.transports[0]! as LivekitTransportConfig,
|
bobMembership.transports[0]! as LivekitTransport,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -186,7 +186,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expect(
|
expect(
|
||||||
areLivekitTransportsEqual(
|
areLivekitTransportsEqual(
|
||||||
connection.transport,
|
connection.transport,
|
||||||
carlMembership.transports[0]! as LivekitTransportConfig,
|
carlMembership.transports[0]! as LivekitTransport,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
return true;
|
return true;
|
||||||
@@ -216,7 +216,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expect(
|
expect(
|
||||||
areLivekitTransportsEqual(
|
areLivekitTransportsEqual(
|
||||||
connection.transport,
|
connection.transport,
|
||||||
daveMembership.transports[0]! as LivekitTransportConfig,
|
daveMembership.transports[0]! as LivekitTransport,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { of } from "rxjs";
|
|
||||||
|
|
||||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
vi.mock("../Platform", () => ({
|
vi.mock("../Platform", () => ({
|
||||||
@@ -15,9 +14,22 @@ vi.mock("../Platform", () => ({
|
|||||||
},
|
},
|
||||||
isFirefox: (): boolean => false,
|
isFirefox: (): boolean => false,
|
||||||
}));
|
}));
|
||||||
vi.mock("@livekit/components-core", () => ({
|
const observers = vi.hoisted(
|
||||||
createMediaDeviceObserver: () => of([]),
|
() => new Map<string, { next: (devices: unknown[]) => void }>(),
|
||||||
}));
|
);
|
||||||
|
vi.mock("@livekit/components-core", async () => {
|
||||||
|
const { BehaviorSubject: Subject } = await import("rxjs");
|
||||||
|
return {
|
||||||
|
createMediaDeviceObserver: (kind: string) => {
|
||||||
|
let observer = observers.get(kind);
|
||||||
|
if (observer === undefined) {
|
||||||
|
observer = new Subject<unknown[]>([]);
|
||||||
|
observers.set(kind, observer);
|
||||||
|
}
|
||||||
|
return observer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||||
@@ -57,3 +69,125 @@ describe("MediaDevices audio output", () => {
|
|||||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function device(deviceId: string, label: string, groupId = deviceId): object {
|
||||||
|
return { deviceId, label, groupId, kind: "audioinput" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the devices of one kind, as the browser reports them. */
|
||||||
|
function setDevices(kind: string, devices: object[]): void {
|
||||||
|
const observer = observers.get(kind);
|
||||||
|
if (observer === undefined) throw new Error(`nothing observing ${kind}`);
|
||||||
|
observer.next(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newMediaDevices(): MediaDevices {
|
||||||
|
return new MediaDevices(new ObservableScope(), {
|
||||||
|
controlledAudioDevices: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaDevices selection", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
for (const kind of observers.keys()) setDevices(kind, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists the selected device across sessions", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Microphone 2"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
|
||||||
|
// A later call reads the same stored preference.
|
||||||
|
expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates the available devices when hardware changes", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
|
||||||
|
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual([
|
||||||
|
"mic1",
|
||||||
|
"mic2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the default device when the selected device disappears", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back when the remembered device is absent", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
devices.audioInput.select("a-device-from-last-time");
|
||||||
|
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to numbered labels when labels are unavailable", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
// Names are withheld until permission is granted.
|
||||||
|
setDevices("audioinput", [device("mic1", ""), device("mic2", "")]);
|
||||||
|
|
||||||
|
expect([...devices.audioInput.available$.value.values()]).toEqual([
|
||||||
|
{ type: "number", number: 1 },
|
||||||
|
{ type: "number", number: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists Default as a distinct entry", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audiooutput", [device("spk1", "Speakers")]);
|
||||||
|
|
||||||
|
const available = devices.audioOutput.available$.value;
|
||||||
|
// Default is its own entry, unnamed: which device it resolves to isn't knowable.
|
||||||
|
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
|
||||||
|
expect(available.get("")).toEqual({ type: "default", name: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting one device kind leaves the others unchanged", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
setDevices("audiooutput", [
|
||||||
|
device("spk1", "Speakers"),
|
||||||
|
device("spk2", "Headset"),
|
||||||
|
]);
|
||||||
|
setDevices("videoinput", [device("cam1", "Camera 1")]);
|
||||||
|
|
||||||
|
devices.audioOutput.select("spk2");
|
||||||
|
const audioInputBefore = devices.audioInput.selected$.value?.id;
|
||||||
|
const videoInputBefore = devices.videoInput.selected$.value?.id;
|
||||||
|
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
|
||||||
|
expect(devices.audioOutput.selected$.value?.id).toBe("spk2");
|
||||||
|
expect(devices.videoInput.selected$.value?.id).toBe(videoInputBefore);
|
||||||
|
expect(audioInputBefore).not.toBe("mic2");
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
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, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ATTACK_MS,
|
||||||
|
LEVEL_SCALE,
|
||||||
|
observeMicrophoneState$,
|
||||||
|
RELEASE_MS,
|
||||||
|
segmentsForVolume,
|
||||||
|
smoothVolume,
|
||||||
|
} from "./MicrophoneLevel";
|
||||||
|
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||||
|
|
||||||
|
describe("segmentsForVolume", () => {
|
||||||
|
test("shows nothing for silence", () => {
|
||||||
|
expect(segmentsForVolume(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows nothing for the hiss of a quiet room", () => {
|
||||||
|
expect(segmentsForVolume(0.005)).toBe(0);
|
||||||
|
expect(segmentsForVolume(0.015)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinguishes quiet, normal and loud speech", () => {
|
||||||
|
const quiet = segmentsForVolume(0.06);
|
||||||
|
const normal = segmentsForVolume(0.2);
|
||||||
|
const loud = segmentsForVolume(0.8);
|
||||||
|
|
||||||
|
expect(quiet).toBeGreaterThan(0);
|
||||||
|
expect(normal).toBeGreaterThan(quiet);
|
||||||
|
expect(loud).toBeGreaterThan(normal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves the meter visibly for normal speech", () => {
|
||||||
|
// Ordinary speech reaches the middle of the meter.
|
||||||
|
expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(LEVEL_SCALE / 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never exceeds the meter", () => {
|
||||||
|
expect(segmentsForVolume(1)).toBe(LEVEL_SCALE);
|
||||||
|
expect(segmentsForVolume(4)).toBe(LEVEL_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a missing reading as silence", () => {
|
||||||
|
expect(segmentsForVolume(NaN)).toBe(0);
|
||||||
|
expect(segmentsForVolume(-1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smoothVolume", () => {
|
||||||
|
test("rises faster than it falls", () => {
|
||||||
|
const rise = smoothVolume(0, 1, 50);
|
||||||
|
const fall = 1 - smoothVolume(1, 0, 50);
|
||||||
|
|
||||||
|
expect(rise).toBeGreaterThan(fall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers a syllable as it starts", () => {
|
||||||
|
// Most of the way within one attack time constant.
|
||||||
|
expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rides over the gaps between words", () => {
|
||||||
|
// A short pause doesn't collapse the meter...
|
||||||
|
expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7);
|
||||||
|
// ...but a real silence brings it down.
|
||||||
|
expect(smoothVolume(1, 0, RELEASE_MS * 3)).toBeLessThan(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("behaves the same whatever the frame rate", () => {
|
||||||
|
const oneStep = smoothVolume(0, 1, 32);
|
||||||
|
let twoSteps = smoothVolume(0, 1, 16);
|
||||||
|
twoSteps = smoothVolume(twoSteps, 1, 16);
|
||||||
|
|
||||||
|
expect(twoSteps).toBeCloseTo(oneStep, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("holds still when no time has passed", () => {
|
||||||
|
expect(smoothVolume(0.5, 1, 0)).toBe(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("observeMicrophoneState$", () => {
|
||||||
|
afterEach(restoreAudioCapture);
|
||||||
|
|
||||||
|
test("releases a capture the browser grants after nobody is watching", async () => {
|
||||||
|
const capture = stubAudioCapture();
|
||||||
|
|
||||||
|
const subscription = observeMicrophoneState$("mic1").subscribe();
|
||||||
|
// The menu closes before the browser hands the microphone over.
|
||||||
|
subscription.unsubscribe();
|
||||||
|
capture.grant();
|
||||||
|
await vi.waitFor(() => expect(capture.track.stop).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("releases the capture and the audio context when the subscription ends", async () => {
|
||||||
|
const capture = stubAudioCapture();
|
||||||
|
|
||||||
|
const subscription = observeMicrophoneState$("mic1").subscribe();
|
||||||
|
capture.grant();
|
||||||
|
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
expect(capture.track.stop).toHaveBeenCalled();
|
||||||
|
expect(capture.contexts[0].close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives the microphone back when the audio graph fails to build", async () => {
|
||||||
|
const capture = stubAudioCapture();
|
||||||
|
// Fails only after getUserMedia has granted the device.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioContext",
|
||||||
|
class {
|
||||||
|
public constructor() {
|
||||||
|
throw new Error("no audio context for you");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
const subscription = observeMicrophoneState$("mic1").subscribe((state) =>
|
||||||
|
seen.push(state.type),
|
||||||
|
);
|
||||||
|
capture.grant();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(seen).toContain("no-device"));
|
||||||
|
expect(capture.track.stop).toHaveBeenCalled();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tells denied permission and a missing device apart", async () => {
|
||||||
|
for (const [name, expected] of [
|
||||||
|
["NotAllowedError", "permission-denied"],
|
||||||
|
["NotFoundError", "no-device"],
|
||||||
|
] as const) {
|
||||||
|
const capture = stubAudioCapture();
|
||||||
|
capture.getUserMedia.mockRejectedValue(named(new Error(name), name));
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
const subscription = observeMicrophoneState$("mic1").subscribe((state) =>
|
||||||
|
seen.push(state.type),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(seen).toContain(expected));
|
||||||
|
subscription.unsubscribe();
|
||||||
|
restoreAudioCapture();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says nothing on a frame that did not change the level", async () => {
|
||||||
|
const capture = stubAudioCapture();
|
||||||
|
|
||||||
|
const states: string[] = [];
|
||||||
|
let levels = 0;
|
||||||
|
const subscription = observeMicrophoneState$("mic1").subscribe((state) => {
|
||||||
|
states.push(state.type);
|
||||||
|
if (state.type === "level") state.level$.subscribe(() => levels++);
|
||||||
|
});
|
||||||
|
capture.grant();
|
||||||
|
await vi.waitFor(() => expect(levels).toBe(1));
|
||||||
|
|
||||||
|
// Read every frame, but a steady signal emits once, and the state itself
|
||||||
|
// arrives only once.
|
||||||
|
capture.drawFrames(20);
|
||||||
|
expect(levels).toBe(1);
|
||||||
|
expect(states).toEqual(["level"]);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An error carrying the browser's `name`. */
|
||||||
|
function named(error: Error, name: string): Error {
|
||||||
|
error.name = name;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
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 { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
|
/** What the microphone picks up, or why it can't be read. */
|
||||||
|
export type MicrophoneState =
|
||||||
|
// A Behavior, so a changing level can be drawn without re-rendering.
|
||||||
|
| { type: "level"; level$: Behavior<number> }
|
||||||
|
| { type: "permission-denied" }
|
||||||
|
| { type: "no-device" };
|
||||||
|
|
||||||
|
/** Scale a level is reported on. Fixed rather than the bar count, so a level reads the same at any width. */
|
||||||
|
export const LEVEL_SCALE = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level of a microphone, captured while subscribed. Its own capture rather
|
||||||
|
* than the call's track, because pre-join freezes that track to the device
|
||||||
|
* selected at mount.
|
||||||
|
*/
|
||||||
|
export function observeMicrophoneState$(
|
||||||
|
deviceId: string | undefined,
|
||||||
|
): Observable<MicrophoneState> {
|
||||||
|
return new Observable<MicrophoneState>((subscriber) => {
|
||||||
|
let stream: MediaStream | undefined;
|
||||||
|
let context: AudioContext | undefined;
|
||||||
|
let frame: number | undefined;
|
||||||
|
let level: BehaviorSubject<number> | undefined;
|
||||||
|
|
||||||
|
// Idempotent: teardown and start can both call it.
|
||||||
|
const release = (): void => {
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
void context?.close();
|
||||||
|
frame = undefined;
|
||||||
|
stream = undefined;
|
||||||
|
context = undefined;
|
||||||
|
level?.complete();
|
||||||
|
level = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async (): Promise<void> => {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio:
|
||||||
|
deviceId === undefined ? true : { deviceId: { exact: deviceId } },
|
||||||
|
});
|
||||||
|
// Unsubscribed while the permission prompt was open.
|
||||||
|
if (subscriber.closed) return release();
|
||||||
|
|
||||||
|
context = new AudioContext();
|
||||||
|
// Starts suspended outside a user gesture, which would read as silence.
|
||||||
|
if (context.state === "suspended") await context.resume();
|
||||||
|
if (subscriber.closed) return release();
|
||||||
|
|
||||||
|
const analyser = context.createAnalyser();
|
||||||
|
analyser.fftSize = 1024;
|
||||||
|
context.createMediaStreamSource(stream).connect(analyser);
|
||||||
|
const samples = new Uint8Array(analyser.fftSize);
|
||||||
|
let displayed = 0;
|
||||||
|
let previousFrame = performance.now();
|
||||||
|
const current = new BehaviorSubject(0);
|
||||||
|
level = current;
|
||||||
|
subscriber.next({ type: "level", level$: current });
|
||||||
|
|
||||||
|
const read = (): void => {
|
||||||
|
analyser.getByteTimeDomainData(samples);
|
||||||
|
// RMS: perceived loudness rather than the peak.
|
||||||
|
let sum = 0;
|
||||||
|
for (const sample of samples) {
|
||||||
|
const centred = (sample - 128) / 128;
|
||||||
|
sum += centred * centred;
|
||||||
|
}
|
||||||
|
const now = performance.now();
|
||||||
|
displayed = smoothVolume(
|
||||||
|
displayed,
|
||||||
|
Math.sqrt(sum / samples.length),
|
||||||
|
now - previousFrame,
|
||||||
|
);
|
||||||
|
previousFrame = now;
|
||||||
|
// Frames that don't move the quantised level say nothing.
|
||||||
|
const next = segmentsForVolume(displayed);
|
||||||
|
if (next !== current.value) current.next(next);
|
||||||
|
frame = requestAnimationFrame(read);
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
};
|
||||||
|
|
||||||
|
start().catch((e: unknown) => {
|
||||||
|
// Building the graph can fail after the device was granted.
|
||||||
|
release();
|
||||||
|
subscriber.next(stateForFailure(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
return release;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a failure to open the microphone means for the person using it. */
|
||||||
|
function stateForFailure(e: unknown): MicrophoneState {
|
||||||
|
const name = e instanceof Error ? e.name : "";
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError")
|
||||||
|
return { type: "permission-denied" };
|
||||||
|
if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||||
|
return { type: "no-device" };
|
||||||
|
logger.error("Could not read the microphone level", e);
|
||||||
|
return { type: "no-device" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Below this counts as silence, so a quiet room's hiss doesn't light the first bars. */
|
||||||
|
const NOISE_FLOOR = 0.02;
|
||||||
|
|
||||||
|
/** Quantises a 0..1 volume onto {@link LEVEL_SCALE}. */
|
||||||
|
export function segmentsForVolume(volume: number): number {
|
||||||
|
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
|
||||||
|
// Square root, so ordinary speech reaches the middle of the scale.
|
||||||
|
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
|
||||||
|
return Math.min(LEVEL_SCALE, Math.ceil(Math.sqrt(aboveFloor) * LEVEL_SCALE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rise time constant: short, so a syllable registers as it starts. */
|
||||||
|
export const ATTACK_MS = 50;
|
||||||
|
|
||||||
|
/** Fall time constant: longer, so the gaps between words don't flicker. */
|
||||||
|
export const RELEASE_MS = 120;
|
||||||
|
|
||||||
|
/** Eases towards a reading, by elapsed time so the frame rate doesn't matter. */
|
||||||
|
export function smoothVolume(
|
||||||
|
displayed: number,
|
||||||
|
reading: number,
|
||||||
|
elapsedMs: number,
|
||||||
|
): number {
|
||||||
|
if (elapsedMs <= 0) return displayed;
|
||||||
|
const timeConstant = reading > displayed ? ATTACK_MS : RELEASE_MS;
|
||||||
|
const towards = 1 - Math.exp(-elapsedMs / timeConstant);
|
||||||
|
return displayed + (reading - displayed) * towards;
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
type LivekitTransportConfig,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
isLivekitTransportConfig,
|
isLivekitTransport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { fromEvent } from "rxjs";
|
import { fromEvent } from "rxjs";
|
||||||
|
|
||||||
@@ -31,35 +31,32 @@ export const membershipsAndTransports$ = (
|
|||||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||||
): {
|
): {
|
||||||
membershipsWithTransport$: Behavior<
|
membershipsWithTransport$: Behavior<
|
||||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||||
>;
|
>;
|
||||||
transports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
transports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||||
} => {
|
} => {
|
||||||
const membershipsWithTransport$: Behavior<
|
const membershipsWithTransport$: Behavior<
|
||||||
Epoch<
|
Epoch<
|
||||||
{
|
{
|
||||||
membership: CallMembership;
|
membership: CallMembership;
|
||||||
transport: LivekitTransportConfig | undefined;
|
transport: LivekitTransport | undefined;
|
||||||
}[]
|
}[]
|
||||||
>
|
>
|
||||||
> = scope.behavior(
|
> = scope.behavior(
|
||||||
memberships$.pipe(
|
memberships$.pipe(
|
||||||
mapEpoch((memberships) => {
|
mapEpoch((memberships) => {
|
||||||
return memberships.map((membership) => {
|
return memberships.map((membership) => {
|
||||||
const oldestMembership = memberships[0] ?? membership;
|
const transport = membership.getTransport();
|
||||||
const transport = membership.getTransport(oldestMembership);
|
|
||||||
return {
|
return {
|
||||||
membership,
|
membership,
|
||||||
transport: isLivekitTransportConfig(transport)
|
transport: isLivekitTransport(transport) ? transport : undefined,
|
||||||
? transport
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const transports$: Behavior<Epoch<LivekitTransportConfig[]>> = scope.behavior(
|
const transports$: Behavior<Epoch<LivekitTransport[]>> = scope.behavior(
|
||||||
membershipsWithTransport$.pipe(
|
membershipsWithTransport$.pipe(
|
||||||
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
mockRemoteScreenShare,
|
mockRemoteScreenShare,
|
||||||
} from "../../utils/test";
|
} from "../../utils/test";
|
||||||
import { constant } from "../Behavior";
|
import { constant } from "../Behavior";
|
||||||
|
import { showConnectionStats } from "../../settings/settings";
|
||||||
|
|
||||||
global.MediaStreamTrack = class {} as unknown as {
|
global.MediaStreamTrack = class {} as unknown as {
|
||||||
new (): MediaStreamTrack;
|
new (): MediaStreamTrack;
|
||||||
@@ -42,6 +43,12 @@ vi.mock("../../Platform", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const observeRtpStreamStatsMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("./observeRtpStreamStats", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
observeRtpStreamStats$: observeRtpStreamStatsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||||
|
|
||||||
test("control a participant's volume", () => {
|
test("control a participant's volume", () => {
|
||||||
@@ -289,3 +296,37 @@ test("remote media is not in waiting state when user does not intend to publish
|
|||||||
);
|
);
|
||||||
expect(vm.waitingForMedia$.value).toBe(false);
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("user media polls stream stats only while the setting is on", () => {
|
||||||
|
const participant = mockRemoteParticipant({});
|
||||||
|
const vm = mockRemoteMedia(rtcMembership, {}, participant);
|
||||||
|
onTestFinished(() => showConnectionStats.setValue(false));
|
||||||
|
withTestScheduler(({ cold, expectObservable, schedule }) => {
|
||||||
|
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||||
|
observeRtpStreamStatsMock.mockImplementation(() =>
|
||||||
|
cold("-s", { s: stats }),
|
||||||
|
);
|
||||||
|
schedule("-a-b", {
|
||||||
|
a() {
|
||||||
|
showConnectionStats.setValue(true);
|
||||||
|
},
|
||||||
|
b() {
|
||||||
|
showConnectionStats.setValue(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectObservable(vm.audioStreamStats$).toBe("u-su", {
|
||||||
|
u: undefined,
|
||||||
|
s: stats,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(observeRtpStreamStatsMock).toHaveBeenCalledWith(
|
||||||
|
participant,
|
||||||
|
Track.Source.Microphone,
|
||||||
|
"inbound-rtp",
|
||||||
|
);
|
||||||
|
expect(observeRtpStreamStatsMock).toHaveBeenCalledWith(
|
||||||
|
participant,
|
||||||
|
Track.Source.Camera,
|
||||||
|
"inbound-rtp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
map,
|
map,
|
||||||
type Observable,
|
|
||||||
of,
|
of,
|
||||||
Subject,
|
Subject,
|
||||||
switchMap,
|
switchMap,
|
||||||
@@ -54,10 +53,10 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
|||||||
rtcBackendIdentity: string;
|
rtcBackendIdentity: string;
|
||||||
handRaised$: Behavior<Date | null>;
|
handRaised$: Behavior<Date | null>;
|
||||||
reaction$: Behavior<ReactionOption | null>;
|
reaction$: Behavior<ReactionOption | null>;
|
||||||
audioStreamStats$: Observable<
|
audioStreamStats$: Behavior<
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
>;
|
>;
|
||||||
videoStreamStats$: Observable<
|
videoStreamStats$: Behavior<
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +93,21 @@ export function createBaseUserMedia(
|
|||||||
);
|
);
|
||||||
const toggleCropVideo$ = new Subject<void>();
|
const toggleCropVideo$ = new Subject<void>();
|
||||||
const videoAspectRatio$ = new BehaviorSubject(NaN);
|
const videoAspectRatio$ = new BehaviorSubject(NaN);
|
||||||
|
const streamStats$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
source: Track.Source,
|
||||||
|
): Behavior<
|
||||||
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
|
> =>
|
||||||
|
scope.behavior(
|
||||||
|
combineLatest([participant$, showConnectionStats.value$]).pipe(
|
||||||
|
switchMap(([p, showConnectionStats]) =>
|
||||||
|
p && showConnectionStats
|
||||||
|
? observeRtpStreamStats$(p, source, statsType)
|
||||||
|
: of(undefined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...createMemberMedia(scope, {
|
...createMemberMedia(scope, {
|
||||||
@@ -130,25 +144,8 @@ export function createBaseUserMedia(
|
|||||||
rtcBackendIdentity,
|
rtcBackendIdentity,
|
||||||
handRaised$,
|
handRaised$,
|
||||||
reaction$,
|
reaction$,
|
||||||
audioStreamStats$: combineLatest([
|
audioStreamStats$: streamStats$(scope, Track.Source.Microphone),
|
||||||
participant$,
|
videoStreamStats$: streamStats$(scope, Track.Source.Camera),
|
||||||
showConnectionStats.value$,
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([p, showConnectionStats]) => {
|
|
||||||
//
|
|
||||||
if (!p || !showConnectionStats) return of(undefined);
|
|
||||||
return observeRtpStreamStats$(p, Track.Source.Microphone, statsType);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
videoStreamStats$: combineLatest([
|
|
||||||
participant$,
|
|
||||||
showConnectionStats.value$,
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([p, showConnectionStats]) => {
|
|
||||||
if (!p || !showConnectionStats) return of(undefined);
|
|
||||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import styles from "./GridTile.module.css";
|
import styles from "./GridTile.module.css";
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
@@ -133,12 +132,8 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const video = useBehavior(vm.video$);
|
const video = useBehavior(vm.video$);
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
const audioStreamStats = useObservableEagerState<
|
const audioStreamStats = useBehavior(vm.audioStreamStats$);
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
const videoStreamStats = useBehavior(vm.videoStreamStats$);
|
||||||
>(vm.audioStreamStats$);
|
|
||||||
const videoStreamStats = useObservableEagerState<
|
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
|
||||||
>(vm.videoStreamStats$);
|
|
||||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const speaking = useBehavior(vm.speaking$);
|
const speaking = useBehavior(vm.speaking$);
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ import {
|
|||||||
VolumeOnSolidIcon,
|
VolumeOnSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type Observable, map } from "rxjs";
|
import { BehaviorSubject, type Observable, map } from "rxjs";
|
||||||
import { useObservableRef } from "observable-hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
@@ -416,7 +415,13 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
const root$ = useInitial(
|
||||||
|
() => new BehaviorSubject<HTMLDivElement | null>(null),
|
||||||
|
);
|
||||||
|
const ourRef = useCallback(
|
||||||
|
(root: HTMLDivElement | null) => root$.next(root),
|
||||||
|
[root$],
|
||||||
|
);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const maximised = useBehavior(vm.maximised$);
|
const maximised = useBehavior(vm.maximised$);
|
||||||
const background = useBehavior(vm.background$);
|
const background = useBehavior(vm.background$);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
soundEffectVolume as soundEffectVolumeSetting,
|
soundEffectVolume as soundEffectVolumeSetting,
|
||||||
@@ -17,6 +16,7 @@ import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext";
|
|||||||
import { type PrefetchedSounds } from "./soundUtils";
|
import { type PrefetchedSounds } from "./soundUtils";
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import * as controls from "./controls";
|
import * as controls from "./controls";
|
||||||
|
import { useBehavior } from "./useBehavior";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play a sound though a given AudioContext. Will take
|
* Play a sound though a given AudioContext. Will take
|
||||||
@@ -168,7 +168,7 @@ export function useAudioContext<S extends string>(
|
|||||||
};
|
};
|
||||||
}, [props.sounds, props.latencyHint]);
|
}, [props.sounds, props.latencyHint]);
|
||||||
|
|
||||||
const audioOutputId = useObservableEagerState(
|
const audioOutputId = useBehavior(
|
||||||
useMediaDevices().audioOutput.selected$,
|
useMediaDevices().audioOutput.selected$,
|
||||||
)?.id;
|
)?.id;
|
||||||
const { controlledAudioDevices } = useUrlParams();
|
const { controlledAudioDevices } = useUrlParams();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { useBehavior } from "./useBehavior";
|
||||||
|
|
||||||
|
describe("useBehavior", () => {
|
||||||
|
test("reads the current value and follows changes", () => {
|
||||||
|
const value$ = new BehaviorSubject(1);
|
||||||
|
const { result } = renderHook(() => useBehavior(value$));
|
||||||
|
expect(result.current).toBe(1);
|
||||||
|
|
||||||
|
act(() => value$.next(2));
|
||||||
|
expect(result.current).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { useValueBehavior } from "./useValueBehavior";
|
||||||
|
|
||||||
|
describe("useValueBehavior", () => {
|
||||||
|
test("holds the value from the first render", () => {
|
||||||
|
const { result } = renderHook(({ value }) => useValueBehavior(value), {
|
||||||
|
initialProps: { value: "a" },
|
||||||
|
});
|
||||||
|
expect(result.current.value).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps one identity and takes each new value", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useValueBehavior(value),
|
||||||
|
{ initialProps: { value: "a" } },
|
||||||
|
);
|
||||||
|
const value$ = result.current;
|
||||||
|
const seen: string[] = [];
|
||||||
|
value$.subscribe((value) => seen.push(value));
|
||||||
|
|
||||||
|
rerender({ value: "b" });
|
||||||
|
expect(result.current).toBe(value$);
|
||||||
|
expect(seen).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
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 } from "react";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "./state/Behavior";
|
||||||
|
import { useInitial } from "./useInitial";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook which mirrors a value from the render cycle into a behavior, so
|
||||||
|
* that a prop or memoised value can be handed to a view model. The behavior
|
||||||
|
* takes each new value once the render that produced it has committed.
|
||||||
|
*/
|
||||||
|
export function useValueBehavior<T>(value: T): Behavior<T> {
|
||||||
|
const value$ = useInitial(() => new BehaviorSubject(value));
|
||||||
|
useEffect(() => {
|
||||||
|
value$.next(value);
|
||||||
|
}, [value$, value]);
|
||||||
|
return value$;
|
||||||
|
}
|
||||||
+98
-3
@@ -8,6 +8,7 @@ import { map, type Observable, of, type SchedulerLike } from "rxjs";
|
|||||||
import { type RunHelpers, TestScheduler } from "rxjs/testing";
|
import { type RunHelpers, TestScheduler } from "rxjs/testing";
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
|
type Mock,
|
||||||
type MockedObject,
|
type MockedObject,
|
||||||
type MockInstance,
|
type MockInstance,
|
||||||
onTestFinished,
|
onTestFinished,
|
||||||
@@ -211,7 +212,6 @@ export function mockEmitter<T>(): EmitterMock<T> {
|
|||||||
export const exampleTransport: LivekitTransport = {
|
export const exampleTransport: LivekitTransport = {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
livekit_alias: "!alias:example.org",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exampleSfuConfig: SFUConfig = {
|
export const exampleSfuConfig: SFUConfig = {
|
||||||
@@ -472,7 +472,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
counters: {},
|
counters: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
public leaveRoomSession: ReturnType<typeof vitest.fn> = vitest
|
public leave: ReturnType<typeof vitest.fn> = vitest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async joinRTCSession(): Promise<void> {
|
public async join(): Promise<void> {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,3 +590,98 @@ export class MockConnection extends Connection {
|
|||||||
public async start(): Promise<void> {}
|
public async start(): Promise<void> {}
|
||||||
public async stop(): Promise<void> {}
|
public async stop(): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StubbedCapture {
|
||||||
|
getUserMedia: Mock;
|
||||||
|
/** Grants the microphone, as the browser does once permission is given. */
|
||||||
|
grant: () => void;
|
||||||
|
track: { stop: Mock };
|
||||||
|
contexts: { close: Mock }[];
|
||||||
|
/** Runs the pending animation frames, in order. */
|
||||||
|
drawFrames: (count: number) => void;
|
||||||
|
/** Sets the microphone's loudness from the next frame, 0 to 1. */
|
||||||
|
speak: (amplitude: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubs the capture and Web Audio APIs, with the grant and the animation
|
||||||
|
* frames driven by the test. Undo with {@link restoreAudioCapture}.
|
||||||
|
*/
|
||||||
|
export function stubAudioCapture(): StubbedCapture {
|
||||||
|
const track = { stop: vi.fn() };
|
||||||
|
const contexts: { close: Mock }[] = [];
|
||||||
|
let grant = (): void => {};
|
||||||
|
const granted = new Promise<MediaStream>((resolve) => {
|
||||||
|
grant = (): void =>
|
||||||
|
resolve({ getTracks: () => [track] } as unknown as MediaStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames: FrameRequestCallback[] = [];
|
||||||
|
let amplitude = 0;
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||||
|
frames.push(callback);
|
||||||
|
return frames.length;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioContext",
|
||||||
|
class {
|
||||||
|
public readonly state = "running";
|
||||||
|
public readonly close = vi.fn();
|
||||||
|
public constructor() {
|
||||||
|
contexts.push(this);
|
||||||
|
}
|
||||||
|
public createAnalyser(): object {
|
||||||
|
return {
|
||||||
|
fftSize: 1024,
|
||||||
|
getByteTimeDomainData: (samples: Uint8Array): void => {
|
||||||
|
// Silence is the midpoint of the range; a zeroed buffer reads as full scale.
|
||||||
|
if (amplitude <= 0) {
|
||||||
|
samples.fill(128);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peak = Math.round(Math.min(1, amplitude) * 127);
|
||||||
|
for (let i = 0; i < samples.length; i++)
|
||||||
|
samples[i] = 128 + (i % 2 ? peak : -peak);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public createMediaStreamSource(): object {
|
||||||
|
return { connect: (): void => {} };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Only this property: replacing navigator loses getters like userAgent.
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia: vi.fn().mockReturnValue(granted) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getUserMedia: navigator.mediaDevices.getUserMedia as unknown as Mock,
|
||||||
|
grant: () => grant(),
|
||||||
|
track,
|
||||||
|
contexts,
|
||||||
|
drawFrames: (count): void => {
|
||||||
|
for (let i = 0; i < count; i++) frames.shift()?.(i);
|
||||||
|
},
|
||||||
|
speak: (next): void => {
|
||||||
|
amplitude = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const realMediaDevices = Object.getOwnPropertyDescriptor(
|
||||||
|
navigator,
|
||||||
|
"mediaDevices",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Undoes {@link stubAudioCapture}. */
|
||||||
|
export function restoreAudioCapture(): void {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (realMediaDevices === undefined) {
|
||||||
|
Reflect.deleteProperty(navigator, "mediaDevices");
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", realMediaDevices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,8 +49,19 @@ export default defineConfig(({ mode }) => {
|
|||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
lib: {
|
lib: {
|
||||||
formats: ["es" as const],
|
formats: ["es" as const],
|
||||||
entry: "./component/index.tsx",
|
entry: {
|
||||||
fileName: "element-call",
|
// The component
|
||||||
|
"element-call": "./component/index.tsx",
|
||||||
|
// What a host needs to talk about it, without it (see component/api.ts).
|
||||||
|
// Built as an entry point of its own so that importing it does not
|
||||||
|
// load the component: the bundler gives it a chunk that reaches only
|
||||||
|
// what it imports.
|
||||||
|
api: "./component/api.ts",
|
||||||
|
},
|
||||||
|
fileName: (_format, entryName) => `${entryName}.js`,
|
||||||
|
// The one stylesheet (see cssCodeSplit) keeps the name it had when the
|
||||||
|
// component was the only entry
|
||||||
|
cssFileName: "element-call",
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// The host already has these, and a second copy of any of them does not
|
// The host already has these, and a second copy of any of them does not
|
||||||
|
|||||||
Reference in New Issue
Block a user