mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6adf067e | ||
|
|
b408610277 | ||
|
|
cde88a7c93 | ||
|
|
d5733e8274 | ||
|
|
3ad778e7e8 | ||
|
|
9a286032f2 | ||
|
|
363a8feab2 | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
9140365251 | ||
|
|
81233e5cfb | ||
|
|
e296d88969 | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
5c497cc6a8 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
d5812ddd34 | ||
|
|
cf7c275fb7 | ||
|
|
93b2661513 | ||
|
|
1f84cb9c5c | ||
|
|
4269aaa163 | ||
|
|
bc62bb9565 | ||
|
|
be56bbd05b | ||
|
|
eb44fd545d | ||
|
|
c9f911b7c9 | ||
|
|
3a189d155b | ||
|
|
0211d5c367 | ||
|
|
c88f884fc9 | ||
|
|
8ebb0b5a90 |
@@ -48,6 +48,8 @@ pnpm i18n:check
|
||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
||||
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
||||
- Comments only where the code can't give the reason — see
|
||||
[code-style.md](docs/agents/code-style.md#comments).
|
||||
- Anything that redraws continuously — an animation frame, a stream — says what it
|
||||
costs, or better, counts the redraws in a test.
|
||||
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
||||
|
||||
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
|
||||
## ✨ Key Features
|
||||
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
|
||||
organize the flow of the meeting.
|
||||
organize the flow of the meeting.
|
||||
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||
engagement and interactivity to the conversation.
|
||||
|
||||
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
|
||||
```
|
||||
|
||||
whose `prepare` script runs the build on install. That build needs pnpm (via
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository
|
||||
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
|
||||
since it inherits the `--max-old-space-size` setting of
|
||||
the app build; the host's pnpm also has to allow it to run at all
|
||||
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
|
||||
project of its own for this reason, so pnpm commands run from inside that
|
||||
@@ -306,8 +306,28 @@ directory target it rather than the repository; run them from the repository
|
||||
root. The host imports the component from
|
||||
`@element-hq/element-call-component` and the stylesheet from
|
||||
`@element-hq/element-call-component/style.css`, and has to provide `react`,
|
||||
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
|
||||
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
|
||||
external.
|
||||
Compound's stylesheet are the host's to load too: the component shares
|
||||
the host's copy of Compound, (required for example for tooltips)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -34,10 +34,18 @@ component rather than a page makes. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Compound's overlay container, which holds tooltips and popovers, has to fill
|
||||
the container for the elements inside it to be positioned against it. The
|
||||
standalone page does the same for the container under `#root`. */
|
||||
.root > [data-overlay-container] {
|
||||
position: relative;
|
||||
/* The call itself. It is a stacking context of its own, as the standalone
|
||||
page's `#root` is, so that the layers the call view uses for its header, footer
|
||||
and overlays stay below the modals and toasts, which are portalled into the
|
||||
root and carry no layer of their own.
|
||||
|
||||
What Compound floats — tooltips, menus — is not in here at all: it goes into
|
||||
the host's body, as it would for any other component on the host's page, and is
|
||||
styled and layered by the host's copy of Compound. */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* Compound speak of `html`, `body` and bare elements, and the design tokens are
|
||||
* declared on `:root`. As a component, all of that would land on the host's
|
||||
* document too. This rewrites every selector so that it matches only the root
|
||||
* or its descendants:
|
||||
* its own base styles speak of `html`, `body` and bare elements, and its custom
|
||||
* properties are declared on `:root`. As a component, all of that would land on
|
||||
* the host's document too. This rewrites every selector so that it matches only
|
||||
* the root or its descendants:
|
||||
*
|
||||
* - `html`, `body` and `:root` become the root element, which is what stands in
|
||||
* for the document inside a host.
|
||||
@@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* CSS modules are scoped by their class names already, so only their selectors
|
||||
* that would match by element alone — `pre` rather than `.pre` — are touched.
|
||||
*
|
||||
* The root's fonts and design tokens are still inherited by everything inside
|
||||
* it, the way they were from `body` and `:root`, and `@font-face` declarations
|
||||
* stay global, which they are by nature.
|
||||
* The root's custom properties are still inherited by everything inside it,
|
||||
* the way they were from `:root`. Compound's own stylesheet — the design
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,16 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// What a host provides along with its copy of Compound: its stylesheet, the
|
||||
// design tokens and the fonts they name. The component does not bundle them.
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/inconsolata/400.css";
|
||||
import "@fontsource/inconsolata/700.css";
|
||||
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
|
||||
import "@vector-im/compound-web/dist/style.css";
|
||||
import { type ConfigOptions, initializeElementCall } from "../index";
|
||||
import { Harness } from "./Harness";
|
||||
// After Element Call's, so that the host has the last word on its own page
|
||||
|
||||
+46
-126
@@ -17,33 +17,37 @@ Please see LICENSE in the repository root for full details.
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// The design tokens, fonts and element defaults every Element Call stylesheet
|
||||
// The element defaults and custom properties every Element Call stylesheet
|
||||
// builds on. Written for a page, they speak of `html`, `body` and bare
|
||||
// elements; the component build confines them, and every other stylesheet in
|
||||
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
|
||||
// that the host's document is left as it was.
|
||||
//
|
||||
// Compound is not in here. The component uses the host's copy of it (a peer
|
||||
// dependency, left external by the build), and the host loads Compound's
|
||||
// stylesheet, design tokens and fonts along with it: what Compound floats into
|
||||
// the host's body — tooltips, menus — is then styled by the same stylesheet as
|
||||
// everything else there, and Element Call's root takes the tokens and fonts of
|
||||
// the theme class it carries, as any element on the host's page would.
|
||||
//
|
||||
// Where these land relative to the component stylesheets is the bundler's
|
||||
// choice — the standalone app puts them first, this build puts them in the
|
||||
// middle — so nothing in base.css may depend on winning or losing against a
|
||||
// component's own rules at equal specificity. It currently does not: what it
|
||||
// declares unlayered is custom properties on Element Call's root, which
|
||||
// components inherit rather than compete with, and everything from Compound
|
||||
// sits in a `@layer`, which loses to unlayered rules either way.
|
||||
// components inherit rather than compete with.
|
||||
import "../src/base.css";
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -62,10 +66,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
@@ -79,102 +81,16 @@ import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
import { useComponentHostBridge } from "./host";
|
||||
import { type ElementCallProps } from "./api";
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
} from "../src/HostBridge";
|
||||
export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of
|
||||
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
|
||||
* to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||
// the host bridge, the intents and what each means — also available without
|
||||
// the component itself from `@element-hq/element-call-component/api`
|
||||
export * from "./api";
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
@@ -321,34 +237,38 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
// Stands in for the standalone page's `#root`: see `.content`
|
||||
// in ElementCall.module.css
|
||||
<div className={styles.content}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</UrlParamsProvider>
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"default": "./dist/element-call.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/types/component/api.d.ts",
|
||||
"default": "./dist/api.js"
|
||||
},
|
||||
"./style.css": "./dist/element-call.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
@@ -34,7 +38,8 @@
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.18.1",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"matrix-js-sdk": "*",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources it reaches rely on.
|
||||
"include": ["./index.tsx", "../src/@types/*.d.ts"],
|
||||
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources they reach rely on.
|
||||
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||
|
||||
@@ -76,4 +76,12 @@ Code that builds in only one is a bug.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
`matrix-js-sdk` / `@vector-im/compound-web` /
|
||||
`@vector-im/compound-design-tokens` subpath the externals list omits.
|
||||
`livekit-client` is bundled on purpose: no host imports it, and the pinned
|
||||
version travels with the component.
|
||||
Compound's stylesheets are loaded by `src/index.css` for the page and by the
|
||||
host for the component, never by `src/base.css`. 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
|
||||
rule in new ones, don't reorder old ones.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
|
||||
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
|
||||
token from the Matrix 2.0 JWT endpoint of the
|
||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
and identifies the room by a hashed identity instead of 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
|
||||
legacy one, so remote participants can be on either.)
|
||||
|
||||
|
||||
@@ -163,6 +163,12 @@
|
||||
"login_auth_links_prompt": "Not registered yet?",
|
||||
"login_subheading": "To continue to Element",
|
||||
"login_title": "Login",
|
||||
"microphone_level": {
|
||||
"label": "Microphone level",
|
||||
"no_device": "No microphone found. Connect one, then try again.",
|
||||
"permission_denied": "Microphone access is blocked. Allow it, then try again.",
|
||||
"value": "{{level}} of {{max}}"
|
||||
},
|
||||
"microphone_off": "Microphone off",
|
||||
"microphone_on": "Microphone on",
|
||||
"mute_microphone_button_label": "Mute microphone",
|
||||
@@ -215,11 +221,14 @@
|
||||
"activating": "Activating…",
|
||||
"camera": "Camera",
|
||||
"camera_numbered": "Camera {{n}}",
|
||||
"camera_source": "Camera Source",
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"default_named_plain": "Default ({{name}})",
|
||||
"handset": "Handset",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"mic_source": "Mic Source",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
"speaker": "Speaker",
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
"build:full:development": "pnpm build:full --mode development",
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
@@ -107,7 +107,6 @@
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
|
||||
@@ -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)
|
||||
'@vector-im/compound-web':
|
||||
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':
|
||||
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))
|
||||
@@ -192,7 +192,7 @@ importers:
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.19.0
|
||||
@@ -202,9 +202,6 @@ importers:
|
||||
normalize.css:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
observable-hooks:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2)
|
||||
oxfmt:
|
||||
specifier: ^0.66.0
|
||||
version: 0.66.0
|
||||
@@ -1197,8 +1194,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
|
||||
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -3246,8 +3243,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.0.1':
|
||||
resolution: {integrity: sha512-CT3aGMalPSf53o3JUbBF7EA10Rfp+auftIuBwOUSna+hfZ/zPxGLTFcuCuOJ7L8hrzijM+V4I8L0PUkfyOczpg==}
|
||||
'@vector-im/compound-web@10.1.0':
|
||||
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -3662,8 +3659,8 @@ packages:
|
||||
constants-browserify@1.0.0:
|
||||
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
||||
|
||||
content-type@3.0.0:
|
||||
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
|
||||
content-type@3.1.1:
|
||||
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
@@ -4683,9 +4680,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
|
||||
version: 42.2.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
|
||||
resolution: {gitHosted: true, integrity: sha512-qF8++74MX36KS1KEw6Lc8HHXgZ6nHD1oUU4yBZUj5vVPvZTIspk1lOGztkFmizE5vmLAHd5tdadL9Xu1Asy4bA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999}
|
||||
version: 42.3.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.19.0:
|
||||
@@ -4816,13 +4813,6 @@ packages:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
observable-hooks@4.2.4:
|
||||
resolution: {integrity: sha512-FdTQgyw1h5bG/QHCBIqctdBSnv9VARJCEilgpV6L2qlw1yeLqFIwPm4U15dMtl5kDmNN0hSt+Nl6iYbLFwEcQA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
rxjs: '>=6.0.0'
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -4905,8 +4895,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.0:
|
||||
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -7111,7 +7101,7 @@ snapshots:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
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)':
|
||||
dependencies:
|
||||
@@ -8700,7 +8690,7 @@ snapshots:
|
||||
'@types/react': 19.2.18
|
||||
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:
|
||||
'@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)
|
||||
@@ -9173,7 +9163,7 @@ snapshots:
|
||||
|
||||
constants-browserify@1.0.0: {}
|
||||
|
||||
content-type@3.0.0: {}
|
||||
content-type@3.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
@@ -10259,17 +10249,17 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@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
|
||||
bs58: 6.0.0
|
||||
content-type: 3.0.0
|
||||
content-type: 3.1.1
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.19.0
|
||||
p-retry: 8.0.0
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10410,12 +10400,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
observable-hooks@4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
rxjs: 7.8.2
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -10600,7 +10584,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ Please see LICENSE in the repository root for full details.
|
||||
* Checks that the component build leaves the packages a host must supply to
|
||||
* the host.
|
||||
*
|
||||
* A host application already has React, the Matrix SDK and LiveKit, and a
|
||||
* second copy of any of them is worse than dead weight: React would hold two
|
||||
* sets of hooks, and the Matrix client would run two sync loops. So the
|
||||
* A host application already has React, the Matrix SDK and Compound,
|
||||
* and a second copy of any of them is worse than dead weight: React would hold
|
||||
* two sets of hooks, the Matrix client would run two sync loops, and a second
|
||||
* Compound would style the tooltips it floats into the host's body with class
|
||||
* names the host's stylesheet does not know. So the
|
||||
* component build lists them as external — but that list has to name every
|
||||
* subpath, since the bundler silently ignores the pattern and callback forms
|
||||
* of the option, and an import it does not cover is bundled with no warning at
|
||||
@@ -33,13 +35,17 @@ import { loadConfigFromFile } from "vite";
|
||||
|
||||
const CONFIG = "vite-component.config.ts";
|
||||
const SOURCES = ["src", "component"];
|
||||
// The development harness is a host, not part of the component: it is the
|
||||
// one that imports what a host supplies (Compound's stylesheets, say).
|
||||
const EXCLUDED = ["component/dev"];
|
||||
|
||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
||||
const MUST_BE_EXTERNAL = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"livekit-client",
|
||||
"@vector-im/compound-web",
|
||||
"@vector-im/compound-design-tokens",
|
||||
];
|
||||
|
||||
const isTestFile = (name) =>
|
||||
@@ -49,6 +55,7 @@ const isTestFile = (name) =>
|
||||
async function* sourceFiles(dir) {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (EXCLUDED.includes(path)) continue;
|
||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||
yield path;
|
||||
|
||||
@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type MediaDevices } from "./state/MediaDevices";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
||||
undefined,
|
||||
@@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): {
|
||||
volume: number;
|
||||
} => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const audioOutput = useBehavior(devices.audioOutput.selected$);
|
||||
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
||||
return {
|
||||
// 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 { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { pickBy } from "lodash-es";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { platform } from "./Platform";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
import { redact } from "./utils/redact";
|
||||
|
||||
// Moved to their own module, so that a host can import them without the rest
|
||||
// of this one; re-exported here as they always were
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
roomId: string | null;
|
||||
viaServers: string[];
|
||||
}
|
||||
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallVoice = "start_call_voice",
|
||||
JoinExistingCallVoice = "join_existing_voice",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
@@ -166,109 +155,6 @@ export interface UrlProperties {
|
||||
*/
|
||||
background: BackgroundStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for the app, which can be set via URL parameters.
|
||||
* Those property are different to the UrlProperties, since they are all optional
|
||||
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||
* the same context but with different accounts/users.
|
||||
*
|
||||
* Their defaults can be controlled by the `intent` property.
|
||||
*/
|
||||
export interface UrlConfiguration {
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||
* hides the header entirely, and "app_bar" produces a header with a back
|
||||
* button like you might see in mobile apps. The callback for the back button
|
||||
* is window.controls.onBackButtonPressed.
|
||||
*/
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
* with the join widget action.
|
||||
*/
|
||||
skipLobby: boolean;
|
||||
/**
|
||||
* Setting this flag makes element call show the lobby after leaving a call.
|
||||
* This is useful for video rooms.
|
||||
*/
|
||||
returnToLobby: boolean;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whether the app should automatically leave the call when there
|
||||
* is no one left in the call.
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||
|
||||
class ParamParser {
|
||||
@@ -354,97 +240,6 @@ export const getUrlParams = (
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link UrlProperties} for Element Call running as a component inside a
|
||||
* host application.
|
||||
|
||||
+15
-19
@@ -5,19 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* The styles Element Call needs wherever it is shown: the design tokens, fonts
|
||||
and element defaults its own stylesheets build on top of.
|
||||
/* The styles Element Call needs wherever it is shown: the element defaults and
|
||||
custom properties its own stylesheets build on top of.
|
||||
|
||||
Split out from index.css so that Element Call as a component can have these
|
||||
without also being given the standalone page's layout, which would style the
|
||||
host's own document. What remains here still speaks of the
|
||||
document — normalize.css and the typography below use bare element selectors,
|
||||
and the custom properties are declared on `:root` — which is right for the
|
||||
page, and is why the component build rewrites it: there every selector is
|
||||
confined to Element Call's root element (see component/build/scopeStylesToRoot.ts),
|
||||
with `html`, `body` and `:root` becoming that element. Nothing needs to be
|
||||
written differently here for that to work, but nothing here may rely on
|
||||
reaching the host's document either.
|
||||
host's own document. Compound's stylesheets — the design tokens, the component
|
||||
styles and the fonts they use — are not here either: as a component, Element
|
||||
Call shares the host's copy of Compound (it is a peer dependency, so that what
|
||||
Compound floats into the host's body, tooltips and menus, is styled by the
|
||||
host's stylesheet), and index.css loads it for the page.
|
||||
|
||||
What remains here still speaks of the document — normalize.css and the
|
||||
typography below use bare element selectors, and the custom properties are
|
||||
declared on `:root` — which is right for the page, and is why the component
|
||||
build rewrites it: there every selector is confined to Element Call's root
|
||||
element (see component/build/scopeStylesToRoot.ts), with `html`, `body` and
|
||||
`:root` becoming that element. Nothing needs to be written differently here for
|
||||
that to work, but nothing here may rely on reaching the host's document either.
|
||||
|
||||
Nothing here should depend on where it lands relative to Element Call's
|
||||
component stylesheets: the bundler decides that, and it decides differently for
|
||||
@@ -25,16 +30,7 @@ the app and for the component build. */
|
||||
|
||||
@layer normalize, compound-legacy, compound;
|
||||
|
||||
@import url("@fontsource/inter/400.css");
|
||||
@import url("@fontsource/inter/500.css");
|
||||
@import url("@fontsource/inter/600.css");
|
||||
@import url("@fontsource/inter/700.css");
|
||||
@import url("@fontsource/inconsolata/400.css");
|
||||
@import url("@fontsource/inconsolata/700.css");
|
||||
|
||||
@import url("normalize.css/normalize.css") layer(normalize);
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
|
||||
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
|
||||
|
||||
:root {
|
||||
--font-scale: 1;
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
aria-labelledby="_r_bh_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -43,7 +43,10 @@ Please see LICENSE in the repository root for full details.
|
||||
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;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Link } from "@vector-im/compound-web";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CallFooter, type FooterSnapshot } from "./CallFooter";
|
||||
import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
@@ -137,10 +138,13 @@ export const Default: Story = {
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
audioOutputOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -158,15 +162,46 @@ export const WithAudioAndVideoOptions: Story = {
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
audioOutputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedAudioOutput: "default",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const StaysWhileAMenuIsOpen: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...WithAudioAndVideoOptions.args,
|
||||
// In a short window the footer overlays the call and hides itself.
|
||||
asOverlay: true,
|
||||
showFooter: true,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const footer = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-testid="footer-container"]',
|
||||
)!;
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "Microphone" }),
|
||||
);
|
||||
await expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
|
||||
|
||||
// The call hides the footer with this class.
|
||||
footer.classList.add(styles.hidden);
|
||||
|
||||
// Read after the 0.15s fade, not mid-transition.
|
||||
await new Promise((settled) => setTimeout(settled, 400));
|
||||
await expect(getComputedStyle(footer).opacity).toBe("1");
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
|
||||
@@ -99,11 +99,15 @@ export interface FooterState {
|
||||
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
audioOptions: MenuOptions[];
|
||||
/** Output (speaker) devices. */
|
||||
audioOutputOptions: MenuOptions[];
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
videoOptions: MenuOptions[];
|
||||
selectedAudio: string | undefined;
|
||||
selectedAudioOutput: string | undefined;
|
||||
selectedVideo: string | undefined;
|
||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
}
|
||||
|
||||
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
outputOptions={audioOutputOptions}
|
||||
selectedOutputOption={selectedAudioOutput}
|
||||
onSelectOutput={selectAudioOutputOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Camera Source"}
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
|
||||
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
@@ -96,6 +101,65 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(false);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is offered where the platform can route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOutputOptions", () => {
|
||||
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "name", name: "Microphone 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
// Empty, not undefined: undefined would hide the section.
|
||||
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
> {
|
||||
return {
|
||||
audioOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.audioInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> =>
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||
videoOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.videoInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
audioOutputOptions$: scope.behavior(
|
||||
options$(mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectAudioOutputOption$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: undefined,
|
||||
audioOutputOptions: undefined,
|
||||
videoOptions: undefined,
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
|
||||
@@ -35,3 +35,60 @@ Please see LICENSE in the repository root for full details.
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
||||
portalled out of the root, so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: var(--device-list-max-height);
|
||||
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
|
||||
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
|
||||
var(--device-list-scroll-padding-end, 0);
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
.menu h3 {
|
||||
margin-block-end: var(--cpd-space-5x);
|
||||
}
|
||||
|
||||
/* And more above a heading that follows another section. */
|
||||
.deviceList [role="group"] + [role="group"] h3 {
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* Sticky within its own section. Opaque and inset a border width so it doesn't
|
||||
paint over the menu's outline. */
|
||||
.sectionHeading {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-start: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Sticky at the foot of the microphone section; opaque and inset as above. */
|
||||
.stickyMeter {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
follow the mouse. Replaced by one shown only for keyboard focus. */
|
||||
.menu [role^="menuitem"]:focus,
|
||||
.menu [role^="menuitem"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
|
||||
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn, userEvent, within, expect } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
|
||||
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
@@ -18,12 +21,74 @@ const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/** Supplies a microphone, a wavering tone: a story's invented device ids match no hardware. */
|
||||
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const context = new AudioContext();
|
||||
const microphone = context.createMediaStreamDestination();
|
||||
const tone = context.createOscillator();
|
||||
const loudness = context.createGain();
|
||||
// Wavers between a third and two thirds of full scale, so it reads as live.
|
||||
const swing = context.createOscillator();
|
||||
const depth = context.createGain();
|
||||
loudness.gain.value = 0.25;
|
||||
depth.gain.value = 0.2;
|
||||
swing.frequency.value = 0.6;
|
||||
tone.frequency.value = 220;
|
||||
swing.connect(depth).connect(loudness.gain);
|
||||
tone.connect(loudness).connect(microphone);
|
||||
tone.start();
|
||||
swing.start();
|
||||
|
||||
const devices = navigator.mediaDevices;
|
||||
const openedForReal = devices.getUserMedia.bind(devices);
|
||||
const opened = Promise.resolve(microphone.stream);
|
||||
// A fresh clone each time, so a caller stopping its tracks doesn't end the next.
|
||||
devices.getUserMedia = async (): Promise<MediaStream> =>
|
||||
(await opened).clone();
|
||||
|
||||
return (): void => {
|
||||
devices.getUserMedia = openedForReal;
|
||||
tone.stop();
|
||||
swing.stop();
|
||||
void context.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
|
||||
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
blockSize: 720,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{callArea !== null && (
|
||||
<RootElementProvider value={callArea}>{children}</RootElementProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<Story />
|
||||
<WithACallArea>
|
||||
<WithAMicrophone>
|
||||
<Story />
|
||||
</WithAMicrophone>
|
||||
</WithACallArea>
|
||||
</MediaDevicesContext>
|
||||
),
|
||||
],
|
||||
@@ -34,7 +99,6 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "SomeMenu",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
@@ -42,6 +106,13 @@ export const Default: Story = {
|
||||
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
||||
],
|
||||
selectedOption: "1",
|
||||
// The footer always passes an output list to the audio menu.
|
||||
outputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "default",
|
||||
onSelectOutput: fn(),
|
||||
onMuteClick: fn(),
|
||||
onSelect: fn(),
|
||||
},
|
||||
@@ -50,7 +121,6 @@ export const Default: Story = {
|
||||
export const AudioMute: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: false,
|
||||
options: [
|
||||
@@ -73,21 +143,19 @@ export const AudioMute: Story = {
|
||||
|
||||
export const AudioUnmute: Story = {
|
||||
args: {
|
||||
title: "Microphone",
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoMute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: false,
|
||||
options: [
|
||||
@@ -101,7 +169,6 @@ export const VideoMute: Story = {
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: true,
|
||||
options: [
|
||||
@@ -113,3 +180,479 @@ export const VideoUnmute: Story = {
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const SpeakerAndMicrophoneSections: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "default",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const headset = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Headset",
|
||||
});
|
||||
await userEvent.click(headset);
|
||||
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
|
||||
|
||||
// A few devices fit, so the list doesn't scroll.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.scrollHeight).toBe(list.clientHeight);
|
||||
|
||||
// Each section is headed by its own edge-to-edge rule, with no separator.
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
await expect(
|
||||
document.body.querySelectorAll("[role='separator']"),
|
||||
).toHaveLength(0);
|
||||
const headings = document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
);
|
||||
await expect(headings).toHaveLength(2);
|
||||
const frame = menu.getBoundingClientRect();
|
||||
for (const heading of headings) {
|
||||
const rule = heading.querySelector("h3")!;
|
||||
await expect(
|
||||
Number.parseFloat(getComputedStyle(rule).borderBottomWidth),
|
||||
).toBeGreaterThan(0);
|
||||
// Edge to edge, within the frame.
|
||||
const box = rule.getBoundingClientRect();
|
||||
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
|
||||
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
// The first device sits further below the rule than from the menu's edge.
|
||||
// Asserted as a relationship, not pixels: the asymmetry is the design.
|
||||
const control = document.body.querySelector("input[type='radio']")!;
|
||||
const ruleBottom = headings[0]
|
||||
.querySelector("h3")!
|
||||
.getBoundingClientRect().bottom;
|
||||
const box = control.getBoundingClientRect();
|
||||
await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left);
|
||||
|
||||
// And a section stands further from the one above than from its own first
|
||||
// device.
|
||||
const groups = document.body.querySelectorAll("[role='group']");
|
||||
const speakers = groups[0].querySelectorAll("input[type='radio']");
|
||||
const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect();
|
||||
const nextHeading = groups[1]!.querySelector("h3")!.getBoundingClientRect();
|
||||
await expect(nextHeading.top - lastSpeaker.bottom).toBeGreaterThan(
|
||||
box.top - ruleBottom,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputCannotBeChosen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "spk1",
|
||||
// No callback: the speakers are listed, but none can be picked.
|
||||
onSelectOutput: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Speakers",
|
||||
});
|
||||
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyOneDevice: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [{ label: { type: "name", name: "Speakers" }, id: "spk1" }],
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// Shown, disabled, rather than hidden.
|
||||
const only = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
await expect(only).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** A requested device hasn't arrived: nothing in either section can be picked. */
|
||||
export const SelectionSettling: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// selectedOption never changes, so the request stays in flight.
|
||||
await userEvent.click(
|
||||
await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
await expect(await menu.findByLabelText("Activating…")).toBeVisible();
|
||||
for (const item of menu.getAllByRole("menuitemradio"))
|
||||
await expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ring shows for keyboard focus only. Asserted on the painted outline:
|
||||
* asserting data-focus-source would still pass with the CSS rule deleted.
|
||||
*/
|
||||
export const KeyboardFocusRing: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
const first = await menu.findByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
|
||||
// Opened by pointer: no ring, even though Radix has moved focus.
|
||||
await expect(outlineWidth(first)).toBe(0);
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
const focused = document.activeElement as HTMLElement;
|
||||
await expect(focused).toHaveRole("menuitemradio");
|
||||
await expect(outlineWidth(focused)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away again.
|
||||
await userEvent.hover(first);
|
||||
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The list scrolls; the meter stays at the foot of the microphone section. */
|
||||
export const ManyDevices: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 6 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// The sticky wrapper rather than the meter: without a fake microphone (as on
|
||||
// WebKit) the meter shows a message instead of a level.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const sticky = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
|
||||
// Scrolled so the microphone section starts at the top: only there does a
|
||||
// pinned meter differ from one that is simply last.
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
list.scrollTop +=
|
||||
group.getBoundingClientRect().top - list.getBoundingClientRect().top;
|
||||
await expect(list.scrollTop + list.clientHeight).toBeLessThan(
|
||||
list.scrollHeight,
|
||||
);
|
||||
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
const pinned = sticky.getBoundingClientRect();
|
||||
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||
|
||||
// The whole menu is on screen, not bounded by the document.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||
|
||||
// The meter is the menu's one opaque part, so it must stay inside the frame.
|
||||
await expect(pinned.left).toBeGreaterThan(frame.left);
|
||||
await expect(pinned.right).toBeLessThan(frame.right);
|
||||
},
|
||||
};
|
||||
|
||||
/** Painted outline width, in px. */
|
||||
function outlineWidth(element: HTMLElement): number {
|
||||
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||
if (outlineStyle === "none") return 0;
|
||||
return Number.parseFloat(outlineWidth) || 0;
|
||||
}
|
||||
|
||||
/** Safari lists no outputs: a disabled, selected Default stands in. */
|
||||
export const OutputNotEnumerated: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [],
|
||||
selectedOutputOption: undefined,
|
||||
onSelectOutput: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Default",
|
||||
});
|
||||
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** The meter's icon shares the radio controls' centre line; only a real browser lays this out. */
|
||||
export const MeterAlignsWithTheDeviceRows: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
const radio = menu.querySelector("input[type='radio']")!;
|
||||
const icon = await waitFor(() => {
|
||||
const element = document.body.querySelector(`.${meterStyles.icon}`);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
|
||||
// A pixel of slack, for subpixel layout.
|
||||
await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1);
|
||||
},
|
||||
};
|
||||
|
||||
/** Inline-axis centre of an element. */
|
||||
function centre(element: Element): number {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.left + box.width / 2;
|
||||
}
|
||||
|
||||
/** Every row the keyboard reaches is fully visible, not under a heading or the meter. */
|
||||
export const KeyboardReachesEveryDevice: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
// Enough to scroll both ways, so either end can hide a row.
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 4 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const meter = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
|
||||
// Drawn over the list: the sticky headings and the meter.
|
||||
const overlays = [
|
||||
meter,
|
||||
...document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
),
|
||||
];
|
||||
const items = within(document.body).getAllByRole("menuitemradio");
|
||||
|
||||
// Down to the last device and back up.
|
||||
for (const key of ["{ArrowDown}", "{ArrowUp}"])
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await userEvent.keyboard(key);
|
||||
const focused = document.activeElement as HTMLElement;
|
||||
await expect(focused).toHaveRole("menuitemradio");
|
||||
// Checked as overlap: a heading only covers rows while its section is on
|
||||
// screen.
|
||||
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A section's heading stays at the top while the section is in view, and leaves with it. */
|
||||
export const HeadingsStayWhileScrolling: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 4 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
// By class: the heading is aria-hidden, and its group carries the name.
|
||||
const heading = group.querySelector<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
)!;
|
||||
|
||||
// Far enough that the heading is only on screen if it is stuck there.
|
||||
list.scrollTop +=
|
||||
group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80;
|
||||
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().top).toBeLessThanOrEqual(
|
||||
scrollport.top + 2,
|
||||
);
|
||||
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
||||
scrollport.top,
|
||||
);
|
||||
// Clear of the menu's frame.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
|
||||
frame.left,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** How far the most overlapping of `overlays` covers `element`, in px. */
|
||||
function overlapping(element: Element, overlays: Element[]): number {
|
||||
const box = element.getBoundingClientRect();
|
||||
return overlays.reduce((worst, overlay) => {
|
||||
const over = overlay.getBoundingClientRect();
|
||||
const shared =
|
||||
Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
return Math.max(worst, shared);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
|
||||
export const FocusRingCoversTheBlurToggle: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
iconsAndLabels: "video",
|
||||
videoBlurEnabled: false,
|
||||
videoBlurToggleClick: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
|
||||
name: /Blur background/,
|
||||
});
|
||||
|
||||
// Arrow down past the cameras to the toggle.
|
||||
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
// The same ring the device rows get.
|
||||
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away, with the toggle still focused.
|
||||
await userEvent.hover(toggle);
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
await expect(outlineWidth(toggle)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
type RenderResult,
|
||||
} from "@testing-library/react";
|
||||
import 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 { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
function withProviders(
|
||||
component: ReactNode,
|
||||
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext
|
||||
value={{ requestDeviceNames } as unknown as MediaDevices}
|
||||
>
|
||||
{component}
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
component: ReactNode,
|
||||
options: Partial<RenderOptions> = {},
|
||||
): RenderResult {
|
||||
return render(withProviders(component, options));
|
||||
}
|
||||
|
||||
describe("MediaMuteAndSwitchButton", () => {
|
||||
// Only one test stubs the capture; don't let it leak into the rest.
|
||||
afterEach(restoreAudioCapture);
|
||||
|
||||
test("renders", () => {
|
||||
const { container } = renderComponent(
|
||||
<TooltipProvider>
|
||||
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
|
||||
<MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
@@ -50,11 +71,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
enabled: boolean,
|
||||
): RenderResult => {
|
||||
return renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
iconsAndLabels={type}
|
||||
enabled={enabled}
|
||||
/>,
|
||||
<MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
|
||||
);
|
||||
};
|
||||
const renderAudioEndabled = renderLabels("audio", true);
|
||||
@@ -81,7 +98,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
@@ -98,7 +114,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
@@ -119,7 +134,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
@@ -139,11 +153,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const user = userEvent.setup();
|
||||
const requestDeviceNames = vi.fn();
|
||||
renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
/>,
|
||||
<MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
|
||||
{ requestDeviceNames },
|
||||
);
|
||||
|
||||
@@ -157,7 +167,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
renderComponent(
|
||||
<>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
options={[
|
||||
@@ -167,7 +176,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
selectedOption="mic1"
|
||||
/>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled
|
||||
options={[
|
||||
@@ -193,7 +201,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -217,7 +224,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -246,7 +252,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -306,7 +311,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
@@ -327,11 +331,10 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
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 { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -342,19 +345,493 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// open menu
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
|
||||
});
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitemradio", {
|
||||
test("disables every device while a selection is settling", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
function Wrapper(): JSX.Element {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
void promise.then(() => setSelectedOption(id));
|
||||
}}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = renderComponent(<Wrapper />);
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
// In flight: nothing else can be picked, in either section.
|
||||
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
|
||||
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
// 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",
|
||||
});
|
||||
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.
|
||||
*/
|
||||
|
||||
import { type ComponentType, useState, type FC, useEffect } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTitle,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOnIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { type DeviceLabel } from "../state/MediaDevices";
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
type DeviceLabel,
|
||||
} from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel;
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MediaMuteAndSwitchButtonProps {
|
||||
/** The title used in the Switcher modal. */
|
||||
title: string;
|
||||
/** If the Mute button is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback if the mute button is clicked */
|
||||
@@ -47,6 +58,12 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
options?: MenuOptions[];
|
||||
/** The option that will currently be rendered as the selected option */
|
||||
selectedOption?: string;
|
||||
/** Output (speaker) devices. Audio menu only. */
|
||||
outputOptions?: MenuOptions[];
|
||||
/** The output option currently rendered as selected */
|
||||
selectedOutputOption?: string;
|
||||
/** Picks an output device. Undefined disables the speaker section. */
|
||||
onSelectOutput?: (id: string) => void;
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/**
|
||||
@@ -58,90 +75,240 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
|
||||
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
|
||||
const DEFAULT_OUTPUT_ID = "default";
|
||||
|
||||
/** Largest share of the call area's height the device list may take. */
|
||||
const LIST_SHARE_OF_CALL = 0.6;
|
||||
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
title,
|
||||
enabled,
|
||||
busy,
|
||||
onMuteClick,
|
||||
iconsAndLabels,
|
||||
options,
|
||||
selectedOption,
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
onSelect,
|
||||
}) => {
|
||||
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 onOpenChange = useCallback((open: boolean): void => {
|
||||
setMenuOpen(open);
|
||||
// Drop a request that never arrived.
|
||||
if (!open) setPlannedSelection(null);
|
||||
}, []);
|
||||
const isBusy = busy ?? false;
|
||||
const { t } = useTranslation();
|
||||
const devices = useMediaDevices();
|
||||
|
||||
/**
|
||||
* Records on the menu whether the keyboard or the pointer moved focus, for
|
||||
* the focus ring. `:focus-visible` can't tell: Radix focuses whatever the
|
||||
* pointer is over. Listened for on the menu, not the document, so a second
|
||||
* Element Call on the page doesn't answer for this one.
|
||||
*/
|
||||
const trackFocusSource = useCallback(
|
||||
(list: HTMLDivElement | null): (() => void) | undefined => {
|
||||
const menu = list?.closest<HTMLElement>('[role="menu"]');
|
||||
if (menu === null || menu === undefined) return;
|
||||
const record = (source: "keyboard" | "pointer"): void => {
|
||||
menu.dataset.focusSource = source;
|
||||
};
|
||||
record("pointer");
|
||||
const usedKeyboard = (): void => record("keyboard");
|
||||
const usedPointer = (): void => record("pointer");
|
||||
menu.addEventListener("keydown", usedKeyboard, true);
|
||||
menu.addEventListener("pointermove", usedPointer, true);
|
||||
return (): void => {
|
||||
menu.removeEventListener("keydown", usedKeyboard, true);
|
||||
menu.removeEventListener("pointermove", usedPointer, true);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setListMaxHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Kept clear at the list's foot, so a row reached by keyboard isn't under
|
||||
// the meter.
|
||||
const [meterHeight, meter] = useMeasuredHeight();
|
||||
|
||||
// Likewise at its head, for the sticky headings.
|
||||
const [headingHeight, heading] = useMeasuredHeight();
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
|
||||
let button;
|
||||
let toggles: { label: string; enabled: boolean; id: string }[] = [];
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
button = (
|
||||
<VideoButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>
|
||||
);
|
||||
if (videoBlurToggleClick !== undefined) {
|
||||
toggles = [
|
||||
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||
const button = (
|
||||
<MuteButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid={
|
||||
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggles =
|
||||
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||
? [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
];
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
button = (
|
||||
<MicButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
|
||||
let optionsButtonLabel: string;
|
||||
let menuTitle: string;
|
||||
let numberedLabel: (number: number) => string;
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
IconOptions = VideoCallIcon;
|
||||
optionsButtonLabel = t("settings.devices.camera");
|
||||
menuTitle = t("settings.devices.camera_source");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.camera_numbered", { n });
|
||||
break;
|
||||
case "audio":
|
||||
IconOptions = MicOnIcon;
|
||||
optionsButtonLabel = t("settings.devices.microphone");
|
||||
menuTitle = t("settings.devices.mic_source");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.microphone_numbered", { n });
|
||||
break;
|
||||
}
|
||||
|
||||
const labelText = (
|
||||
label: MenuOptions["label"],
|
||||
numbered: (n: number) => string,
|
||||
): string => {
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
return label.name;
|
||||
case "number":
|
||||
return numbered(label.number);
|
||||
case "default":
|
||||
return label.name === null
|
||||
? t("settings.devices.default")
|
||||
: t("settings.devices.default_named_plain", { name: label.name });
|
||||
case "speaker":
|
||||
return t("settings.devices.loudspeaker");
|
||||
case "earpiece":
|
||||
return t("settings.devices.handset");
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing can be picked while a requested device is on offer but not yet
|
||||
// selected, so a second request can't overtake it.
|
||||
const plannedOutput = plannedSelection?.kind === "output";
|
||||
const selectedOfPlannedKind = plannedOutput
|
||||
? selectedOutputOption
|
||||
: selectedOption;
|
||||
const offeredOfPlannedKind = plannedOutput ? outputOptions : options;
|
||||
const settling =
|
||||
plannedSelection !== null &&
|
||||
plannedSelection.id !== selectedOfPlannedKind &&
|
||||
offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true;
|
||||
|
||||
// Safari lists no outputs: show a disabled, selected Default rather than an
|
||||
// empty section.
|
||||
const noOutputsListed = outputOptions?.length === 0;
|
||||
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
|
||||
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
|
||||
: outputOptions;
|
||||
const selectedSpeaker = noOutputsListed
|
||||
? DEFAULT_OUTPUT_ID
|
||||
: selectedOutputOption;
|
||||
|
||||
const deviceItems = (
|
||||
kind: "input" | "output",
|
||||
items: MenuOptions[] | undefined,
|
||||
selected: string | undefined,
|
||||
select: ((id: string) => void) | undefined,
|
||||
numbered: (n: number) => string,
|
||||
): ReactElement[] => {
|
||||
const list = items ?? [];
|
||||
// Disabled rather than hidden, so the menu keeps its shape.
|
||||
const disabled = select === undefined || list.length <= 1 || settling;
|
||||
return list.map(({ label, id }) => (
|
||||
<MenuItem
|
||||
// A radio input may not sit inside a button.
|
||||
as="div"
|
||||
hideChevron
|
||||
disabled={disabled}
|
||||
label={labelText(label, numbered)}
|
||||
Icon={
|
||||
// Inert, not aria-hidden: aria-hidden alone leaves it focusable.
|
||||
<span inert>
|
||||
<RadioInput
|
||||
checked={selected === id}
|
||||
disabled={disabled}
|
||||
// Not readOnly, which mutes the selected fill. The item handles
|
||||
// the click.
|
||||
onChange={(): void => {}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selected) return;
|
||||
setPlannedSelection({ kind, id });
|
||||
select?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected === id}
|
||||
>
|
||||
{selected !== id &&
|
||||
plannedSelection?.kind === kind &&
|
||||
plannedSelection.id === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
@@ -152,10 +319,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
<Menu
|
||||
title={title}
|
||||
showTitle={true}
|
||||
className={styles.menu}
|
||||
// Named for screen readers only: each section has its own heading.
|
||||
title={menuTitle}
|
||||
showTitle={false}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
side="top"
|
||||
trigger={
|
||||
<Button
|
||||
@@ -171,67 +340,74 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{options?.map(({ label, id }) => {
|
||||
let labelText: string;
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
labelText = label.name;
|
||||
break;
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
<div
|
||||
ref={trackFocusSource}
|
||||
// Keeps the items the menu's own children for assistive tech.
|
||||
role="none"
|
||||
className={styles.deviceList}
|
||||
style={
|
||||
{
|
||||
"--device-list-max-height":
|
||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||
"--device-list-scroll-padding-end":
|
||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
||||
"--device-list-scroll-padding-start":
|
||||
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
label={labelText}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selectedOption) return;
|
||||
setPlannedSelection(id);
|
||||
onSelect?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedOption === id}
|
||||
>
|
||||
{selectedOption === id && (
|
||||
<CheckIcon
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden // A label would be redundant to aria-checked above
|
||||
>
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
{/* A menu may only contain items, separators and groups, so each
|
||||
heading is a hidden part of a named group. */}
|
||||
<div role="group" aria-label={t("settings.devices.speaker")}>
|
||||
<div aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
</div>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div role="group" aria-label={optionsButtonLabel}>
|
||||
<div ref={heading} aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
</div>
|
||||
{/* Apart from the heading, so the sticky meter can't ride over it. */}
|
||||
<div role="none">
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
ref={meter}
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
className={styles.stickyMeter}
|
||||
/>
|
||||
)}
|
||||
{selectedOption !== id && plannedSelection === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled ?? false}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
@@ -239,3 +415,25 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Follows an element's height. */
|
||||
function useMeasuredHeight(): [
|
||||
number | undefined,
|
||||
(element: HTMLElement | null) => (() => void) | undefined,
|
||||
] {
|
||||
const [height, setHeight] = useState<number>();
|
||||
const ref = useCallback(
|
||||
(element: HTMLElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map((size) => size.height),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
},
|
||||
[],
|
||||
);
|
||||
return [height, ref];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { type TileViewModel } from "../state/TileViewModel";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
export interface Bounds {
|
||||
width: number;
|
||||
@@ -20,7 +20,7 @@ export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
minBounds$: Observable<Bounds>;
|
||||
minBounds$: Behavior<Bounds>;
|
||||
}
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
|
||||
+12
-18
@@ -11,14 +11,12 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--gap": string;
|
||||
@@ -39,22 +37,18 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
// lives
|
||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
model.spotlightAlignment$.pipe(
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const alignment = useBehavior(model.spotlightAlignment$);
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.spotlightAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
({ xRatio, yRatio }) => {
|
||||
const block = yRatio < 0.5 ? "start" : "end";
|
||||
const inline = xRatio < 0.5 ? "start" : "end";
|
||||
// A drag reports the same alignment on every move; only a change is
|
||||
// worth a re-render
|
||||
const current = model.spotlightAlignment$.value;
|
||||
if (current.block !== block || current.inline !== inline)
|
||||
model.spotlightAlignment$.next({ block, inline });
|
||||
},
|
||||
[model.spotlightAlignment$],
|
||||
);
|
||||
|
||||
@@ -78,7 +72,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width, height: minHeight } = useObservableEagerState(minBounds$);
|
||||
const { width, height: minHeight } = useBehavior(minBounds$);
|
||||
const { gap, tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(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 { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
@@ -37,7 +36,7 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const { width, height } = useBehavior(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => 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 { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
@@ -33,7 +32,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds$);
|
||||
useBehavior(minBounds$);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
@@ -56,7 +55,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
useObservableEagerState(minBounds$);
|
||||
useBehavior(minBounds$);
|
||||
|
||||
return (
|
||||
<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 { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
@@ -58,7 +57,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width } = useObservableEagerState(minBounds$);
|
||||
const { width } = useBehavior(minBounds$);
|
||||
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||
width,
|
||||
// TODO: We pretend that the minimum height is the width, because the
|
||||
|
||||
+15
-1
@@ -7,10 +7,24 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
/* Styles for Element Call as a page of its own. The parts that apply wherever
|
||||
Element Call is shown live in base.css; these are about owning the document,
|
||||
and are not loaded when a host embeds Element Call as a component. */
|
||||
and are not loaded when a host embeds Element Call as a component.
|
||||
|
||||
That includes Compound: the page brings the design tokens, the component styles
|
||||
and their fonts, where a host embedding the component supplies its own. The
|
||||
`compound` layers are ordered by base.css. */
|
||||
|
||||
@import url("./base.css");
|
||||
|
||||
@import url("@fontsource/inter/400.css");
|
||||
@import url("@fontsource/inter/500.css");
|
||||
@import url("@fontsource/inter/600.css");
|
||||
@import url("@fontsource/inter/700.css");
|
||||
@import url("@fontsource/inconsolata/400.css");
|
||||
@import url("@fontsource/inconsolata/700.css");
|
||||
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
|
||||
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
color: var(--cpd-color-text-primary);
|
||||
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
@@ -31,6 +30,7 @@ import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
import { platform } from "../Platform";
|
||||
import { useValueBehavior } from "../useValueBehavior";
|
||||
|
||||
//TODO-MULTI-SFU: This is not yet fully there.
|
||||
// it is a combination of exposing observable and react hooks.
|
||||
@@ -52,18 +52,8 @@ export function useTrackProcessor(): ProcessorState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
const state = use(ProcessorContext);
|
||||
if (state === undefined)
|
||||
throw new Error(
|
||||
"useTrackProcessor must be used within a ProcessorProvider",
|
||||
);
|
||||
const state$ = useObservable(
|
||||
(init$) => init$.pipe(map(([init]) => init)),
|
||||
[state],
|
||||
);
|
||||
|
||||
return state$;
|
||||
export function useTrackProcessorState$(): Behavior<ProcessorState> {
|
||||
return useValueBehavior(useTrackProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,8 @@ import waveSoundOgg from "../sound/reactions/wave.ogg?url";
|
||||
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
|
||||
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
|
||||
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
|
||||
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
|
||||
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
|
||||
|
||||
export const ElementCallReactionEventType = "io.element.call.reaction";
|
||||
|
||||
@@ -202,6 +204,15 @@ export const ReactionSet: ReactionOption[] = [
|
||||
mp3: baduntssSoundMp3,
|
||||
},
|
||||
},
|
||||
{
|
||||
emoji: "🛎️",
|
||||
name: "bell",
|
||||
alias: ["ding"],
|
||||
sound: {
|
||||
ogg: bellSoundOgg,
|
||||
mp3: bellSoundMp3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface RaisedHandInfo {
|
||||
|
||||
+8
-12
@@ -20,8 +20,6 @@ import {
|
||||
import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -77,10 +75,11 @@ import {
|
||||
useAppBarSubtitle,
|
||||
} from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useValueBehavior } from "../useValueBehavior.ts";
|
||||
import { constant } from "../state/Behavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
@@ -121,7 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
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
|
||||
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||
const rootElement = useRootElement();
|
||||
@@ -151,7 +150,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
scope.behavior(trackProcessorState$),
|
||||
trackProcessorState$,
|
||||
);
|
||||
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||
vm.join();
|
||||
@@ -252,7 +251,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
useWakeLock();
|
||||
// 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,
|
||||
// only by listening for the emitted event
|
||||
@@ -374,10 +373,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
edgeToEdge,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable$ = useObservable(
|
||||
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
|
||||
[gridBounds],
|
||||
);
|
||||
const gridBounds$ = useValueBehavior(gridBounds);
|
||||
|
||||
useAppBarHidden(!showHeader);
|
||||
useAppBarSubtitle(
|
||||
@@ -538,7 +534,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
const inputs = { minBounds$: gridBoundsObservable$ };
|
||||
const inputs = { minBounds$: gridBounds$ };
|
||||
return {
|
||||
grid: makeGridLayout(inputs),
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
@@ -547,7 +543,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
||||
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable$]);
|
||||
}, [gridBounds$]);
|
||||
|
||||
const showFooter = useBehavior(footerVm.showFooter$);
|
||||
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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { of } from "rxjs";
|
||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -18,7 +20,13 @@ import {
|
||||
|
||||
import { LobbyView } from "./LobbyView";
|
||||
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 { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
@@ -77,9 +85,10 @@ function renderLobbyView(
|
||||
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
||||
withAppBar = false,
|
||||
platform = "android",
|
||||
devices: Partial<MediaDevices> = {},
|
||||
): ReturnType<typeof render> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
const mediaDevices = mockMediaDevices(devices);
|
||||
const muteStates = mockMuteStates();
|
||||
const hideHeader = withAppBar ? true : false;
|
||||
const lobbyView = (
|
||||
@@ -178,3 +187,30 @@ describe("LobbyView", () => {
|
||||
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,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -125,9 +124,7 @@ export const LobbyView: FC<Props> = ({
|
||||
);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const videoInputId = useObservableEagerState(
|
||||
devices.videoInput.selected$,
|
||||
)?.id;
|
||||
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
|
||||
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
// we're not doing anything with the audio anyway so we don't need to
|
||||
|
||||
@@ -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,
|
||||
} from "@vector-im/compound-web";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
type MediaDevice,
|
||||
} from "../state/MediaDevices";
|
||||
import styles from "./DeviceSelection.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface Props {
|
||||
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
|
||||
@@ -44,8 +44,8 @@ export const DeviceSelection: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const groupId = useId();
|
||||
const available = useObservableEagerState(device.available$);
|
||||
const selectedId = useObservableEagerState(device.selected$)?.id;
|
||||
const available = useBehavior(device.available$);
|
||||
const selectedId = useBehavior(device.selected$)?.id;
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
device.select(e.target.value);
|
||||
|
||||
@@ -10,6 +10,7 @@ The following sound effects have been licensed from Pixabay, under https://pixab
|
||||
- `reactions/generic`
|
||||
- `reactions/lightbulb`
|
||||
- `reactions/party`
|
||||
- `reactions/bell`
|
||||
|
||||
### Other
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,6 @@ import {
|
||||
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
@@ -39,11 +38,12 @@ import {
|
||||
throttleTime,
|
||||
timer,
|
||||
takeUntil,
|
||||
from,
|
||||
} from "rxjs";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
@@ -112,7 +112,7 @@ import {
|
||||
TransportState,
|
||||
} from "./localMember/LocalMember.ts";
|
||||
import {
|
||||
createLocalTransport$,
|
||||
getLocalTransport,
|
||||
type LocalTransport,
|
||||
} from "./localMember/LocalTransport.ts";
|
||||
import {
|
||||
@@ -573,16 +573,16 @@ export function createCallViewModel$(
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
const localTransport =
|
||||
options.localTransport ??
|
||||
createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId: matrixRoom.roomId,
|
||||
matrixRTCMode,
|
||||
});
|
||||
const localTransport$ = options.localTransport
|
||||
? constant(options.localTransport)
|
||||
: from(
|
||||
getLocalTransport({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId: matrixRoom.roomId,
|
||||
matrixRTCMode,
|
||||
}),
|
||||
);
|
||||
|
||||
const connectionFactory =
|
||||
options.connectionFactory ??
|
||||
@@ -599,17 +599,7 @@ export function createCallViewModel$(
|
||||
const connectionManager = createConnectionManager$({
|
||||
scope: scope,
|
||||
connectionFactory: connectionFactory,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport.active$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
logger.info(
|
||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||
e,
|
||||
);
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
),
|
||||
localTransport$,
|
||||
remoteTransports$: membershipsAndTransports.transports$,
|
||||
logger: logger,
|
||||
ownMembershipIdentity,
|
||||
@@ -634,7 +624,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
muteStates,
|
||||
joinMatrixRTC: (
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||
) => {
|
||||
return enterRTCSession(
|
||||
@@ -665,7 +655,7 @@ export function createCallViewModel$(
|
||||
connectionManager,
|
||||
client,
|
||||
matrixRTCSession,
|
||||
localTransport,
|
||||
localTransport$,
|
||||
roomId: matrixRoom.roomId,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
|
||||
@@ -210,11 +210,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
connectionState$,
|
||||
windowSize$,
|
||||
localTransport: {
|
||||
active$: constant({
|
||||
transport: exampleTransport,
|
||||
sfuConfig: exampleSfuConfig,
|
||||
}),
|
||||
advertised$: constant(exampleTransport),
|
||||
transport: exampleTransport,
|
||||
sfuConfig: exampleSfuConfig,
|
||||
},
|
||||
connectionFactory: {
|
||||
createConnection(
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
Status as RTCMemberStatus,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
import { BehaviorSubject, map, of, Subject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LocalParticipant, type LocalTrack } from "livekit-client";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -50,10 +50,7 @@ import {
|
||||
TrackState,
|
||||
watchScreenShareToggle,
|
||||
} from "./LocalMember";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
MatrixRTCTransportMissingError,
|
||||
} from "../../../utils/errors";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { constant } from "../../Behavior";
|
||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||
@@ -61,10 +58,7 @@ import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
import { nullHostBridge } from "../../../HostBridge";
|
||||
import {
|
||||
type LocalTransport,
|
||||
type LocalTransportWithSFUConfig,
|
||||
} from "./LocalTransport";
|
||||
import { type LocalTransport } from "./LocalTransport";
|
||||
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||
|
||||
initializeWidget();
|
||||
@@ -127,8 +121,7 @@ const mockedClient = {
|
||||
};
|
||||
|
||||
describe("enterRTCSession", () => {
|
||||
const transport: LivekitTransportConfig = {
|
||||
livekit_alias: "roomId",
|
||||
const transport: LivekitTransport = {
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -145,7 +138,7 @@ describe("enterRTCSession", () => {
|
||||
client: mockedClient,
|
||||
},
|
||||
memberships: [],
|
||||
joinRTCSession: vi.fn(),
|
||||
join: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
beforeEach(() =>
|
||||
@@ -157,19 +150,18 @@ describe("enterRTCSession", () => {
|
||||
it("It joins the correct Session", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
{
|
||||
deviceId: "DEVICE",
|
||||
memberId: "@alice:example.org:DEVICE",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
[],
|
||||
transport,
|
||||
[transport],
|
||||
expect.objectContaining({ manageMediaKeys: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
|
||||
it("passes keyRotationParticipantLimit from config to join", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
matrix_rtc_session: {
|
||||
@@ -182,9 +174,8 @@ describe("enterRTCSession", () => {
|
||||
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
[],
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
keyRotationParticipantLimit: 50,
|
||||
@@ -195,8 +186,7 @@ describe("enterRTCSession", () => {
|
||||
it("uses the specified delayed leave timings", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -216,7 +206,7 @@ describe("LocalMembership", () => {
|
||||
}),
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
||||
leaveRoomSession: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
} as unknown as MatrixRTCSession,
|
||||
muteStates: mockMuteStates(),
|
||||
trackProcessorState$: constant({
|
||||
@@ -261,8 +251,8 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||
const localTransport$ = scope.behavior<null | LivekitTransport>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
@@ -277,16 +267,15 @@ describe("LocalMembership", () => {
|
||||
),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: localTransport$,
|
||||
active$: behavior("a", { a: null }),
|
||||
};
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: hot(
|
||||
"1ms #",
|
||||
{},
|
||||
new MatrixRTCTransportMissingError("domain.com"),
|
||||
),
|
||||
});
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
||||
@@ -296,61 +285,9 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should not publish to active transport if advertised has errors", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
|
||||
null,
|
||||
);
|
||||
|
||||
// Populate a connection for active
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportBConnected, []);
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([bTransport])),
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$,
|
||||
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
|
||||
};
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
return {} as unknown as Publisher;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
});
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("n-e", {
|
||||
n: TransportState.Waiting,
|
||||
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
|
||||
});
|
||||
|
||||
// Should not have created any publisher
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs if callIntent cannot be updated", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([])),
|
||||
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
|
||||
@@ -363,10 +300,10 @@ describe("LocalMembership", () => {
|
||||
...defaultCreateLocalMemberValues,
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockImplementation(reject),
|
||||
leaveRoomSession: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
},
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
const expextedLog =
|
||||
"'not connected yet' while updating the call intent (this is expected on startup)";
|
||||
@@ -378,33 +315,19 @@ describe("LocalMembership", () => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
const aTransport = {
|
||||
const mockTransportConfig = {
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransportConfig;
|
||||
} as LivekitTransport;
|
||||
|
||||
const aTransportWithSFUConfig = {
|
||||
transport: aTransport,
|
||||
const mockTransport = {
|
||||
transport: mockTransportConfig,
|
||||
sfuConfig: {
|
||||
jwt: "foo",
|
||||
livekitAlias: "bar",
|
||||
livekitIdentity: "baz",
|
||||
url: "bro",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
|
||||
const bTransport = {
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const bTransportWithSFUConfig = {
|
||||
transport: bTransport,
|
||||
sfuConfig: {
|
||||
jwt: "foo2",
|
||||
livekitAlias: "bar2",
|
||||
livekitIdentity: "baz2",
|
||||
url: "bro2",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
} as LocalTransport;
|
||||
|
||||
const connectionTransportAConnected = {
|
||||
livekitRoom: mockLivekitRoom({
|
||||
@@ -414,18 +337,13 @@ describe("LocalMembership", () => {
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport,
|
||||
} as unknown as Connection;
|
||||
transport: mockTransportConfig,
|
||||
} as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
state$: constant(ConnectionState.LivekitConnecting),
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
const connectionTransportBConnected = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: bTransport,
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const authCallSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
@@ -459,10 +377,7 @@ describe("LocalMembership", () => {
|
||||
),
|
||||
},
|
||||
joinMatrixRTC,
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
delayId$,
|
||||
});
|
||||
|
||||
@@ -471,7 +386,7 @@ describe("LocalMembership", () => {
|
||||
await flushPromises();
|
||||
// Joins with timings appropriate for the level of delegation support
|
||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||
aTransport,
|
||||
mockTransportConfig,
|
||||
delayedLeaveTimings,
|
||||
);
|
||||
|
||||
@@ -505,75 +420,7 @@ describe("LocalMembership", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
let seed = 0;
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const a = seed;
|
||||
seed += 1;
|
||||
logger.info(`creating [${a}]`);
|
||||
const p = {
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
stopPublishing: vi.fn().mockImplementation(() => {
|
||||
logger.info(`stopPublishing [${a}]`);
|
||||
}),
|
||||
stopTracks: vi.fn(),
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
connectionManagerData.add(connectionTransportBConnected, []);
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
});
|
||||
await flushPromises();
|
||||
activeTransport$.next({
|
||||
...aTransportWithSFUConfig,
|
||||
transport: bTransport,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(2);
|
||||
expect(publishers.length).toBe(2);
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[1].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[1].stopTracks).toHaveBeenCalled();
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
|
||||
});
|
||||
|
||||
it("only start tracks if requested", async () => {
|
||||
it("only starts tracks if requested", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
@@ -602,11 +449,6 @@ describe("LocalMembership", () => {
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
// connectionManagerData.add(connectionTransportB, []);
|
||||
@@ -616,7 +458,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||
@@ -637,16 +479,8 @@ describe("LocalMembership", () => {
|
||||
//
|
||||
it("tracks livekit state correctly", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
|
||||
const activeTransport$ =
|
||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
const localTransport$ = new Subject<LocalTransport>();
|
||||
|
||||
const connectionManagerData$ = new BehaviorSubject(
|
||||
new Epoch(connectionManagerData),
|
||||
@@ -687,14 +521,14 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$,
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual(
|
||||
TransportState.Waiting,
|
||||
);
|
||||
activeTransport$.next(aTransportWithSFUConfig);
|
||||
localTransport$.next(mockTransport);
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
@@ -719,7 +553,7 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
(
|
||||
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||
connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next(ConnectionState.LivekitConnected);
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
@@ -822,10 +656,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -862,10 +693,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -913,10 +741,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -957,10 +782,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -1026,10 +848,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
return { scope, localMembership };
|
||||
};
|
||||
@@ -1039,7 +858,7 @@ describe("LocalMembership", () => {
|
||||
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
|
||||
const connection = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport,
|
||||
transport: mockTransportConfig,
|
||||
livekitRoom: mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({
|
||||
isScreenShareEnabled: false,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
Status as RTCSessionStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
@@ -38,6 +37,10 @@ import {
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
NEVER,
|
||||
concat,
|
||||
race,
|
||||
Subject,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
@@ -77,8 +80,6 @@ import {
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { type LocalTransport } from "./LocalTransport.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { or$ } from "../../../utils/observable.ts";
|
||||
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
|
||||
|
||||
export enum TransportState {
|
||||
@@ -142,18 +143,15 @@ interface Props {
|
||||
connectionManager: IConnectionManager;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
joinMatrixRTC: (
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||
) => void;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
roomId: string;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
localTransport: LocalTransport;
|
||||
localTransport$: Observable<LocalTransport>;
|
||||
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
>;
|
||||
matrixRTCSession: Pick<MatrixRTCSession, "updateCallIntent" | "leave">;
|
||||
/** Whether to hide the screen-sharing button. */
|
||||
hideScreensharing: boolean;
|
||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||
@@ -194,7 +192,7 @@ interface Props {
|
||||
export const createLocalMembership$ = ({
|
||||
scope,
|
||||
connectionManager,
|
||||
localTransport,
|
||||
localTransport$: localTransportWithErrors$,
|
||||
homeserverConnected,
|
||||
createPublisherFactory,
|
||||
joinMatrixRTC,
|
||||
@@ -252,20 +250,24 @@ export const createLocalMembership$ = ({
|
||||
const logger = parentLogger.getChild("[LocalMembership]");
|
||||
logger.debug(`Creating local membership..`);
|
||||
|
||||
// We consider error on the transport as fatal.
|
||||
// Whether it is the active transport or the preferred transport.
|
||||
const handleTransportError = (e: unknown): Observable<null> => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error ? e : new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
setTransportError(error);
|
||||
return of(null);
|
||||
};
|
||||
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||
const fatalTransportError$ = new Subject<ElementCallError>();
|
||||
const localTransport$ = localTransportWithErrors$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error
|
||||
? e
|
||||
: new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
fatalTransportError$.next(error);
|
||||
return NEVER; // Make this Observable swallow the error
|
||||
}),
|
||||
);
|
||||
|
||||
async function checkDelegationSupport(
|
||||
endpointUrl: string,
|
||||
@@ -303,58 +305,40 @@ export const createLocalMembership$ = ({
|
||||
|
||||
// The transport that we will advertise in our membership, paired with info as
|
||||
// to whether delayed event delegation is supported
|
||||
const joinParams$ = localTransport.advertised$.pipe(
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
switchMap((transport) => {
|
||||
if (transport === null) return of(null);
|
||||
const transportSupportsDelegation = checkDelegationSupport(
|
||||
transport.livekit_service_url + "/delegate_delayed_leave",
|
||||
`transport ${transport.livekit_service_url}`,
|
||||
);
|
||||
return or$(
|
||||
from(homeserverSupportsDelegation),
|
||||
from(transportSupportsDelegation),
|
||||
).pipe(
|
||||
map((delegationSupported) => ({ transport, delegationSupported })),
|
||||
startWith(null),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||
const activeTransport$ = scope.behavior(
|
||||
combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
|
||||
map(([active, advertised]) => {
|
||||
// Our policy is to not publish to another transport if our prefered transport is miss-configured
|
||||
if (advertised == null) return null;
|
||||
|
||||
return active?.transport ?? null;
|
||||
const joinParams$ = scope.behavior(
|
||||
localTransport$.pipe(
|
||||
switchMap(async ({ transport }) => {
|
||||
const transportSupportsDelegation = checkDelegationSupport(
|
||||
transport.livekit_service_url + "/delegate_delayed_leave",
|
||||
`transport ${transport.livekit_service_url}`,
|
||||
);
|
||||
return {
|
||||
transport,
|
||||
delegationSupported:
|
||||
(await homeserverSupportsDelegation) ||
|
||||
(await transportSupportsDelegation),
|
||||
};
|
||||
}),
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
// Drop Epoch data here since we will not combine this anymore
|
||||
const localConnection$ = scope.behavior(
|
||||
combineLatest([
|
||||
connectionManager.connectionManagerData$,
|
||||
activeTransport$,
|
||||
localTransport$,
|
||||
]).pipe(
|
||||
map(([{ value: connectionData }, localTransport]) => {
|
||||
if (localTransport === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionData.getConnectionForTransport(localTransport);
|
||||
}),
|
||||
map(([{ value: connectionData }, { transport }]) =>
|
||||
connectionData.getConnectionForTransport(transport),
|
||||
),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
// Tracks error that happen when creating the local tracks.
|
||||
@@ -490,18 +474,6 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
};
|
||||
|
||||
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setTransportError = (e: ElementCallError): void => {
|
||||
if (fatalTransportError$.value !== null) {
|
||||
logger.error("Multiple Transport Errors:", e);
|
||||
} else {
|
||||
fatalTransportError$.next(e);
|
||||
}
|
||||
};
|
||||
|
||||
const localConnectionState$ = localConnection$.pipe(
|
||||
switchMap((connection) => (connection ? connection.state$ : of(null))),
|
||||
);
|
||||
@@ -509,38 +481,29 @@ export const createLocalMembership$ = ({
|
||||
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
|
||||
combineLatest([
|
||||
localConnectionState$,
|
||||
activeTransport$,
|
||||
joinAndPublishRequested$,
|
||||
from(trackStartRequested.promise).pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
localConnectionState,
|
||||
localTransport,
|
||||
shouldPublish,
|
||||
shouldStartTracks,
|
||||
]) => {
|
||||
if (!localTransport) return null;
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
map(([localConnectionState, shouldPublish, shouldStartTracks]) => {
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
},
|
||||
),
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
}),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
@@ -554,30 +517,36 @@ export const createLocalMembership$ = ({
|
||||
};
|
||||
|
||||
const localMemberState$ = scope.behavior<LocalMemberState>(
|
||||
combineLatest([
|
||||
mediaState$,
|
||||
homeserverConnected.rtsSession$,
|
||||
fatalMatrixError$,
|
||||
fatalTransportError$,
|
||||
publishError$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
mediaState,
|
||||
rtcSessionStatus,
|
||||
fatalMatrixError,
|
||||
fatalTransportError,
|
||||
publishError,
|
||||
]) => {
|
||||
if (fatalTransportError !== null) return fatalTransportError;
|
||||
// `mediaState` will be 'null' until the transport/connection appears.
|
||||
if (mediaState && rtcSessionStatus)
|
||||
return {
|
||||
matrix: fatalMatrixError ?? rtcSessionStatus,
|
||||
media: publishError ?? mediaState,
|
||||
};
|
||||
return TransportState.Waiting;
|
||||
},
|
||||
concat(
|
||||
// Waiting until…
|
||||
of(TransportState.Waiting),
|
||||
race(
|
||||
// either there is a fatal transport error
|
||||
fatalTransportError$,
|
||||
// or the transport is available.
|
||||
localTransport$.pipe(
|
||||
switchMap(() =>
|
||||
// Once available, track session/media state.
|
||||
combineLatest([
|
||||
mediaState$,
|
||||
homeserverConnected.rtsSession$,
|
||||
fatalMatrixError$,
|
||||
publishError$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
mediaState,
|
||||
rtcSessionStatus,
|
||||
fatalMatrixError,
|
||||
publishError,
|
||||
]) => ({
|
||||
matrix: fatalMatrixError ?? rtcSessionStatus,
|
||||
media: publishError ?? mediaState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -685,7 +654,7 @@ export const createLocalMembership$ = ({
|
||||
});
|
||||
});
|
||||
|
||||
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
|
||||
// Join and leave the session as needed
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
|
||||
async ([joinParams, shouldConnect]) => {
|
||||
@@ -710,7 +679,7 @@ export const createLocalMembership$ = ({
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
try {
|
||||
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
||||
await matrixRTCSession.leaveRoomSession(1000);
|
||||
await matrixRTCSession.leave(1000);
|
||||
} catch (e) {
|
||||
logger.error("Error leaving RTC session", e);
|
||||
}
|
||||
@@ -993,7 +962,7 @@ interface EnterRTCSessionOptions {
|
||||
export function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
{
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
@@ -1014,21 +983,6 @@ export function enterRTCSession(
|
||||
matrix_rtc_session: sessionConfig,
|
||||
} = Config.get();
|
||||
const retryInterval = sessionConfig.network_error_retry_ms;
|
||||
const multiSFU =
|
||||
matrixRTCMode === MatrixRTCMode.Compatibility ||
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
|
||||
|
||||
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
|
||||
// we add the livekit alias to the transport.
|
||||
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
|
||||
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
|
||||
backwardCompatibleTransport = transport;
|
||||
} else {
|
||||
backwardCompatibleTransport = {
|
||||
livekit_alias: rtcSession.room.roomId,
|
||||
...transport,
|
||||
};
|
||||
}
|
||||
|
||||
// Set maximumNetworkErrorRetryCount such that we will consider the client
|
||||
// disconnected as soon as either it fails to sync for longer than the grace
|
||||
@@ -1041,24 +995,19 @@ export function enterRTCSession(
|
||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||
|
||||
rtcSession.joinRTCSession(
|
||||
ownMembershipIdentity,
|
||||
multiSFU ? [] : [backwardCompatibleTransport],
|
||||
multiSFU ? backwardCompatibleTransport : undefined,
|
||||
{
|
||||
notificationType,
|
||||
callIntent,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs:
|
||||
delayedLeaveTimings.restart_timeout_ms,
|
||||
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
||||
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
},
|
||||
);
|
||||
rtcSession.join(ownMembershipIdentity, [transport], {
|
||||
notificationType,
|
||||
callIntent,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs:
|
||||
delayedLeaveTimings.restart_timeout_ms,
|
||||
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
||||
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,19 +14,10 @@ import {
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import { createLocalTransport$ } from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { mockConfig, ownMemberMock } from "../../../utils/test";
|
||||
import { getLocalTransport } from "./LocalTransport";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -47,118 +38,79 @@ describe("LocalTransport", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
roomId: "!room:example.org",
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
|
||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||
// Provide a valid config so makeTransportInternal resolves a transport
|
||||
const scope = new ObservableScope();
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const resolver = Promise.withResolvers<void>();
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
|
||||
async () => {
|
||||
await resolver.promise;
|
||||
throw new FailToGetOpenIdToken(new Error("no openid"));
|
||||
},
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
|
||||
new FailToGetOpenIdToken(new Error("no openid")),
|
||||
);
|
||||
const observations: unknown[] = [];
|
||||
const errors: Error[] = [];
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
active$.subscribe(
|
||||
(o) => observations.push(o),
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
resolver.resolve();
|
||||
await flushPromises();
|
||||
|
||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||
expect(observations).toStrictEqual([null]);
|
||||
expect(errors).toStrictEqual([expectedError]);
|
||||
expect(() => advertised$.value).toThrow(expectedError);
|
||||
expect(() => active$.value).toThrow(expectedError);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
roomId: "!example_room_id",
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
|
||||
});
|
||||
|
||||
it("emits preferred transport after OpenID resolves", async () => {
|
||||
it("returns preferred transport", async () => {
|
||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
|
||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.({
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
|
||||
url: "https://lk.example.org",
|
||||
jwt: "jwt",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||
});
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
|
||||
expect(
|
||||
await getLocalTransport({
|
||||
roomId: "!room:example.org",
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "jwt",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -168,22 +120,19 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
|
||||
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
|
||||
|
||||
describe("transport configuration mechanisms", () => {
|
||||
let localTransportOpts: LocalTransportProps & {
|
||||
client: MockedObject<LocalTransportProps["client"]>;
|
||||
};
|
||||
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
|
||||
localTransportOpts = {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope: testScope(),
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -192,10 +141,6 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
};
|
||||
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -206,19 +151,15 @@ describe("LocalTransport", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -230,12 +171,11 @@ describe("LocalTransport", () => {
|
||||
|
||||
it("supports getting transport via user settings", async () => {
|
||||
customLivekitUrl.setValue("https://lk.example.org");
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(active$.value).toStrictEqual({
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
@@ -253,19 +193,15 @@ describe("LocalTransport", () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -279,38 +215,31 @@ describe("LocalTransport", () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
openIdResolver.reject(
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
|
||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||
);
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
|
||||
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
|
||||
expect.any(FailToGetOpenIdToken),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no options are available", async () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,11 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
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 { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
@@ -27,19 +21,12 @@ import {
|
||||
type SFUConfig,
|
||||
type OpenIDClientParts,
|
||||
} from "../../../livekit/openIDSFU.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { customLivekitUrl } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
|
||||
|
||||
/*
|
||||
* It figures out “which LiveKit focus URL/alias the local user should use,”
|
||||
* and ensures the SFU path is primed before advertising that choice.
|
||||
*/
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
@@ -47,135 +34,65 @@ interface Props {
|
||||
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 LocalTransportWithSFUConfig {
|
||||
transport: LivekitTransportConfig;
|
||||
export interface LocalTransport {
|
||||
transport: LivekitTransport;
|
||||
sfuConfig: SFUConfig;
|
||||
}
|
||||
|
||||
export function isLocalTransportWithSFUConfig(
|
||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||
): obj is LocalTransportWithSFUConfig {
|
||||
export function isLocalTransport(
|
||||
obj: LivekitTransport | LocalTransport,
|
||||
): obj is LocalTransport {
|
||||
return "transport" in obj && "sfuConfig" in obj;
|
||||
}
|
||||
|
||||
export interface LocalTransport {
|
||||
/**
|
||||
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||
* yet fetched/validated.
|
||||
*/
|
||||
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||
/**
|
||||
* The transport to connect to and publish media on. `null` when not yet known
|
||||
* or available.
|
||||
*/
|
||||
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the JWT service and determines the transports that the local member should use.
|
||||
* Connects to the JWT service and determines the transport that the local member should use.
|
||||
*
|
||||
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||
* (which is expected for non sticky event based rtc member events)
|
||||
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||
* @returns The transport to advertise in our MatrixRTC membership and publish media on.
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
export const createLocalTransport$ = ({
|
||||
scope,
|
||||
memberships$,
|
||||
export async function getLocalTransport({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
matrixRTCMode,
|
||||
}: Props): LocalTransport => {
|
||||
}: Props): Promise<LocalTransport> {
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
resolvedConfig: Config.get(),
|
||||
logger: logger,
|
||||
});
|
||||
const customUrl = customLivekitUrl.value$.value;
|
||||
|
||||
// Get the preferred transport from the current deployment.
|
||||
const discoveredTransport$ = from(
|
||||
transportDiscovery.discoverPreferredTransport(),
|
||||
);
|
||||
// Respect the user's custom URL, if set
|
||||
const transport: LivekitTransport | null = customUrl
|
||||
? { type: "livekit", livekit_service_url: customUrl }
|
||||
: await discovery.discoverPreferredTransport();
|
||||
|
||||
const preferredConfig$ = customLivekitUrl.value$.pipe(
|
||||
switchMap((customUrl) => {
|
||||
if (customUrl) {
|
||||
return of({
|
||||
type: "livekit",
|
||||
livekit_service_url: customUrl,
|
||||
} as LivekitTransportConfig);
|
||||
} else {
|
||||
return discoveredTransport$;
|
||||
}
|
||||
}),
|
||||
map((config) => {
|
||||
if (!config) {
|
||||
// Bubbled up from the preferredConfig$ observable.
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
}
|
||||
return config;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
);
|
||||
if (transport === null)
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
|
||||
const preferredTransport$ = preferredConfig$.pipe(
|
||||
switchMap(async (transport) => {
|
||||
try {
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
matrixRTCMode,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
map((t) => t.transport),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
null,
|
||||
),
|
||||
active$: scope.behavior(preferredTransport$, null),
|
||||
};
|
||||
};
|
||||
try {
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
matrixRTCMode,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to ensure the user can authenticate with the SFU.
|
||||
@@ -193,13 +110,13 @@ export const createLocalTransport$ = ({
|
||||
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
||||
*/
|
||||
async function doOpenIdAndJWTFromUrl(
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
matrixRTCMode: MatrixRTCMode,
|
||||
membership: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
|
||||
logger?: Logger,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
): Promise<LocalTransport> {
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
membership,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
|
||||
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
||||
|
||||
const backendTransport: LivekitTransportConfig = {
|
||||
const backendTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://backend.example.org",
|
||||
};
|
||||
|
||||
const configTransport: LivekitTransportConfig = {
|
||||
const configTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
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.
|
||||
*/
|
||||
import {
|
||||
isLivekitTransportConfig,
|
||||
type LivekitTransportConfig,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -40,7 +40,7 @@ export class RtcTransportAutoDiscovery {
|
||||
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
||||
}
|
||||
|
||||
public async discoverPreferredTransport(): Promise<LivekitTransportConfig | null> {
|
||||
public async discoverPreferredTransport(): Promise<LivekitTransport | null> {
|
||||
// 1) backend transports
|
||||
const backendTransport = await this.tryBackendTransports();
|
||||
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.
|
||||
* @private
|
||||
*/
|
||||
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
|
||||
private async tryBackendTransports(): Promise<LivekitTransport | null> {
|
||||
const client = this.client;
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
@@ -75,7 +75,7 @@ export class RtcTransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
const first = transportList.find(isLivekitTransportConfig);
|
||||
const first = transportList.find(isLivekitTransport);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
@@ -90,7 +90,7 @@ export class RtcTransportAutoDiscovery {
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryConfigTransport(): LivekitTransportConfig | null {
|
||||
private tryConfigTransport(): LivekitTransport | null {
|
||||
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
||||
if (url) {
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { type IOpenIDToken } from "matrix-js-sdk";
|
||||
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 {
|
||||
Connection,
|
||||
@@ -57,7 +57,7 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
|
||||
const livekitFocus: LivekitTransportConfig = {
|
||||
const livekitFocus: LivekitTransport = {
|
||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -228,9 +228,7 @@ describe("Start connection states", () => {
|
||||
capturedState = capturedStates.pop();
|
||||
if (capturedState instanceof Error) {
|
||||
expect(capturedState.message).toEqual("Something went wrong");
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||
}
|
||||
@@ -290,9 +288,7 @@ describe("Start connection states", () => {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||
}
|
||||
@@ -357,9 +353,7 @@ describe("Start connection states", () => {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
"Failed to connect to livekit",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type Track,
|
||||
type TrackPublication,
|
||||
} 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 { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
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 */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** The media transport to connect to. */
|
||||
transport: LivekitTransportConfig;
|
||||
transport: LivekitTransport;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
client: OpenIDClientParts;
|
||||
/** The room ID this connection is associated with. */
|
||||
@@ -107,7 +107,7 @@ export class Connection {
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: LivekitTransportConfig;
|
||||
public readonly transport: LivekitTransport;
|
||||
|
||||
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)
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||
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 { Connection } from "./Connection.ts";
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
export interface ConnectionFactory {
|
||||
createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -103,7 +103,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
*/
|
||||
public createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
|
||||
@@ -6,8 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
@@ -20,16 +20,16 @@ import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
|
||||
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
|
||||
import { constant, type Behavior } from "../../Behavior.ts";
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
|
||||
// Some test constants
|
||||
|
||||
const TRANSPORT_1: LivekitTransportConfig = {
|
||||
const TRANSPORT_1: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
|
||||
const TRANSPORT_2: LivekitTransportConfig = {
|
||||
const TRANSPORT_2: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
};
|
||||
@@ -47,7 +47,7 @@ beforeEach(() => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
const mockConnection = {
|
||||
transport,
|
||||
remoteParticipants$: new BehaviorSubject([]),
|
||||
@@ -74,7 +74,7 @@ describe("connections$ stream", () => {
|
||||
const { connectionManagerData$ } = createConnectionManager$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport$: constant(null),
|
||||
localTransport$: NEVER,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||
}),
|
||||
@@ -115,7 +115,7 @@ describe("connections$ stream", () => {
|
||||
const { connectionManagerData$ } = createConnectionManager$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport$: constant(null),
|
||||
localTransport$: NEVER,
|
||||
remoteTransports$: behavior("abcdef", {
|
||||
a: new Epoch([TRANSPORT_1], 0),
|
||||
b: new Epoch([TRANSPORT_1], 1),
|
||||
@@ -162,7 +162,7 @@ describe("connections$ stream", () => {
|
||||
const { connectionManagerData$ } = createConnectionManager$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport$: constant(null),
|
||||
localTransport$: NEVER,
|
||||
remoteTransports$: behavior("abc", {
|
||||
a: new Epoch([TRANSPORT_1], 0),
|
||||
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
|
||||
@@ -225,7 +225,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
// Used in test to control fake connections' remoteParticipants$ streams
|
||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||
|
||||
function keyForTransport(transport: LivekitTransportConfig): string {
|
||||
function keyForTransport(transport: LivekitTransport): string {
|
||||
return `${transport.livekit_service_url}`;
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
fakeRemoteParticipantsStreams = new Map();
|
||||
|
||||
function getRemoteParticipantsFor(
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
return (
|
||||
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||
@@ -245,7 +245,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||
RemoteParticipant[]
|
||||
>([]);
|
||||
@@ -297,7 +297,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
const { connectionManagerData$ } = createConnectionManager$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport$: constant(null),
|
||||
localTransport$: NEVER,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||
}),
|
||||
|
||||
@@ -6,8 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
startWith,
|
||||
catchError,
|
||||
NEVER,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -19,8 +28,8 @@ import { generateItemsWithEpoch } from "../../../utils/observable.ts";
|
||||
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import {
|
||||
isLocalTransportWithSFUConfig,
|
||||
type LocalTransportWithSFUConfig,
|
||||
isLocalTransport,
|
||||
type LocalTransport,
|
||||
} from "../localMember/LocalTransport.ts";
|
||||
import { type SFUConfig } from "../../../livekit/openIDSFU.ts";
|
||||
|
||||
@@ -47,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.
|
||||
// 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;
|
||||
@@ -58,13 +67,13 @@ export class ConnectionManagerData {
|
||||
}
|
||||
|
||||
public getConnectionForTransport(
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
): Connection | null {
|
||||
return this.store.get(this.getKey(transport))?.connection ?? null;
|
||||
}
|
||||
|
||||
public getParticipantsForTransport(
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
): RemoteParticipant[] {
|
||||
const key = this.getKey(transport);
|
||||
const existing = this.store.get(key);
|
||||
@@ -78,8 +87,8 @@ export class ConnectionManagerData {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
localTransport$: Observable<LocalTransport>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
|
||||
logger: Logger;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
@@ -121,6 +130,14 @@ export function createConnectionManager$({
|
||||
const logger = parentLogger.getChild("[ConnectionManager]");
|
||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||
|
||||
const localTransportAsArray$ = localTransport$.pipe(
|
||||
// LocalMember already surfaces local transport errors properly in the UI,
|
||||
// here we can just swallow them
|
||||
catchError(() => NEVER),
|
||||
map((transport) => [transport]),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
/**
|
||||
* All transports currently managed by the ConnectionManager.
|
||||
*
|
||||
@@ -130,16 +147,12 @@ export function createConnectionManager$({
|
||||
* externally this is modified via `registerTransports()`.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<
|
||||
Epoch<(LivekitTransportConfig | LocalTransportWithSFUConfig)[]>
|
||||
Epoch<(LivekitTransport | LocalTransport)[]>
|
||||
> = scope.behavior(
|
||||
combineLatest([remoteTransports$, localTransport$]).pipe(
|
||||
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
|
||||
// Combine local and remote transports into one transport array
|
||||
// and set the forceOldJwtEndpoint property on the local transport
|
||||
map(([remoteTransports, localTransport]) => {
|
||||
let localTransportAsArray: LocalTransportWithSFUConfig[] = [];
|
||||
if (localTransport) {
|
||||
localTransportAsArray = [localTransport];
|
||||
}
|
||||
map(([localTransportAsArray, remoteTransports]) => {
|
||||
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
|
||||
const remoteWithoutLocal = dedupedRemote.filter(
|
||||
(transport) =>
|
||||
@@ -170,8 +183,9 @@ export function createConnectionManager$({
|
||||
"ConnectionManager connections$",
|
||||
function* (transports) {
|
||||
for (const transport of transports) {
|
||||
if (isLocalTransportWithSFUConfig(transport)) {
|
||||
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
|
||||
if (isLocalTransport(transport)) {
|
||||
// This is the local transport; only the `LocalTransport`
|
||||
// interface has a `sfuConfig` field.
|
||||
yield {
|
||||
keys: [
|
||||
transport.transport.livekit_service_url,
|
||||
@@ -257,7 +271,7 @@ export function createConnectionManager$({
|
||||
return { connectionManagerData$ };
|
||||
}
|
||||
|
||||
function removeDuplicateTransports<T extends LivekitTransportConfig>(
|
||||
function removeDuplicateTransports<T extends LivekitTransport>(
|
||||
transports: T[],
|
||||
): T[] {
|
||||
return transports.reduce((acc, transport) => {
|
||||
|
||||
@@ -42,13 +42,11 @@ const fallbackMemberId = (userId: string, deviceId: string): string =>
|
||||
const transportA: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
livekit_alias: "!alias:example.org",
|
||||
};
|
||||
|
||||
const transportB: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
livekit_alias: "!alias:sample.com",
|
||||
};
|
||||
|
||||
const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", {
|
||||
@@ -126,7 +124,7 @@ function createEpochedMemberships$(m$: Observable<CallMembership[]>): {
|
||||
const membershipsWithTransport$ = memberships$.pipe(
|
||||
mapEpoch((members) => {
|
||||
return members.map((m) => {
|
||||
const tr = m.getTransport(m);
|
||||
const tr = m.getTransport();
|
||||
return {
|
||||
membership: m,
|
||||
transport:
|
||||
@@ -152,7 +150,7 @@ test("should signal participant on a connection that is publishing", async () =>
|
||||
);
|
||||
|
||||
const connection = {
|
||||
transport: bobMembership.getTransport(bobMembership),
|
||||
transport: bobMembership.getTransport(),
|
||||
} as unknown as Connection;
|
||||
const dataWithPublisher = new ConnectionManagerData();
|
||||
dataWithPublisher.add(connection, [
|
||||
@@ -195,7 +193,7 @@ test("should signal participant on a connection that is not publishing", async (
|
||||
);
|
||||
|
||||
const connection = {
|
||||
transport: bobMembership.getTransport(bobMembership),
|
||||
transport: bobMembership.getTransport(),
|
||||
} as unknown as Connection;
|
||||
const dataWithPublisher = new ConnectionManagerData();
|
||||
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 CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, filter, map } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -60,7 +60,7 @@ export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
>;
|
||||
connectionManager: IConnectionManager;
|
||||
localUser: { deviceId: string; userId: string };
|
||||
@@ -165,7 +165,7 @@ export function createRemoteMatrixLivekitMembers$({
|
||||
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
||||
|
||||
// TODO add this to the JS-SDK
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransportConfig>(
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransport>(
|
||||
t1: T | null,
|
||||
t2: T | null,
|
||||
): boolean {
|
||||
|
||||
@@ -6,11 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi, expect, beforeEach, afterEach } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import EventEmitter from "events";
|
||||
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 {
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "./MatrixLivekitMembers.ts";
|
||||
import { createConnectionManager$ } from "./ConnectionManager.ts";
|
||||
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
||||
import { constant } from "../../Behavior.ts";
|
||||
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||
|
||||
// Test the integration of ConnectionManager and MatrixLivekitMerger
|
||||
@@ -124,7 +123,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
const connectionManager = createConnectionManager$({
|
||||
scope: testScope,
|
||||
connectionFactory: ecConnectionFactory,
|
||||
localTransport$: constant(null),
|
||||
localTransport$: NEVER,
|
||||
remoteTransports$: membershipsAndTransports.transports$,
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -150,7 +149,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
a: expect.toSatisfy((co) =>
|
||||
areLivekitTransportsEqual(
|
||||
co.transport,
|
||||
bobMembership.transports[0]! as LivekitTransportConfig,
|
||||
bobMembership.transports[0]! as LivekitTransport,
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -187,7 +186,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
carlMembership.transports[0]! as LivekitTransportConfig,
|
||||
carlMembership.transports[0]! as LivekitTransport,
|
||||
),
|
||||
).toBe(true);
|
||||
return true;
|
||||
@@ -217,7 +216,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
daveMembership.transports[0]! as LivekitTransportConfig,
|
||||
daveMembership.transports[0]! as LivekitTransport,
|
||||
),
|
||||
).toBe(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.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { of } from "rxjs";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -15,9 +14,22 @@ vi.mock("../Platform", () => ({
|
||||
},
|
||||
isFirefox: (): boolean => false,
|
||||
}));
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: () => of([]),
|
||||
}));
|
||||
const observers = vi.hoisted(
|
||||
() => 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 { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||
@@ -57,3 +69,125 @@ describe("MediaDevices audio output", () => {
|
||||
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 {
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
isLivekitTransportConfig,
|
||||
isLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { fromEvent } from "rxjs";
|
||||
|
||||
@@ -22,48 +22,41 @@ import {
|
||||
} from "./ObservableScope";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
/**
|
||||
* Tracks the transports used by ourselves, plus all other MatrixRTC session
|
||||
* members.
|
||||
*/
|
||||
export const membershipsAndTransports$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
): {
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
>;
|
||||
transports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
transports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
} => {
|
||||
/**
|
||||
* Lists the transports used by ourselves, plus all other MatrixRTC session
|
||||
* members.
|
||||
* For completeness this also lists the preferred transport and
|
||||
* whether we are in multi-SFU mode or sticky events mode.
|
||||
* `advertisedTransport$` reads these values together, so bundling them avoids inconsistent state or
|
||||
* excessive updates when using RxJS.
|
||||
*/
|
||||
const membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{
|
||||
membership: CallMembership;
|
||||
transport: LivekitTransportConfig | undefined;
|
||||
transport: LivekitTransport | undefined;
|
||||
}[]
|
||||
>
|
||||
> = scope.behavior(
|
||||
memberships$.pipe(
|
||||
mapEpoch((memberships) => {
|
||||
return memberships.map((membership) => {
|
||||
const oldestMembership = memberships[0] ?? membership;
|
||||
const transport = membership.getTransport(oldestMembership);
|
||||
const transport = membership.getTransport();
|
||||
return {
|
||||
membership,
|
||||
transport: isLivekitTransportConfig(transport)
|
||||
? transport
|
||||
: undefined,
|
||||
transport: isLivekitTransport(transport) ? transport : undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const transports$: Behavior<Epoch<LivekitTransportConfig[]>> = scope.behavior(
|
||||
const transports$: Behavior<Epoch<LivekitTransport[]>> = scope.behavior(
|
||||
membershipsWithTransport$.pipe(
|
||||
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
mockRemoteScreenShare,
|
||||
} from "../../utils/test";
|
||||
import { constant } from "../Behavior";
|
||||
import { showConnectionStats } from "../../settings/settings";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
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");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
@@ -54,10 +53,10 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
audioStreamStats$: Observable<
|
||||
audioStreamStats$: Behavior<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
videoStreamStats$: Observable<
|
||||
videoStreamStats$: Behavior<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
/**
|
||||
@@ -94,6 +93,21 @@ export function createBaseUserMedia(
|
||||
);
|
||||
const toggleCropVideo$ = new Subject<void>();
|
||||
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 {
|
||||
...createMemberMedia(scope, {
|
||||
@@ -130,25 +144,8 @@ export function createBaseUserMedia(
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
audioStreamStats$: combineLatest([
|
||||
participant$,
|
||||
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);
|
||||
}),
|
||||
),
|
||||
audioStreamStats$: streamStats$(scope, Track.Source.Microphone),
|
||||
videoStreamStats$: streamStats$(scope, Track.Source.Camera),
|
||||
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
Menu,
|
||||
Text,
|
||||
} from "@vector-im/compound-web";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import { Slider } from "../Slider";
|
||||
@@ -133,12 +132,8 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
||||
const { t } = useTranslation();
|
||||
const video = useBehavior(vm.video$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioStreamStats = useBehavior(vm.audioStreamStats$);
|
||||
const videoStreamStats = useBehavior(vm.videoStreamStats$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type Observable, map } from "rxjs";
|
||||
import { useObservableRef } from "observable-hooks";
|
||||
import { BehaviorSubject, type Observable, map } from "rxjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
@@ -416,7 +415,13 @@ export const SpotlightTile: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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 maximised = useBehavior(vm.maximised$);
|
||||
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 { useState, useEffect } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import {
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
@@ -17,6 +16,7 @@ import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext";
|
||||
import { type PrefetchedSounds } from "./soundUtils";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import * as controls from "./controls";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
|
||||
/**
|
||||
* Play a sound though a given AudioContext. Will take
|
||||
@@ -168,7 +168,7 @@ export function useAudioContext<S extends string>(
|
||||
};
|
||||
}, [props.sounds, props.latencyHint]);
|
||||
|
||||
const audioOutputId = useObservableEagerState(
|
||||
const audioOutputId = useBehavior(
|
||||
useMediaDevices().audioOutput.selected$,
|
||||
)?.id;
|
||||
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$;
|
||||
}
|
||||
@@ -9,30 +9,9 @@ import { expect, test } from "vitest";
|
||||
import { type Observable, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "./test";
|
||||
import { or$, filterBehavior, generateItems, pauseWhen } from "./observable";
|
||||
import { filterBehavior, generateItems, pauseWhen } from "./observable";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
const yesNo = {
|
||||
y: true,
|
||||
n: false,
|
||||
};
|
||||
|
||||
test("or$", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const input1Marbles = "ny--n--";
|
||||
const input2Marbles = "n-y--n-";
|
||||
const input3Marbles = "n--y--n";
|
||||
const outputMarbles = "nyyyyyn";
|
||||
expectObservable(
|
||||
or$(
|
||||
behavior(input1Marbles, yesNo),
|
||||
behavior(input2Marbles, yesNo),
|
||||
behavior(input3Marbles, yesNo),
|
||||
),
|
||||
).toBe(outputMarbles, yesNo);
|
||||
});
|
||||
});
|
||||
|
||||
test("pauseWhen", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const inputMarbles = " abcdefgh-i-jk-";
|
||||
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type Observable,
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
defer,
|
||||
filter,
|
||||
@@ -113,14 +112,6 @@ export function getValue<T>(state$: Observable<T>): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Observable that has a value of true whenever some of its inputs
|
||||
* are true.
|
||||
*/
|
||||
export function or$(...inputs: Observable<boolean>[]): Observable<boolean> {
|
||||
return combineLatest(inputs, (...flags) => flags.some((flag) => flag));
|
||||
}
|
||||
|
||||
/**
|
||||
* RxJS operator that pauses all changes in the input value whenever a Behavior
|
||||
* is true. When the Behavior returns to being false, the most recently
|
||||
|
||||
+98
-3
@@ -8,6 +8,7 @@ import { map, type Observable, of, type SchedulerLike } from "rxjs";
|
||||
import { type RunHelpers, TestScheduler } from "rxjs/testing";
|
||||
import {
|
||||
expect,
|
||||
type Mock,
|
||||
type MockedObject,
|
||||
type MockInstance,
|
||||
onTestFinished,
|
||||
@@ -211,7 +212,6 @@ export function mockEmitter<T>(): EmitterMock<T> {
|
||||
export const exampleTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
livekit_alias: "!alias:example.org",
|
||||
};
|
||||
|
||||
export const exampleSfuConfig: SFUConfig = {
|
||||
@@ -472,7 +472,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
counters: {},
|
||||
};
|
||||
|
||||
public leaveRoomSession: ReturnType<typeof vitest.fn> = vitest
|
||||
public leave: ReturnType<typeof vitest.fn> = vitest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
@@ -531,7 +531,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
||||
}
|
||||
|
||||
public async joinRTCSession(): Promise<void> {
|
||||
public async join(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -590,3 +590,98 @@ export class MockConnection extends Connection {
|
||||
public async start(): 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { defineConfig, searchForWorkspaceRoot } from "vite";
|
||||
import { realpathSync } from "node:fs";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { vitePluginsConfig } from "./vite.config";
|
||||
import { vitePluginsConfig } from "./vite.config.ts";
|
||||
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
|
||||
|
||||
// Serves the harness under `component/dev`, which embeds Element Call as a
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { vitePluginsConfig } from "./vite.config";
|
||||
import { vitePluginsConfig } from "./vite.config.ts";
|
||||
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
|
||||
|
||||
// Config for Element Call as a React component, to be imported by an
|
||||
@@ -49,13 +49,28 @@ export default defineConfig(({ mode }) => {
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
formats: ["es" as const],
|
||||
entry: "./component/index.tsx",
|
||||
fileName: "element-call",
|
||||
entry: {
|
||||
// The component
|
||||
"element-call": "./component/index.tsx",
|
||||
// What a host needs to talk about it, without it (see component/api.ts).
|
||||
// Built as an entry point of its own so that importing it does not
|
||||
// load the component: the bundler gives it a chunk that reaches only
|
||||
// what it imports.
|
||||
api: "./component/api.ts",
|
||||
},
|
||||
fileName: (_format, entryName) => `${entryName}.js`,
|
||||
// The one stylesheet (see cssCodeSplit) keeps the name it had when the
|
||||
// component was the only entry
|
||||
cssFileName: "element-call",
|
||||
},
|
||||
rollupOptions: {
|
||||
// The host already has these, and a second copy of any of them does not
|
||||
// merely bloat the bundle: React would hold two sets of hooks, and the
|
||||
// Matrix client would run two sync loops.
|
||||
// merely bloat the bundle: React would hold two sets of hooks, the
|
||||
// Matrix client would run two sync loops, and a second Compound would
|
||||
// bring a second stylesheet with class names of its own — so that what
|
||||
// it floats into the host's body, tooltips and menus, would match the
|
||||
// host's Compound stylesheet only when the two happened to be the same
|
||||
// version.
|
||||
//
|
||||
// Every subpath has to be named. Element Call reaches most of the Matrix
|
||||
// SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not
|
||||
@@ -73,7 +88,6 @@ export default defineConfig(({ mode }) => {
|
||||
"react/compiler-runtime",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"livekit-client",
|
||||
"matrix-js-sdk",
|
||||
"matrix-js-sdk/lib/browser-index",
|
||||
"matrix-js-sdk/lib/client",
|
||||
@@ -92,6 +106,8 @@ export default defineConfig(({ mode }) => {
|
||||
"matrix-js-sdk/lib/sync",
|
||||
"matrix-js-sdk/lib/types",
|
||||
"matrix-js-sdk/lib/utils",
|
||||
"@vector-im/compound-web",
|
||||
"@vector-im/compound-design-tokens/assets/web/icons",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import generateFile from "vite-plugin-generate-file";
|
||||
|
||||
import fullConfig from "./vite.config";
|
||||
import fullConfig from "./vite.config.ts";
|
||||
|
||||
const base = "./";
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import { vitePluginsConfig } from "./vite.config";
|
||||
import { vitePluginsConfig } from "./vite.config.ts";
|
||||
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -25,6 +25,7 @@ export default defineConfig((configEnv) =>
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"component/**/*.test.ts",
|
||||
"component/**/*.test.tsx",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user