mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa48f68c | ||
|
|
471c76c0e7 | ||
|
|
4881fd5fc6 | ||
|
|
49c29f160a | ||
|
|
aa8f73e632 | ||
|
|
14c561af8d | ||
|
|
4fd2f66949 | ||
|
|
53e038b09b | ||
|
|
3b7ccb63e2 | ||
|
|
1f07f949b5 | ||
|
|
a60036b3d7 | ||
|
|
2eb3a2fd2d | ||
|
|
8b6adf067e | ||
|
|
b408610277 | ||
|
|
2faa6c3dac | ||
|
|
9b3c3569ec | ||
|
|
a4654d9d00 | ||
|
|
6e5f71c796 | ||
|
|
067f3d3038 | ||
|
|
6d70221fe6 | ||
|
|
427376f78b | ||
|
|
ccfd36923d | ||
|
|
89567726f8 | ||
|
|
9cd111b5f2 | ||
|
|
096aaac9f3 | ||
|
|
8835a6d75d | ||
|
|
bfb868aec3 | ||
|
|
4e8fb6e4cb | ||
|
|
7c53a6be5a | ||
|
|
64b932f128 | ||
|
|
bfad85a3af | ||
|
|
be822adec8 | ||
|
|
4c0f02ef47 | ||
|
|
ca6319f06d | ||
|
|
1d57a8eee1 | ||
|
|
0bb9632b60 | ||
|
|
cbe195d449 | ||
|
|
e800fec74d | ||
|
|
9d0725ad31 | ||
|
|
241412f29b | ||
|
|
d96d00b64a | ||
|
|
cde88a7c93 | ||
|
|
d5733e8274 | ||
|
|
3befe3d34b | ||
|
|
3ad778e7e8 | ||
|
|
9a286032f2 | ||
|
|
363a8feab2 | ||
|
|
bd8c3142a6 | ||
|
|
16c60c8b6b | ||
|
|
63ac1ba03e | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
9140365251 | ||
|
|
81233e5cfb | ||
|
|
e296d88969 | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
6600c891f3 | ||
|
|
33cfd2d8ad | ||
|
|
5a8714805c | ||
|
|
232e3f7ad4 | ||
|
|
10adf6658b | ||
|
|
29742006df | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
5c497cc6a8 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
d5812ddd34 | ||
|
|
cf7c275fb7 | ||
|
|
93b2661513 | ||
|
|
1f84cb9c5c | ||
|
|
4269aaa163 | ||
|
|
bc62bb9565 | ||
|
|
be56bbd05b | ||
|
|
eb44fd545d | ||
|
|
c9f911b7c9 | ||
|
|
3a189d155b | ||
|
|
0211d5c367 | ||
|
|
c88f884fc9 | ||
|
|
9f7c35cd8f | ||
|
|
ac81a7c0ab | ||
|
|
8ebb0b5a90 | ||
|
|
03f81bcd64 | ||
|
|
9f6c8bb434 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
426acd222e |
@@ -102,6 +102,9 @@
|
||||
- name: "backport-candidate"
|
||||
description: "Something that is a candidate for backport to a particular release branch"
|
||||
color: "0B8D85"
|
||||
- name: "Claude-Review"
|
||||
description: "Request code review from Claude"
|
||||
color: "e57157"
|
||||
- name: "customer-retainer"
|
||||
color: "F44A5F"
|
||||
- name: "dependencies"
|
||||
|
||||
@@ -44,7 +44,9 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
- name: Build Element Call
|
||||
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Claude
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
|
||||
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
# The review looks at the individual commits, not just the combined diff
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Review
|
||||
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
|
||||
with:
|
||||
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
|
||||
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
|
||||
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
|
||||
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
|
||||
# The token used here must grant read access to code, issues, and metadata as well as
|
||||
# read and write access to pull requests.
|
||||
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
track_progress: true
|
||||
prompt: |
|
||||
Review this pull request under the following (and no further) angles:
|
||||
|
||||
1. Scope:
|
||||
|
||||
- Does the PR do one thing?
|
||||
- Is the PR free of unrelated fixes and drive-by refactorings?
|
||||
- If the PR is large, could it reasonably be split into smaller PRs
|
||||
that still deliver value?
|
||||
|
||||
2. Commits:
|
||||
|
||||
- Is the work split into self-contained commits of reasonable size?
|
||||
- Are the commits arranged in a sensible order?
|
||||
- Have required refactorings been split out into separate commits?
|
||||
- Is the history free of "wip", checkpoint and fixup-style commits
|
||||
which only correct an earlier commit in the same PR?
|
||||
|
||||
3. Code:
|
||||
|
||||
- Are the code changes functionally correct?
|
||||
- Do the changes introduce new security vulnerabilities or metadata
|
||||
leaks?
|
||||
- Is the new code performant enough?
|
||||
- Don't check anything that is already validated via other GitHub
|
||||
workflows like linting, formatting or tests passing.
|
||||
|
||||
Read the commits themselves with `git log` and `git diff` against
|
||||
the base branch of the PR.
|
||||
|
||||
Drop any findings that are outside the above review angles or that don't
|
||||
require resolution. Grade the severity of each finding as either "🟡 low",
|
||||
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
|
||||
finding.
|
||||
|
||||
Report the graded findings and resolutions from the "Code" angle as inline
|
||||
comments. Add a footer "_-- Finding from automated review_" to each inline
|
||||
comment.
|
||||
|
||||
Finally, report your overall result as PR comment. Start your message with
|
||||
"# Automated review". Then add one section per review angle with:
|
||||
|
||||
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
|
||||
- A bullet list summarizing the remaining non-inlined graded findings and
|
||||
resolutions (as nested bullets).
|
||||
|
||||
Be brief and concise in all your communication. Do not mention what you have
|
||||
not covered and don't bring up issues that have already been fixed.
|
||||
|
||||
Use Markdown links for anything that can be navigated to on GitHub such
|
||||
as files, commits, issues, pull requests, workflow runs, etc.
|
||||
claude_args: |
|
||||
--model claude-fable-5-1
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
|
||||
--max-turns 50
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Lint, format & type check
|
||||
on:
|
||||
pull_request: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
@@ -9,6 +15,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -19,7 +27,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Formatting
|
||||
run: "pnpm run format:check"
|
||||
- name: i18n
|
||||
|
||||
@@ -3,6 +3,25 @@ on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
disable_coverage:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip generating and uploading coverage reports
|
||||
disable_e2e_tests:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip the end-to-end tests
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
@@ -11,6 +30,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -21,7 +42,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
@@ -35,8 +59,11 @@ jobs:
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Vitest
|
||||
run: "pnpm run test:coverage"
|
||||
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
|
||||
env:
|
||||
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
|
||||
- name: Upload to codecov
|
||||
if: ${{ !inputs.disable_coverage }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -45,11 +72,14 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -59,7 +89,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
|
||||
@@ -41,3 +41,6 @@ yarn-error.log
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Nested checkout made by scripts/layered.sh
|
||||
/matrix-js-sdk/
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_mode": "compatibility",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -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:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
|
||||
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:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,21 @@ git config --local core.hooksPath .githooks
|
||||
|
||||
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
|
||||
|
||||
## Testing against a matrix-js-sdk branch in CI
|
||||
|
||||
Linking covers local development. In CI, `scripts/layered.sh` plays the same
|
||||
role for `matrix-js-sdk`: after the usual frozen install, `scripts/fetchdep.sh`
|
||||
looks for a matrix-js-sdk branch with the same name as the pull request's head
|
||||
branch (in the fork first, when the pull request comes from one). If there is
|
||||
one, it is cloned, packed and installed over the locked version. If there is
|
||||
none, the install stays as `pnpm-lock.yaml` pins it.
|
||||
|
||||
This means a change that needs a not yet merged matrix-js-sdk change can be tested
|
||||
by giving both branches the same name. This mirrors how element-web tests against
|
||||
matrix-js-sdk. Once the matrix-js-sdk branch is merged and deleted, the pull
|
||||
request falls back to the lockfile and needs a lockfile bump to stay green,
|
||||
which is also what keeps `main` consistent after the merge.
|
||||
|
||||
## Background
|
||||
|
||||
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
@@ -83,7 +83,7 @@
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
@@ -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",
|
||||
@@ -130,7 +129,6 @@
|
||||
"uuid": "^14.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -92,6 +92,12 @@ export async function expectWithin(
|
||||
outer: Locator,
|
||||
): Promise<void> {
|
||||
await expect(inner).toBeVisible();
|
||||
// Wait for animations to settle.
|
||||
await inner.evaluate(async (element) => {
|
||||
for (const animation of element.getAnimations({ subtree: true }))
|
||||
await animation.finished;
|
||||
});
|
||||
|
||||
const innerBox = await inner.boundingBox();
|
||||
const outerBox = await outer.boundingBox();
|
||||
if (innerBox === null || outerBox === null)
|
||||
|
||||
@@ -132,7 +132,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -223,7 +223,7 @@ export class TestHelpers {
|
||||
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
|
||||
Generated
+53
-104
@@ -131,11 +131,11 @@ importers:
|
||||
specifier: ^10.2.11
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^10.0.0
|
||||
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.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@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
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.1.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
|
||||
@@ -225,7 +222,7 @@ importers:
|
||||
version: 10.6.1(postcss@8.5.26)
|
||||
postcss-selector-parser:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.3
|
||||
version: 7.1.6
|
||||
posthog-js:
|
||||
specifier: 1.408.2
|
||||
version: 1.408.2
|
||||
@@ -271,9 +268,6 @@ importers:
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 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)
|
||||
vite-plugin-generate-file:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.1
|
||||
vite-plugin-html:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(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))
|
||||
@@ -1197,8 +1191,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':
|
||||
@@ -3229,8 +3223,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.4':
|
||||
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
|
||||
'@vector-im/compound-design-tokens@11.1.0':
|
||||
resolution: {integrity: sha512-vkMcy/G5VBlVle8umQNpctKHqZITS8XOQprsgcbSGRmwW080Z+TXV/vPsEXhgq8JKdxURkLhua0zQUufCeo46A==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
@@ -3246,8 +3240,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 +3656,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:
|
||||
@@ -4468,10 +4462,6 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.3.1:
|
||||
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||
hasBin: true
|
||||
@@ -4683,9 +4673,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:
|
||||
@@ -4706,14 +4696,6 @@ packages:
|
||||
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
|
||||
hasBin: true
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4816,13 +4798,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 +4880,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:
|
||||
@@ -5185,8 +5160,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
postcss-selector-parser@7.1.3:
|
||||
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
|
||||
postcss-selector-parser@7.1.6:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
@@ -5914,9 +5889,6 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vite-plugin-generate-file@0.3.1:
|
||||
resolution: {integrity: sha512-tiA3gkPM21MS2+RyqsBMT33GSlM9LM1TJjf6vGvV/e/ml3e3vTKfuH3l2N0NpUgcayvj1fXnmlo5YBuahA6bsg==}
|
||||
|
||||
vite-plugin-html@3.2.2:
|
||||
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
|
||||
peerDependencies:
|
||||
@@ -6425,9 +6397,9 @@ snapshots:
|
||||
|
||||
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6533,9 +6505,9 @@ snapshots:
|
||||
|
||||
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6637,7 +6609,7 @@ snapshots:
|
||||
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6681,13 +6653,13 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/utilities@2.0.0(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -7111,7 +7083,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:
|
||||
@@ -8695,12 +8667,12 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)':
|
||||
'@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8)':
|
||||
optionalDependencies:
|
||||
'@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@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@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.1.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)
|
||||
@@ -8712,7 +8684,7 @@ snapshots:
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 11.1.0(@types/react@19.2.18)(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
react: 19.2.8
|
||||
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -9173,7 +9145,7 @@ snapshots:
|
||||
|
||||
constants-browserify@1.0.0: {}
|
||||
|
||||
content-type@3.0.0: {}
|
||||
content-type@3.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
@@ -9244,13 +9216,13 @@ snapshots:
|
||||
css-blank-pseudo@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
css-has-pseudo@7.0.3(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
|
||||
@@ -10044,10 +10016,6 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@4.3.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -10259,17 +10227,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
|
||||
|
||||
@@ -10296,12 +10264,6 @@ snapshots:
|
||||
bn.js: 4.12.3
|
||||
brorand: 1.1.0
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
@@ -10410,12 +10372,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 +10556,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
|
||||
|
||||
@@ -10710,7 +10666,7 @@ snapshots:
|
||||
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-clamp@4.1.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10761,12 +10717,12 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10778,12 +10734,12 @@ snapshots:
|
||||
postcss-focus-visible@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-focus-within@9.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-font-variant@5.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10815,10 +10771,10 @@ snapshots:
|
||||
|
||||
postcss-nesting@13.0.2(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10916,7 +10872,7 @@ snapshots:
|
||||
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10925,9 +10881,9 @@ snapshots:
|
||||
postcss-selector-not@8.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-selector-parser@7.1.3:
|
||||
postcss-selector-parser@7.1.6:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
@@ -11668,13 +11624,6 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vite-plugin-generate-file@0.3.1:
|
||||
dependencies:
|
||||
ejs: 3.1.10
|
||||
js-yaml: 4.3.0
|
||||
mime-types: 2.1.35
|
||||
picocolors: 1.1.1
|
||||
|
||||
vite-plugin-html@3.2.2(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)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
|
||||
@@ -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;
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Clones the branch of a dependency's repository that has the same name as
|
||||
# the pull request branch under test, so a change spanning both repositories
|
||||
# can be tested before either side is merged. Inspired by scripts/fetchdep.sh
|
||||
# element-web.
|
||||
#
|
||||
# Usage: scripts/fetchdep.sh <org> <repo> [ref]
|
||||
#
|
||||
# The clone lands in ./<repo>. When the pull request comes from a fork and
|
||||
# PR_HEAD_REPO_OWNER was set, the fork's own copy of the repository is tried
|
||||
# before <org>'s. Exits 0 when a branch was cloned and 1 when there is none,
|
||||
# so the caller can keep the locked version in that case. Without a ref,
|
||||
# nothing is closed outside a pull request.
|
||||
|
||||
set -eu
|
||||
|
||||
org=$1
|
||||
repo=$2
|
||||
ref=${3:-}
|
||||
|
||||
rm -rf "$repo"
|
||||
|
||||
# If a ref was passed, try to clone it or die.
|
||||
if [ -n "$ref" ]; then
|
||||
echo "fetchdep.sh: Using $org/$repo at $ref"
|
||||
git clone --depth 1 "https://github.com/$org/$repo.git" "$repo"
|
||||
git -C "$repo" fetch --depth 1 origin "$ref"
|
||||
git -C "$repo" -c advice.detachedHead=false checkout FETCH_HEAD
|
||||
git -C "$repo" log -1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise ensure that we're on a pull request.
|
||||
head_ref=${GITHUB_HEAD_REF:-}
|
||||
if [ -z "$head_ref" ]; then
|
||||
echo "fetchdep.sh: Not a pull request, nothing to match"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bots name their branches the same way in every repository, so a match on
|
||||
# one of theirs would be a coincidence rather than a paired change.
|
||||
case "$head_ref" in
|
||||
renovate/* | dependabot/*)
|
||||
echo "fetchdep.sh: Not matching bot branch $head_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clones a branch if it exists and ends the script on success.
|
||||
clone() {
|
||||
local try_org=$1
|
||||
local branch=$2
|
||||
echo "fetchdep.sh: Trying to use $try_org/$repo#$branch"
|
||||
# Disable auth prompts: https://serverfault.com/a/665959
|
||||
if GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$try_org/$repo.git" "$repo" --branch "$branch" --depth 1; then
|
||||
git -C "$repo" log -1
|
||||
# Surface the match on the workflow run summary.
|
||||
echo "::notice title=Matched $repo branch::Using $try_org/$repo#$branch at $(git -C "$repo" rev-parse HEAD)"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Try cloning from the fork, then from the org.
|
||||
head_owner=${PR_HEAD_REPO_OWNER:-}
|
||||
if [ -n "$head_owner" ] && [ "$head_owner" != "${GITHUB_REPOSITORY_OWNER:-}" ]; then
|
||||
clone "$head_owner" "$head_ref"
|
||||
fi
|
||||
clone "$org" "$head_ref"
|
||||
|
||||
# Nothing worked. Die.
|
||||
echo "fetchdep.sh: No branch named $head_ref in $org/$repo"
|
||||
exit 1
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Installs dependencies, then swaps matrix-js-sdk for a branch of the same
|
||||
# name as the pull request branch under test if one exists. This is what
|
||||
# lets CI test an Element Call branch together with a matrix-js-sdk branch
|
||||
# before either is merged. Inspired by scripts/layered.sh in element-web.
|
||||
#
|
||||
# Usage: scripts/layered.sh [pnpm install flags]
|
||||
#
|
||||
# The layering is only performed when package.json uses the develop branch of
|
||||
# matrix-js-sdk and the run is for a pull request whose branch has a matrix-js-sdk
|
||||
# counterpart. Otherwise the dependency is left as pnpm-lock.yaml pins it.
|
||||
#
|
||||
# The matrix-js-sdk checkout is nested inside this directory because some CI
|
||||
# systems do not allow moving above the primary checkout. It is git-ignored.
|
||||
#
|
||||
# Set JS_SDK_GITHUB_BASE_REF to a ref to use that exact ref of matrix-js-sdk
|
||||
# instead of looking for a matching branch.
|
||||
#
|
||||
# This script is only meant to be used in CI. For local development use
|
||||
# see docs/linking.md.
|
||||
|
||||
set -ex
|
||||
|
||||
# Paths below are relative to the repository root.
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
pnpm install --frozen-lockfile "$@"
|
||||
|
||||
js_sdk_dep=$(jq -r '.devDependencies["matrix-js-sdk"]' < package.json)
|
||||
|
||||
# Only layer when package.json follows the branch head rather than pinning a
|
||||
# release, so a release build keeps its pinned version.
|
||||
if [ "$js_sdk_dep" != "github:matrix-org/matrix-js-sdk#develop" ]; then
|
||||
echo "layered.sh: Skipping matrix-js-sdk fetch and install as package.json pins $js_sdk_dep"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! scripts/fetchdep.sh matrix-org matrix-js-sdk "${JS_SDK_GITHUB_BASE_REF:-}"; then
|
||||
echo "layered.sh: No matching matrix-js-sdk branch, keeping the locked version"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# matrix-js-sdk pins its pnpm version in `devEngines` only. Under corepack,
|
||||
# pnpm does not switch versions on its own and corepack, finding no
|
||||
# `packageManager` field there, would walk up to this project's and use
|
||||
# ours. Pinning it in the clone makes every pnpm invocation inside that
|
||||
# directory, including the `prepare` script run by pack, use the right one.
|
||||
js_sdk_pnpm="pnpm@$(jq -r '.devEngines.packageManager.version' < matrix-js-sdk/package.json)"
|
||||
jq --arg pm "$js_sdk_pnpm" '.packageManager = $pm' matrix-js-sdk/package.json > matrix-js-sdk/package.json.tmp
|
||||
mv matrix-js-sdk/package.json.tmp matrix-js-sdk/package.json
|
||||
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# Install matrix-js-sdk's build dependencies. `prepare` runs on pack below,
|
||||
# so it is skipped here.
|
||||
(cd matrix-js-sdk && pnpm install --frozen-lockfile --ignore-scripts)
|
||||
|
||||
# Rather than `pnpm link`, build matrix-js-sdk into a tarball and install
|
||||
# that. This better reflects a clean install: dependencies may be hoisted
|
||||
# and shared, and there is no tsconfig.json to pick up sources from.
|
||||
(cd matrix-js-sdk && pnpm pack --out matrix-js-sdk.tgz)
|
||||
pnpm install "$(pwd)/matrix-js-sdk/matrix-js-sdk.tgz" "$@"
|
||||
+2
-3
@@ -26,7 +26,7 @@ This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a
|
||||
Now you just need to add the widget to element web via:
|
||||
|
||||
```
|
||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
## Widgets
|
||||
@@ -42,7 +42,6 @@ by the matrixRTCSdk to start the postmessage widget api (communication between t
|
||||
|
||||
```
|
||||
widgetId = $matrix_widget_id
|
||||
perParticipantE2EE = true
|
||||
userId = $matrix_user_id
|
||||
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||
@@ -53,7 +52,7 @@ baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||
Full template use as `<widgetUrl>`:
|
||||
|
||||
```
|
||||
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||
|
||||
@@ -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,235 @@
|
||||
/*
|
||||
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 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,
|
||||
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,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
@@ -230,7 +230,6 @@ describe("UrlParams", () => {
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
@@ -243,7 +242,6 @@ describe("UrlParams", () => {
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
@@ -256,7 +254,6 @@ describe("UrlParams", () => {
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
@@ -499,7 +496,6 @@ describe("UrlParams", () => {
|
||||
// A host owns navigation, so Element Call must not offer a way out of
|
||||
// the room
|
||||
confineToRoom: true,
|
||||
perParticipantE2EE: true,
|
||||
// The lobby first, so that the user picks their devices rather than
|
||||
// being thrown into the call by the act of being rendered
|
||||
skipLobby: false,
|
||||
@@ -512,7 +508,6 @@ describe("UrlParams", () => {
|
||||
expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({
|
||||
confineToRoom: false,
|
||||
header: HeaderStyle.Standard,
|
||||
perParticipantE2EE: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-237
@@ -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.
|
||||
@@ -555,7 +350,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
showControls: parser.getFlag("showControls"),
|
||||
hideScreensharing: parser.getFlag("hideScreensharing"),
|
||||
allowIceFallback: parser.getFlag("allowIceFallback"),
|
||||
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
|
||||
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
|
||||
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
|
||||
// In SPA mode the user should always exit to the home screen when hanging
|
||||
@@ -578,7 +372,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
"intent:",
|
||||
intent,
|
||||
"\nproperties:",
|
||||
redact(properties, "password"),
|
||||
redact(properties, "password", "posthogUserId"),
|
||||
"configuration:",
|
||||
configuration,
|
||||
);
|
||||
@@ -706,20 +500,14 @@ export function generateUrlSearchParams(
|
||||
const params = new URLSearchParams();
|
||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||
// it in case it came from another client that generated a non url-safe one
|
||||
switch (encryptionSystem?.kind) {
|
||||
case E2eeType.SHARED_KEY: {
|
||||
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
|
||||
if (encodedPassword !== encryptionSystem.secret) {
|
||||
logger.info(
|
||||
"Encoded call password used non URL-safe chars: buggy client?",
|
||||
);
|
||||
}
|
||||
params.set("password", encodedPassword);
|
||||
break;
|
||||
if (encryptionSystem?.kind === E2eeType.SHARED_KEY) {
|
||||
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
|
||||
if (encodedPassword !== encryptionSystem.secret) {
|
||||
logger.info(
|
||||
"Encoded call password used non URL-safe chars: buggy client?",
|
||||
);
|
||||
}
|
||||
case E2eeType.PER_PARTICIPANT:
|
||||
params.set("perParticipantE2EE", "true");
|
||||
break;
|
||||
params.set("password", encodedPassword);
|
||||
}
|
||||
params.set("roomId", roomId);
|
||||
viaServers?.forEach((s) => params.set("viaServers", s));
|
||||
|
||||
+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;
|
||||
|
||||
@@ -83,7 +83,7 @@ div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.reactionButton {
|
||||
.reactionPopupMenu .reactionsMenu .reactionButton {
|
||||
padding: var(--reaction-button-padding);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
font-size: var(--reaction-button-fontsize);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -207,12 +207,6 @@ export interface ConfigOptions {
|
||||
* Take care when changing these options.
|
||||
*/
|
||||
matrix_rtc_session?: {
|
||||
/**
|
||||
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
|
||||
* when someone leaves a call.
|
||||
*/
|
||||
wait_for_key_rotation_ms?: number;
|
||||
|
||||
/**
|
||||
* How long we wait before retrying after a network error on any of the requests.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -242,12 +242,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room);
|
||||
const {
|
||||
perParticipantE2EE,
|
||||
returnToLobby,
|
||||
password: passwordFromUrl,
|
||||
header,
|
||||
} = useUrlParams();
|
||||
const { returnToLobby, password: passwordFromUrl, header } = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
// Save the password once we start the groupCallView
|
||||
@@ -365,7 +360,6 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
rtcSession,
|
||||
preload,
|
||||
skipLobby,
|
||||
perParticipantE2EE,
|
||||
mediaDevices,
|
||||
latestMuteStates,
|
||||
setJoined,
|
||||
|
||||
+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.
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { prefetchSounds } from "./soundUtils";
|
||||
|
||||
const bytesOf = (buffer: ArrayBuffer): number[] => [...new Uint8Array(buffer)];
|
||||
|
||||
describe("prefetchSounds", () => {
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
it("fetches sounds given as ordinary URLs", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
);
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "https://example.org/beep.ogg" },
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith("https://example.org/beep.ogg");
|
||||
expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("decodes base64 data: URLs without fetching them", async () => {
|
||||
// "OggS" — the bytes 0x4f 0x67 0x67 0x53
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg;base64,T2dnUw==" },
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]);
|
||||
});
|
||||
|
||||
it("leaves out a sound with a percent-encoded data: URL", async () => {
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg,Ogg%53" },
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sounds).toEqual({});
|
||||
});
|
||||
|
||||
it("leaves out a sound whose response is not okay", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "https://example.org/missing.ogg" },
|
||||
});
|
||||
expect(sounds).toEqual({});
|
||||
});
|
||||
|
||||
it("leaves out a sound that fails to load, keeping the others", async () => {
|
||||
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "https://example.org/beep.ogg" },
|
||||
boop: { ogg: "data:audio/ogg;base64,T2dnUw==" },
|
||||
});
|
||||
expect(Object.keys(sounds)).toEqual(["boop"]);
|
||||
});
|
||||
|
||||
it("leaves out a sound with a malformed data: URL", async () => {
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg;base64" },
|
||||
});
|
||||
expect(sounds).toEqual({});
|
||||
});
|
||||
});
|
||||
+46
-7
@@ -32,6 +32,43 @@ function getPreferredAudioFormat(): "ogg" | "mp3" {
|
||||
|
||||
const preferredFormat = getPreferredAudioFormat();
|
||||
|
||||
/**
|
||||
* Decode a `data:` URL into the bytes it carries.
|
||||
*
|
||||
* The component build inlines the sound files as `data:` URLs. Those could be
|
||||
* `fetch`ed like any other URL, but that fetch is subject to the host page's
|
||||
* Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*`
|
||||
* source does not cover it). Reading the bytes out of the URL directly needs no
|
||||
* network access, so no policy can stop it.
|
||||
* @param url The `data:` URL to decode.
|
||||
* @returns The decoded bytes.
|
||||
*/
|
||||
function decodeDataUrl(url: string): ArrayBuffer {
|
||||
const separator = url.indexOf(",");
|
||||
if (!url.startsWith("data:") || separator === -1)
|
||||
throw new Error("Not a valid data: URL");
|
||||
const header = url.slice("data:".length, separator);
|
||||
// Vite only percent-encodes SVGs; every other inlined asset is base64.
|
||||
if (!header.split(";").includes("base64"))
|
||||
throw new Error("Only base64 data: URLs are supported");
|
||||
// `decoded` is a binary string: one char per byte
|
||||
const decoded = atob(url.slice(separator + 1));
|
||||
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bytes of a single sound file.
|
||||
* @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL.
|
||||
* @returns The bytes, or null if the sound could not be loaded.
|
||||
*/
|
||||
async function loadSound(url: string): Promise<ArrayBuffer | null> {
|
||||
if (url.startsWith("data:")) return decodeDataUrl(url);
|
||||
const response = await fetch(url);
|
||||
if (isFailure(response)) return null;
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch sounds to be used by the AudioContext. This can
|
||||
* be called outside the scope of a component to ensure the
|
||||
@@ -47,18 +84,20 @@ export async function prefetchSounds<S extends string>(
|
||||
Object.entries(sounds).map(async ([name, file]) => {
|
||||
const { mp3, ogg } = file as SoundDefinition;
|
||||
// Use preferred format, fallback to ogg if no mp3 is provided.
|
||||
// Load an audio file
|
||||
const response = await fetch(
|
||||
preferredFormat === "ogg" ? ogg : (mp3 ?? ogg),
|
||||
);
|
||||
if (isFailure(response)) {
|
||||
const url = preferredFormat === "ogg" ? ogg : (mp3 ?? ogg);
|
||||
let buffer: ArrayBuffer | null = null;
|
||||
try {
|
||||
buffer = await loadSound(url);
|
||||
} catch (e) {
|
||||
logger.warn(`Could not load sound ${name}`, e);
|
||||
}
|
||||
if (buffer === null) {
|
||||
// If the sound doesn't load, it's not the end of the world. We won't play
|
||||
// the sound when requested, but it's better than failing the whole application.
|
||||
logger.warn(`Could not load sound ${name}, response was not okay`);
|
||||
return;
|
||||
}
|
||||
// Decode it
|
||||
buffers[name] = await response.arrayBuffer();
|
||||
buffers[name] = buffer;
|
||||
}),
|
||||
);
|
||||
return buffers as Record<S, ArrayBuffer>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { AudioOutput } from "./MediaDevices";
|
||||
import { audioOutput as audioOutputSetting } from "../settings/settings";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BT_SPEAKER = {
|
||||
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioOutputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||
a: undefined,
|
||||
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// No "default" pseudo-device (Firefox): the virtual browser default is
|
||||
// selected until the user picks something.
|
||||
b: { id: "", virtualEarpiece: false },
|
||||
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
@@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the browser default when the selected device disappears", () => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a---b", {
|
||||
a: DEVICE_LIST_B,
|
||||
// The monitor is unplugged (or a Bluetooth sink changes profile)
|
||||
b: [LAPTOP_SPEAKER],
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
schedule("--a", {
|
||||
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
|
||||
});
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("a-b-c", {
|
||||
a: { id: "", virtualEarpiece: false },
|
||||
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// Rather than pinning some other physical device, let the browser route
|
||||
c: { id: "", virtualEarpiece: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the virtual browser default first when there is no default pseudo-device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", { a: DEVICE_LIST_B }),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
expectObservable(audioOutput.available$).toBe("a", {
|
||||
a: new Map([
|
||||
["", { type: "default", name: null }],
|
||||
[
|
||||
LAPTOP_SPEAKER.deviceId,
|
||||
{ type: "name", name: LAPTOP_SPEAKER.label },
|
||||
],
|
||||
[
|
||||
MONITOR_SPEAKER.deviceId,
|
||||
{ type: "name", name: MONITOR_SPEAKER.label },
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Test mappings", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
scan,
|
||||
startWith,
|
||||
Subject,
|
||||
switchAll,
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
@@ -39,11 +37,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,
|
||||
@@ -83,6 +82,7 @@ import {
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
@@ -92,17 +92,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
type WindowMode,
|
||||
} from "../layout-types.ts";
|
||||
import {
|
||||
chooseSpotlightSpeaker,
|
||||
computeLayoutMedia,
|
||||
computeSpotlight,
|
||||
type OneOnOneMedia,
|
||||
} from "../layoutMedia.ts";
|
||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||
@@ -112,7 +112,7 @@ import {
|
||||
TransportState,
|
||||
} from "./localMember/LocalMember.ts";
|
||||
import {
|
||||
createLocalTransport$,
|
||||
getLocalTransport,
|
||||
type LocalTransport,
|
||||
} from "./localMember/LocalTransport.ts";
|
||||
import {
|
||||
@@ -263,21 +263,10 @@ export function callViewModelOptionsFromParams(
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
// This is the number of participants that we think constitutes a "small" call
|
||||
// on mobile. No spotlight tile should be shown below this threshold.
|
||||
const smallMobileCallThreshold = 3;
|
||||
|
||||
// How long the footer should be shown for when hovering over or interacting
|
||||
// with the interface
|
||||
const showFooterMs = 4000;
|
||||
|
||||
/**
|
||||
* The general shape of the space the call is drawn in. Called a window because
|
||||
* that is what it is in the standalone app; for a component it is the container
|
||||
* the host gave us, which may be a small corner of a large window.
|
||||
*/
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
@@ -563,20 +552,26 @@ export function createCallViewModel$(
|
||||
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
||||
userId,
|
||||
deviceId,
|
||||
// This will only be consumed by the sticky membership manager. So it has no impact on legacy calls.
|
||||
memberId: uuidv4(),
|
||||
// Consumed by the sticky membership manager as `member.id`, *and* stamped
|
||||
// into every to-device key event by the key transport. A pre-sticky
|
||||
// membership advertises `${userId}:${deviceId}` as its `membershipID`
|
||||
// instead, so a uuid there names a member no peer can resolve.
|
||||
memberId:
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0
|
||||
? uuidv4()
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
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 ??
|
||||
@@ -593,17 +588,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,
|
||||
@@ -628,7 +613,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
muteStates,
|
||||
joinMatrixRTC: (
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||
) => {
|
||||
return enterRTCSession(
|
||||
@@ -659,7 +644,7 @@ export function createCallViewModel$(
|
||||
connectionManager,
|
||||
client,
|
||||
matrixRTCSession,
|
||||
localTransport,
|
||||
localTransport$,
|
||||
roomId: matrixRoom.roomId,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
@@ -985,33 +970,16 @@ export function createCallViewModel$(
|
||||
mediaItems.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
mediaItems.map((m) =>
|
||||
m.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
mediaItems.map((media) =>
|
||||
media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<
|
||||
(readonly [UserMediaViewModel, boolean])[],
|
||||
{ media: UserMediaViewModel; speaking: boolean }[],
|
||||
UserMediaViewModel | undefined,
|
||||
undefined
|
||||
>((prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.local)?.[0]);
|
||||
}, undefined),
|
||||
>(chooseSpotlightSpeaker, undefined),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1055,33 +1023,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const spotlightAndPip$ = scope.behavior<{
|
||||
spotlight: MediaViewModel[];
|
||||
pip$: Observable<UserMediaViewModel | undefined>;
|
||||
}>(
|
||||
ringingMedia$.pipe(
|
||||
switchMap((ringingMedia) => {
|
||||
if (ringingMedia !== null)
|
||||
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
|
||||
|
||||
return screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0)
|
||||
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
|
||||
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => ({
|
||||
spotlight: speaker ? [speaker] : [],
|
||||
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
||||
pip$: localUserMediaForPip$.pipe(
|
||||
map((m) => (m === speaker ? undefined : m)),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const spotlightAndPip$ = scope.behavior(
|
||||
combineLatest(
|
||||
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
|
||||
(ringing, screenShares, speaker, localPip) =>
|
||||
computeSpotlight({ ringing, screenShares, speaker, localPip }),
|
||||
).pipe(distinctUntilChanged(shallowObjectEquals)),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
@@ -1139,56 +1086,7 @@ export function createCallViewModel$(
|
||||
hasRemoteScreenShares$,
|
||||
);
|
||||
|
||||
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
grid,
|
||||
}),
|
||||
);
|
||||
|
||||
const spotlightLandscapeLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightLandscapeLayoutMedia> =>
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-portrait",
|
||||
edgeToEdge: false,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightExpandedLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightExpandedLayoutMedia> =>
|
||||
spotlightAndPip$.pipe(
|
||||
switchMap(({ spotlight, pip$ }) =>
|
||||
pip$.pipe(
|
||||
map((pip) => ({
|
||||
type: "spotlight-expanded" as const,
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Behavior<{
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = scope.behavior(
|
||||
const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
@@ -1226,47 +1124,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
|
||||
const localVideoEnabled$ = scope.behavior<boolean>(
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
map((media) => {
|
||||
if (media === null) return null;
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((media) => {
|
||||
if (media === null) return of(null);
|
||||
return media.local.videoEnabled$.pipe(
|
||||
map((videoEnabled) => ({
|
||||
type: "one-on-one-mobile" as const,
|
||||
edgeToEdge: true as const,
|
||||
spotlight: media.remote,
|
||||
pip: videoEnabled ? media.local : undefined,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
||||
map((spotlight) => ({
|
||||
type: "pip",
|
||||
edgeToEdge: platform !== "desktop",
|
||||
spotlight,
|
||||
})),
|
||||
switchMap((media) =>
|
||||
media === null ? of(false) : media.local.videoEnabled$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
spotlight$
|
||||
@@ -1301,66 +1164,38 @@ export function createCallViewModel$(
|
||||
* The media to be used to produce a layout.
|
||||
*/
|
||||
const layoutMedia$ = scope.behavior<LayoutMedia>(
|
||||
windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
return oneOnOneDesktopLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$(false)
|
||||
: spotlightLandscapeLayoutMedia$(false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
)
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
// We deliberately don't use combineLatest here. Several of the inputs
|
||||
// below derive from the same source. So one upstream change cascades
|
||||
// through them one at a time. combineLatest would see each step of the
|
||||
// cascade and emit layouts built from inputs that never coexisted.
|
||||
// Instead, we treat the emissions as a mere "something changed" signal
|
||||
// and read every input's current value. The derived behaviors were
|
||||
// subscribed before this one and a BehaviorSubject updates its value
|
||||
// before notifying. So by the time the first signal arrives every value
|
||||
// is already consistent. The duplicate signals from the rest of the
|
||||
// cascade are dropped by distinctUntilChanged.
|
||||
merge(
|
||||
windowMode$,
|
||||
layoutSwitchVm.layout$,
|
||||
spotlightExpanded$,
|
||||
oneOnOneLayoutMedia$,
|
||||
localVideoEnabled$,
|
||||
spotlightAndPip$,
|
||||
grid$,
|
||||
).pipe(
|
||||
map(() =>
|
||||
computeLayoutMedia({
|
||||
windowMode: windowMode$.value,
|
||||
layoutMode: layoutSwitchVm.layout$.value,
|
||||
spotlightExpanded: spotlightExpanded$.value,
|
||||
oneOnOne: oneOnOneLayoutMedia$.value,
|
||||
localVideoEnabled: localVideoEnabled$.value,
|
||||
...spotlightAndPip$.value,
|
||||
grid: grid$.value,
|
||||
desktop: platform === "desktop",
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1661,7 +1496,7 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
||||
distinctUntilChanged(shallowObjectEquals<Layout>),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,18 @@ 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,
|
||||
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;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
switchAll,
|
||||
} from "rxjs";
|
||||
|
||||
import { type WindowMode } from "./CallViewModel/CallViewModel.ts";
|
||||
import { type WindowMode } from "./layout-types.ts";
|
||||
import { constant, type Behavior } from "./Behavior.ts";
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user