mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5733e8274 | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
81233e5cfb | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
5c497cc6a8 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
d5812ddd34 | ||
|
|
cf7c275fb7 | ||
|
|
93b2661513 | ||
|
|
1f84cb9c5c | ||
|
|
4269aaa163 | ||
|
|
bc62bb9565 | ||
|
|
be56bbd05b | ||
|
|
eb44fd545d | ||
|
|
c9f911b7c9 | ||
|
|
3a189d155b | ||
|
|
0211d5c367 | ||
|
|
c88f884fc9 | ||
|
|
9f7c35cd8f | ||
|
|
ac81a7c0ab | ||
|
|
8ebb0b5a90 | ||
|
|
03f81bcd64 | ||
|
|
9f6c8bb434 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
71593f1a24 | ||
|
|
5d38a13c46 | ||
|
|
fe91162869 | ||
|
|
74328d7e42 | ||
|
|
6046337877 | ||
|
|
16b2c31ebb | ||
|
|
a58a5f615f | ||
|
|
2cac2d7e8b | ||
|
|
1695d3f518 | ||
|
|
96a2e9b481 | ||
|
|
23ba8f3204 | ||
|
|
b284bb00c0 | ||
|
|
cb0ae89143 | ||
|
|
344aac21f9 | ||
|
|
21d7f24f61 | ||
|
|
dbd2423e7b | ||
|
|
426acd222e | ||
|
|
2e5b5cb186 | ||
|
|
8060d10fcc |
+1
-1
@@ -2,5 +2,5 @@
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist", "FEATURES_SPEC"]
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ host's page. It is the MatrixRTC reference implementation.
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Commit when the
|
||||
user confirms direction, not before.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
@@ -48,5 +48,9 @@ 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.
|
||||
- Once review starts, fix forward. Never force-push a regeneration over a review.
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
---
|
||||
status: implement
|
||||
blocked_from: null
|
||||
title: Audio quick menu
|
||||
owner: fkwp
|
||||
signoff:
|
||||
eng: ok
|
||||
design: ok
|
||||
product: ok
|
||||
experiment_ref: fkwp/feature/audio_quick_menu_exploration
|
||||
base: 029622f07b44f58f8a84e7e167e5b66374479b63
|
||||
repos: [element-call]
|
||||
specs: []
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
- `MediaMuteAndSwitchButton` populates its menu from `audioInput.available$` only. On
|
||||
desktop, audio output is reachable only through `SettingsModal` → audio tab, which covers
|
||||
the call view; the footer's `audioOutputSwitcher$` is an earpiece/loudspeaker toggle that
|
||||
only materialises for controlled (mobile) devices.
|
||||
- No input-level indicator exists anywhere in the app: a participant cannot tell whether the
|
||||
selected microphone captures sound without unmuting and asking another participant.
|
||||
- `soundEffectVolume` is adjustable only in `SettingsModal`, which replaces the call view.
|
||||
- `DeviceSelection` renders nothing when `available.size <= 1`, and `AudioOutput.available$`
|
||||
is emptied on Safari, so those users get no indication of where call audio is going.
|
||||
- The in-call chevron is suppressed off desktop and in PiP via `disableDeviceSwitcher$`
|
||||
(`CallFooterViewModel`), while `createLobbyFooterViewModel` passes `constant(false)`; the
|
||||
two surfaces already disagree about when the menu exists.
|
||||
|
||||
## Decisions
|
||||
|
||||
- D1 — The level indicator captures from the microphone only while the menu is open, muted or
|
||||
not — bounds the capture and the microphone-in-use indicator to a deliberate user action
|
||||
while still answering "is my microphone working?" before unmuting.
|
||||
- D2 — The menu appears both before joining and during a call — pre-join is where a device
|
||||
check matters most, in-call is where the settings detour costs most.
|
||||
- D3 — Before joining, the full menu appears on every platform, including mobile; during a
|
||||
call it stays web/desktop only — the pre-join chevron already exists everywhere, so
|
||||
suppressing sections there would be new work for no user gain.
|
||||
- D4 — Sound-effects volume appears in the menu and stays in settings, both reading and
|
||||
writing one stored value — no migration, and neither audience loses the control it knows.
|
||||
- D5 — Where the audio output cannot be changed, the menu still shows the active output as a
|
||||
non-selectable row — knowing where audio goes is useful even when it cannot be redirected.
|
||||
- D6 — The menu carries no disclosure that the microphone is sampled while muted — mute state
|
||||
and the operating system's microphone-in-use indicator are independent of each other.
|
||||
- D7 — A microphone producing no signal shows an idle indicator; one held exclusively by
|
||||
another application shows a greyed-out indicator — two distinguishable states, the second
|
||||
rare on current operating systems.
|
||||
- D8 — A denied microphone permission shows a hint rather than a silent idle indicator —
|
||||
silence is otherwise indistinguishable from a broken microphone.
|
||||
- D9 — The level indicator is focusable and announces its state to a screen reader while
|
||||
focused — it is the one control in the menu with no non-visual equivalent.
|
||||
- D10 — No numeric latency target for the level indicator; it must move visibly while the
|
||||
person is still speaking — perceived latency is not critical for a presence check.
|
||||
- D11 — Only the device lists scroll; the menu's heading and the sound-effect slider stay
|
||||
in place — a list longer than the screen otherwise carries both out of view.
|
||||
- D12 — The level indicator belongs to the microphone group and stays visible while that
|
||||
group is on screen, rather than sitting with the output controls — pinned next to the
|
||||
sound-effect slider it reads as one of them.
|
||||
- D13 — Keyboard navigation marks the current item with a border, pointer use marks it with
|
||||
a background and no border — the menu moves focus to whatever the pointer is over, so
|
||||
without the distinction both appear at once.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Test names are the anchor; tests are created with exactly these names.
|
||||
|
||||
- AC1 [FR-001] — The chevron beside the microphone button opens a menu headed "Audio controls".
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu is headed Audio controls"`
|
||||
- AC2 [FR-002] — The menu lists every available microphone, marks the active one, and selecting
|
||||
another switches input without closing the menu.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu switches microphone and stays open"`
|
||||
- AC3 [FR-003] — The menu lists every available audio output, marks the active one, and
|
||||
selecting another switches output without closing the menu.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu switches audio output and stays open"`
|
||||
- AC4 [FR-004] — The menu renders a separator between the microphone, speaker and
|
||||
sound-effects groups.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu separates microphone speaker and sound effects groups"`
|
||||
- AC5 [FR-005] — With exactly one audio output available, the speaker group still names the
|
||||
active output and offers no selectable row.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu shows single audio output as non-selectable"`
|
||||
- AC6 [FR-005] — In a browser that does not permit choosing an audio output, the speaker group
|
||||
names the active output and offers no selectable row.
|
||||
- check: manual, on Safari: join a call, open the microphone chevron, confirm the speaker
|
||||
group names the output in use and that no row responds to a click.
|
||||
- AC7 [FR-006] — An output selected in the menu is shown as active in settings, and an output
|
||||
selected in settings is shown as active in the menu.
|
||||
- check: `pnpm vitest run --project=unit -t "audio output selection is shared between menu and settings"`
|
||||
- AC8 [FR-007] — While the menu is open, the level indicator's reported level follows the
|
||||
signal level of the selected microphone.
|
||||
- check: `pnpm vitest run --project=unit -t "level indicator follows the microphone signal level"`
|
||||
- AC9 [FR-008] — The level indicator responds to the microphone signal while the participant
|
||||
is muted.
|
||||
- check: `pnpm vitest run --project=unit -t "level indicator responds while muted"`
|
||||
- AC10 [FR-009] — Opening the menu starts capturing from the selected microphone; closing it
|
||||
stops the capture.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu starts capture on open and stops on close"`
|
||||
- AC11 [internal] — No microphone capture started by the menu outlives the menu, including
|
||||
when the menu is closed while a device switch is in flight.
|
||||
- check: `pnpm vitest run --project=unit -t "menu capture is released when closed during a device switch"`
|
||||
- AC12 [FR-010] — Selecting a different microphone re-points the level indicator at it without
|
||||
closing the menu.
|
||||
- check: `pnpm vitest run --project=unit -t "level indicator follows a microphone change"`
|
||||
- AC13 [FR-011] — A selected microphone that produces no signal leaves the indicator in its
|
||||
idle state and produces no error.
|
||||
- check: `pnpm vitest run --project=unit -t "level indicator stays idle for a silent microphone"`
|
||||
- AC14 [FR-018] — With microphone permission denied, the menu shows a hint in place of the
|
||||
idle indicator.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu hints when microphone permission is denied"`
|
||||
- AC15 [D7] — When the selected microphone cannot be opened because another application holds
|
||||
it, the indicator renders in a greyed-out state.
|
||||
- check: `pnpm vitest run --project=storybook -t "Microphone Unavailable"`, which reads
|
||||
the greying a browser applies. The unit test
|
||||
`-t "level indicator greys out when the microphone is unavailable"` covers the state
|
||||
the stylesheet keys off, and cannot fail on the greying itself.
|
||||
- AC16 [FR-012] — Moving the sound-effects slider changes the level at which the next sound
|
||||
effect plays.
|
||||
- check: `pnpm vitest run --project=unit -t "sound effects volume from the menu applies to the next effect"`
|
||||
- AC17 [FR-013] — Sound-effects volume set in the menu is shown in settings and vice versa.
|
||||
- check: `pnpm vitest run --project=unit -t "sound effects volume is shared between menu and settings"`
|
||||
- AC18 [FR-014] — The full menu is present before joining on every platform, and during a call
|
||||
on web and desktop.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu is present pre-join on every platform"`
|
||||
- AC19 [FR-015] — During a call on mobile, the microphone chevron offers no menu.
|
||||
- check: `pnpm test:playwright --project=mobile -g "no audio menu during a call on mobile"`
|
||||
- AC20 [FR-016] — Every control in the menu — device rows, level indicator and sound-effects
|
||||
slider — is reachable and operable from the keyboard alone.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu is fully operable from the keyboard"`
|
||||
- AC21 [FR-017] — The level indicator takes focus and announces its current state to a screen
|
||||
reader while focused.
|
||||
- check: manual, with VoiceOver active: open the menu, tab to the level indicator, speak,
|
||||
confirm the announced state changes with the signal.
|
||||
- AC22 [D11] — With more devices than fit on screen, the heading and the sound-effect slider
|
||||
stay visible while the device lists scroll.
|
||||
- check: `pnpm vitest run --project=storybook -t "With Many Devices"`, which measures the
|
||||
layout in a browser with the device count fixed. The unit test
|
||||
`-t "audio menu keeps its title and volume slider out of the scrolling area"` and the
|
||||
e2e `-g "audio menu stays inside a short window"` cover the structure and the window
|
||||
bound; neither can fail on layout alone.
|
||||
- AC23 [D12] — The level indicator stays with the microphone list and never sits among the
|
||||
output controls.
|
||||
- check: `pnpm vitest run --project=unit -t "level indicator stays with the microphone list rather than the speakers"`
|
||||
- AC24 [D13] — Moving through the menu by keyboard marks the current item with a border;
|
||||
moving over it with a pointer marks it with a background and no border.
|
||||
- check: `pnpm test:playwright --project=chromium --project=firefox -g "the focus border follows the keyboard and not the pointer"`
|
||||
- AC25 [SC-001] — Selecting an output from the menu during a call leaves the other
|
||||
participants visible throughout.
|
||||
- check: `pnpm test:playwright --project=chromium -g "audio menu leaves participants visible while switching output"`
|
||||
- AC26 [SC-002] — In a real browser with the menu open, sound at the microphone moves the
|
||||
level indicator.
|
||||
- check: `pnpm test:playwright --project=chromium --project=firefox -g "level indicator moves with microphone input"`
|
||||
- AC27 [SC-003] — Every menu control is reachable and operable by keyboard alone in
|
||||
Chromium and Firefox.
|
||||
- check: `pnpm test:playwright --project=chromium --project=firefox -g "audio menu is keyboard operable in a real browser"`
|
||||
- AC28 [review: #4254] — With no microphone present, the menu names the absence in place
|
||||
of the level indicator, rather than an indicator at rest.
|
||||
- check: `pnpm vitest run --project=unit -t "audio menu says when there is no microphone"`
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Driving the level meter from the microphone track the call already publishes — the
|
||||
track is muted exactly when the user is muted, so the meter reads flat in the one
|
||||
situation it exists for (D1, FR-008). A capture of its own is what makes a muted
|
||||
check possible.
|
||||
- Gating the audio menu on there being more than one microphone, as the chevron did
|
||||
before — leaves no menu at all where there are no input devices to list but an
|
||||
output section is still worth showing, which is the pre-join case on iOS (D3).
|
||||
- Naming the sound-effects slider with `aria-label` — the shared slider takes its
|
||||
accessible name from its tooltip, so the label was overridden and the control
|
||||
announced itself as "50%". The tooltip text now carries the control's name.
|
||||
- Bounding the height of the menu as a whole — keeps it on screen, but scrolls the
|
||||
heading away with the device list, which is what the bound was meant to prevent (D11).
|
||||
- Pinning the level meter beside the sound-effect slider — always visible, but read as
|
||||
one of the output controls rather than as a reading of the microphone (D12).
|
||||
- Letting the level meter scroll with the microphone list — on a machine with many
|
||||
devices it is off screen for most of the interaction (D12).
|
||||
- Fixed-width meter bars spread across the row — all leftover width lands in the gaps,
|
||||
so the bars read as thin lines separated by wide spaces. The bars share the row
|
||||
instead, and the gap and bar count together set their thickness.
|
||||
|
||||
## Measurements
|
||||
|
||||
- Unit suite: ≥ 771 passing, 0 failing, ≥ 98 files (method: `pnpm vitest run --project=unit`
|
||||
on the experiment branch rebased onto `base`, 2026-09-10; measured 771 / 0 / 98, 9 skipped).
|
||||
- SC-001: holds (method: manual, owner, 2026-09-10 — output switched from the microphone
|
||||
chevron menu during a call; the other participants stayed visible throughout).
|
||||
- SC-002: holds (method: manual, owner, 2026-09-10 — menu open, speaking, muted and
|
||||
unmuted; the level indicator moved while still speaking).
|
||||
- SC-003: holds (method: manual, owner, 2026-09-10 — every menu control reached and
|
||||
operated by keyboard alone on the supported desktop browsers).
|
||||
- Not measured: what the meter's second concurrent capture costs alongside the call's own
|
||||
— CPU of the second capture, analyser and per-frame poll during a call, and any glitch
|
||||
on the call's own track when the capture opens or closes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The in-call chevron on mobile (`platform` `android` / `ios`), which stays suppressed by
|
||||
`disableDeviceSwitcher$` — FR-015. The lobby chevron is in scope on every platform
|
||||
(FR-014), so whatever `audioOutput.available$` reports there — including the
|
||||
speaker/earpiece entries of `AndroidControlledAudioOutput` / `IOSControlledAudioOutput` —
|
||||
is rendered by the same menu.
|
||||
- The iOS native audio-device picker button (`showNativeAudioDevicePicker`); it stays in
|
||||
`SettingsModal`.
|
||||
- The camera chevron menu and the background-blur toggle it carries.
|
||||
- PiP layout; the menu stays suppressed there.
|
||||
- An output test tone or "play test sound" affordance.
|
||||
- Noise suppression, echo cancellation and gain controls.
|
||||
- Restructuring the settings dialog beyond keeping the sound-effects slider in place.
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
|
||||
## Slicing plan
|
||||
|
||||
Re-derived 2026-09-10 against head 029622f (see Drift log). Owner decision 2026-09-10: the
|
||||
feature lands as one PR into `fkwp/feature/audio_quick_menu`, one commit per slice, each
|
||||
commit building and passing the unit suite on its own. Order follows story priority: US1
|
||||
(P1) first, US2 (P2) second, US3 (P3) third; FR-014's pre-join surface last. Both footers
|
||||
read one `MediaDevices` helper, so slice 1 lights the menu up on either surface and slice 5
|
||||
is what holds it there.
|
||||
|
||||
1. Audio menu: "Audio controls" heading, microphone and speaker groups, active output as
|
||||
a non-selectable row where it cannot be changed, outputs derived in
|
||||
`CallFooterViewModel` — covers AC1, AC2, AC3, AC5, AC6, AC7, AC25 — depends on: none
|
||||
2. Level meter component and its capture hook, unused until slice 3 — covers AC8, AC11,
|
||||
AC13, AC15 — depends on: none
|
||||
3. Level meter in the microphone group: capture bound to the menu being open, placement
|
||||
with the microphone list, keyboard and pointer focus states — covers AC9, AC10, AC12,
|
||||
AC14, AC23, AC24, AC26 — depends on: 1, 2
|
||||
4. Sound-effects volume in the menu, with only the device lists scrolling — covers AC4,
|
||||
AC16, AC17, AC20, AC21, AC22, AC27 — depends on: 3
|
||||
5. Surface coverage: the menu reaches every platform before joining and stays gated to
|
||||
desktop during a call — covers AC18, AC19 — depends on: 4
|
||||
|
||||
## Drift log
|
||||
|
||||
### 2026-09-10 — drift check (base 029622f → head 029622f)
|
||||
- `origin/main` is at `base`; `base..head` is empty. Every file and symbol named in
|
||||
`## Problem`, `## Measurements` and the provisional slicing plan exists unchanged; no
|
||||
decision is contradicted.
|
||||
- AC check commands: vitest project `unit` and playwright projects `chromium`, `firefox`
|
||||
and `mobile` exist; every command runs.
|
||||
- Slicing plan re-derived on size and story priority, not on drift: the provisional
|
||||
slice 2 (menu, speakers and meter, plus the `LobbyView` and `InCallView` snapshot
|
||||
churn) exceeds the 800-line hard stop, and the P1 story (output selection) did not
|
||||
lead. The pre-join surface moves to its own slice so the lobby snapshots change once.
|
||||
- Implementation base: `fkwp/feature/audio_quick_menu` = head + the repo agent contract
|
||||
and the FEATURES_SPEC process, which are in review of their own. No source file differs
|
||||
from head. The slices and this spec ride on one branch that opens a PR against it.
|
||||
|
||||
### 2026-09-10 — owner decision: single PR
|
||||
- The five slices land as one PR with one commit per slice, at roughly 2000 changed lines.
|
||||
Deviates from the 800-line hard stop in `FEATURES_SPEC/AGENTS.md` ("Turning a spec into
|
||||
PRs", step 3); accepted by the owner for a feature of this size. Review is per commit.
|
||||
- The meter's capture cost stays unmeasured (see `## Measurements`): it needs a real-browser
|
||||
CPU profile during a call, which the implementation environment cannot produce.
|
||||
|
||||
### 2026-09-10 — implementation findings (head 029622f)
|
||||
- The level meter reads zero on Firefox in CI, and neither the test device nor the code is
|
||||
the reason: Firefox's fake stream carries a 1 kHz tone at about a tenth of full scale,
|
||||
and the same test drives the meter to 0.62 against Firefox off the runner, including
|
||||
against the Docker image over plain HTTP that CI serves. The runner has no audio backend
|
||||
for Firefox. AC26 is therefore skipped there, a deviation from its check.
|
||||
- Firefox starts an `AudioContext` suspended, which feeds the analyser silence until it
|
||||
resumes of its own accord seconds later. The capture is now resumed on creation, so the
|
||||
meter answers while the person is still speaking (D10).
|
||||
- AC27 is skipped on Firefox too, a deviation from its check, because headless Firefox
|
||||
drives Tab unreliably, as `reconnect.spec.ts` records. AC25 skips Firefox, whose fake
|
||||
stream enumerates no audio outputs and so has nothing to switch; its check names only
|
||||
Chromium, so that is not a deviation.
|
||||
- Keyboard reach: the Radix menu swallows Tab and walks only its own items with the arrow
|
||||
keys, so the meter and the slider were unreachable by keyboard alone. The audio menu
|
||||
keeps Tab from the menu so the browser's focus order reaches them (AC20, AC27); the
|
||||
exploration's keyboard test had only asserted that the controls exist.
|
||||
- Unit suite on the PR head: 99 files, 784 passing, 0 failing, 9 skipped (method:
|
||||
`pnpm vitest run --project=unit`, 2026-09-10). The ≥ 771 / ≥ 98 floor holds.
|
||||
|
||||
### 2026-09-10 — finding: menu taller than the viewport (AC22, D11)
|
||||
- With many devices the menu overflowed the viewport and carried the heading off the top:
|
||||
its height bound applied to the content box, leaving the menu's own padding outside it.
|
||||
Fixed by bounding the border box. AC22's check runs in jsdom, which lays nothing out, so
|
||||
it passed throughout. The story `CallFooter › With Many Devices` now measures the layout
|
||||
in a browser with the device count fixed; owner to decide whether AC22's check names it.
|
||||
An e2e test cannot stand in for it, because how many devices overflow the menu depends on
|
||||
how many a browser invents: the e2e test "audio menu stays inside a short window" asserts
|
||||
only what holds at any device count.
|
||||
|
||||
### 2026-09-10 — upstream PRs for the base of this branch
|
||||
- The repo agent contract and the FEATURES_SPEC process go to `main` on their own: #4256
|
||||
(`fkwp/agents-md`) and #4255 (`fkwp/features-spec-process`). `fkwp/feature/audio_quick_menu`
|
||||
carries both as if merged; rebasing it onto `main` after they land drops the duplicates.
|
||||
|
||||
### 2026-09-10 — finding: chevron menus leave the root in the component build
|
||||
- Compound's `Menu` portals to `document.body`, and the component build rewrites every
|
||||
selector to match only `[data-element-call-root]` or its descendants. A menu opened there
|
||||
matches none of its own styles: no background, no border, no padding, and the audio menu's
|
||||
height bound is inert, so nothing holds a long device list inside the window. Measured in
|
||||
the component harness, which applies the same scoping the library build ships.
|
||||
- Not introduced by this feature. The camera chevron menu, untouched here, is equally
|
||||
unstyled in that build, and no component test covers a menu, which is why it went unseen.
|
||||
- Out of scope for this spec: the fix is to give the portal the root element as its
|
||||
container, which Compound does not expose today. Raised separately. The audio menu behaves
|
||||
as specified standalone and as a widget, which is where its acceptance criteria are checked.
|
||||
|
||||
### 2026-09-11 — review finding: a missing microphone read as a silent one (#4254)
|
||||
- The product spec's edge case for no microphone mapped to no FR and so to no acceptance
|
||||
criterion, and the menu drew a level indicator at rest, which is what a working but
|
||||
silent microphone draws. The owner chose a hint in the indicator's place, as FR-018
|
||||
requires for a denied permission, over omitting the microphone group entirely. The level
|
||||
state now tells a microphone that is absent from one that is silent.
|
||||
- Added as AC28 and the product spec's edge case reworded to match, both with the owner's
|
||||
authorisation, since acceptance criteria and the product spec are human-owned.
|
||||
|
||||
### 2026-09-11 — finding: `:focus-visible` cannot express D13 (#4254)
|
||||
- The menu moves focus onto whichever item the pointer is over, so the browser decides the
|
||||
modality for a focus it did not see the user cause. Chromium calls every focus after any
|
||||
key press keyboard-driven, so one Escape left the border following the mouse for the rest
|
||||
of the session; Firefox never calls a programmatic focus keyboard-driven, so the border
|
||||
never appeared there at all. Neither matches D13.
|
||||
- The menu now tracks which way each item was reached — a key press on the trigger or on an
|
||||
item, against pointer movement over one — and the border follows that. Verified in both
|
||||
browsers: the border appears only after a key press and leaves again on the next pointer
|
||||
move. AC24's manual check could now name a unit test; owner's call.
|
||||
|
||||
### 2026-09-11 — acceptance checks that could not fail (#4254)
|
||||
- Raised in review: AC22's check ran in jsdom, which lays nothing out, so it passed whatever
|
||||
the layout did — the defect it exists for went unseen until a screenshot. Its check now
|
||||
names the `CallFooter` "With Many Devices" story, which measures the geometry in a browser
|
||||
with the device count fixed. The unit test and the short-window e2e stay as they are; the
|
||||
criterion names the one that would fail.
|
||||
- AC24 was a manual check, and the border it describes turned out to be wrong in both
|
||||
browsers for different reasons. Its check now names an e2e test that reads the painted
|
||||
outline as the pointer and the keyboard take turns.
|
||||
- The same audit found AC15: its check asserted the attribute the stylesheet keys off, not
|
||||
the greying, so losing the CSS rule would not have failed it. Re-anchored to the meter's
|
||||
own story, which reads the applied opacity.
|
||||
- Every edit to `## Acceptance criteria` was made with the owner's authorisation. The
|
||||
remaining manual checks are AC6, which needs Safari, and AC21, which needs a screen
|
||||
reader.
|
||||
|
||||
### 2026-09-11 — e2e tests must not assume how many devices a browser reports
|
||||
- Twice now a new e2e test has failed in CI for the same reason: how many devices a browser
|
||||
invents differs between them and from a developer machine. Chromium reports several
|
||||
microphones and outputs; Firefox in CI reports one microphone and no outputs, so the menu
|
||||
holds a single selectable row. A test that hovered the second row timed out there.
|
||||
- Tests in `playwright/audio-menu.spec.ts` now assert only what holds at any device count.
|
||||
Where a fixed device list is what makes the check meaningful, it belongs in a story.
|
||||
|
||||
## PRs
|
||||
|
||||
- #4254 — draft, one commit per slice — AC1–AC27 (AC6, AC21, AC24 manual by the reviewer;
|
||||
AC26, AC27 on Chromium only)
|
||||
@@ -1,75 +0,0 @@
|
||||
# Audio quick menu — exploration notes
|
||||
|
||||
Not load-bearing. Nothing here constrains the implementation; constraints live in the
|
||||
feature spec.
|
||||
|
||||
## Findings from the spike
|
||||
|
||||
- **The shared slider names itself after its tooltip.** `Slider` wraps its thumb in a
|
||||
compound `Tooltip`, which sets `aria-labelledby` on the thumb and so overrides the
|
||||
`label` prop's `aria-label`. A screen reader announced the volume control as "50%".
|
||||
Worked around at the call site by putting the control's name into the tooltip text.
|
||||
The same defect exists in Settings → Audio today and would be better fixed in
|
||||
`Slider` itself.
|
||||
- **Arrow keys inside the menu.** The radix menu treats arrow keys as navigation
|
||||
between items, so a slider placed inside it never receives them. The slider row stops
|
||||
propagation for arrow/Home/End. Without this the slider is mouse-only, which would
|
||||
have failed AC20 silently.
|
||||
- **jsdom gaps.** The radix slider needs `ResizeObserver` and the meter needs
|
||||
`AudioContext`; neither exists in jsdom, so both are stubbed per test file rather
|
||||
than globally.
|
||||
- **Snapshot churn.** `LobbyView` and `InCallView` snapshots changed because the mic
|
||||
button is now wrapped in the chevron container in cases that previously rendered a
|
||||
bare button. Reviewed before updating; the change is the intended one.
|
||||
|
||||
## Findings from design review
|
||||
|
||||
- **An invented design token fails silently.** The meter's bars were written with
|
||||
`--cpd-radius-pill-effective`; the token is `--cpd-radius-pill-effect`. An unknown
|
||||
custom property makes the whole `border-radius` declaration invalid, so the bars
|
||||
rendered square with no error anywhere. Worth grepping for the token name before
|
||||
using one.
|
||||
- **A sticky child paints over the menu's border.** `FloatingMenu` draws its border as
|
||||
an `outline` inset by one border width rather than as a real border. A
|
||||
`position: sticky` child is positioned, so it paints above that outline and its
|
||||
opaque background covered the border at the left and right edges. Fixed with a
|
||||
matching inline margin on the sticky row.
|
||||
- **The bar geometry has two coupled knobs.** The bars share the row, so their width
|
||||
is whatever the gaps leave over: raising the gap thins them and raising the count
|
||||
thins them too. Moving both at once cancels out, which cost two review rounds.
|
||||
Across the inset row (~248px at the menu's 340px maximum):
|
||||
|
||||
| bars | gap | bar width | bar:gap |
|
||||
| ---- | --- | --------- | ------- |
|
||||
| 20 | 8px | 4.8px | 0.60 |
|
||||
| 18 | 8px | 6.2px | 0.78 |
|
||||
| 17 | 8px | 7.1px | 0.88 |
|
||||
| 16 | 8px | 8.0px | 1.00 |
|
||||
|
||||
The design sits near 0.8, hence 18 bars at an 8px gap.
|
||||
|
||||
- **The bars line up with the label column, not the menu edge.** A menu item keeps a
|
||||
`4x` margin after its label plus a `2x` column reserved for the chevron, on top of
|
||||
the menu's own `4x` padding, so the meter's inline-end inset is `2 * 4x + 2x`.
|
||||
|
||||
## Still open with design
|
||||
|
||||
- The mockup marks the active device with a radio circle; the menu marks it with a
|
||||
device icon and a checkmark, which is the existing pattern shared with the camera
|
||||
chevron. Changing it would either change the camera menu too or make the two
|
||||
inconsistent. Left as-is pending design sign-off.
|
||||
|
||||
## Not done
|
||||
|
||||
- Storybook interaction tests were not run; that project drives a real browser.
|
||||
`AudioLevelMeter.stories.tsx` covers the five meter states for visual review.
|
||||
- Sticky positioning, the focus border and the bar geometry are all unverified by
|
||||
tests: jsdom has no layout. The tests assert the DOM structure that makes them
|
||||
possible, and the rest was checked by eye in a browser.
|
||||
- No measurement of what the meter's second capture costs while a call is running.
|
||||
This is the main thing left before sign-off, and it is the decision most likely to
|
||||
be revisited: if the cost is material, D1 gets narrower.
|
||||
- `oxfmt` covers markdown, and `AGENTS.md`, `_TEMPLATE.product.md` and the product
|
||||
spec do not match its style, so `pnpm format:check` fails on them. Left alone: the
|
||||
product spec is not editable after `draft` and the other two are process documents.
|
||||
Either format them in a separate commit or exclude the folder from `oxfmt`.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Feature Specification: Audio quick menu
|
||||
|
||||
**Feature Branch**: `[not yet created]`
|
||||
**Created**: 2026-09-09
|
||||
**Feature Spec**: `2026-09_Audio_Quick_Menu.md` <!-- status lives in the feature spec's frontmatter, not here -->
|
||||
**Owner**: fkwp
|
||||
**Source**: this file
|
||||
**Input**: User description: "The chevron next to the mic button only offers microphone selection ('Mic Source'). Extend it into a combined audio quick-menu that also lets the user pick the speaker/output. Microphone check: the live, input-level meter that visibly reacts to the user's voice — so they immediately see the mic is active. For now this is web/desktop only (mobile is another story). Stretch: validate if it's possible to also place the sound effects volume in that new chevron menu."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Choose where the call is heard, without leaving the call (Priority: P1)
|
||||
|
||||
A participant realises the call is coming out of the laptop speakers instead of their headset. They open the chevron next to the microphone button, see a speaker section alongside the microphone section, pick their headset, and hear the difference immediately — the call view never goes away and the menu stays open so they can confirm the choice.
|
||||
|
||||
**Why this priority**: This is the change that removes a full detour through the settings dialog during a live call, and it is the reason the menu is being reshaped at all. It is valuable on its own, with no dependency on the level meter or the volume slider.
|
||||
|
||||
**Independent Test**: With two or more audio outputs attached, join a call, open the microphone chevron, and switch output. Audio follows the choice, and the same choice is visible in settings.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a participant in a call with two or more audio outputs available, **When** they open the microphone chevron, **Then** the menu shows a microphone section and a speaker section under one "Audio controls" heading, each marking the active device.
|
||||
2. **Given** the menu is open, **When** the participant selects a different speaker, **Then** call audio moves to that device, the row is marked active, and the menu stays open.
|
||||
3. **Given** the participant switched output from the menu, **When** they open settings, **Then** settings shows the same output as the active one.
|
||||
4. **Given** a browser that does not permit choosing an audio output, or a machine with exactly one output, **When** the participant opens the menu, **Then** the speaker section still shows which output is in use, presented as information rather than a choice.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Confirm the microphone is picking me up (Priority: P2)
|
||||
|
||||
Before speaking — or after being told "we can't hear you" — a participant opens the same menu and speaks. A level indicator next to the microphone section moves with their voice, so they can tell within a second whether the selected microphone is live, and can try another one in the same menu if it is not.
|
||||
|
||||
**Why this priority**: It answers the single most common audio complaint, but the menu is useful without it, and it carries the largest open design and privacy questions.
|
||||
|
||||
**Independent Test**: Open the menu, speak, and watch the indicator move; select a different microphone in the same menu and confirm the indicator follows it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the menu is open and the participant is unmuted, **When** they speak, **Then** the indicator visibly rises and falls with their voice.
|
||||
2. **Given** the menu is open and the participant is muted, **When** they speak, **Then** the indicator still rises and falls, so they can verify the microphone before unmuting.
|
||||
3. **Given** the indicator is live, **When** the participant closes the menu, **Then** the indicator stops capturing from the microphone.
|
||||
4. **Given** the menu is open, **When** the participant selects a different microphone, **Then** the indicator follows the newly selected microphone and the menu stays open.
|
||||
5. **Given** the selected microphone produces no signal, **When** the participant speaks, **Then** the indicator rests in an idle state rather than showing an error.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Adjust sound-effect volume without leaving the call (Priority: P3)
|
||||
|
||||
Join and leave chimes and reaction sounds are too loud during a call. The participant opens the audio menu and moves a sound-effects slider until the next chime sits where they want it.
|
||||
|
||||
**Why this priority**: A convenience placement of an existing setting. It is the smallest slice, it can be dropped without touching the other two, and the value it adds is discoverability rather than new capability.
|
||||
|
||||
**Independent Test**: Move the slider in the menu, trigger a sound effect, and confirm both the audible change and that settings shows the new value.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the menu is open, **When** the participant moves the sound-effects slider, **Then** the next sound effect plays at the new level.
|
||||
2. **Given** the participant changed the level in the menu, **When** they open settings, **Then** the settings slider shows the same level, and changing it there is reflected back in the menu.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- The menu is opened while muted: the operating system's "microphone in use" indicator turns on even though the participant is muted. No disclosure in the menu is required — that indicator is expected to behave the same whether the participant is muted or not, and mute state and microphone sampling are independent of one another.
|
||||
- The active microphone or speaker is unplugged while the menu is open — the menu must settle on a device that exists rather than showing a stale selection.
|
||||
- No microphone is present at all: the menu says so where the level indicator would be. An indicator at rest is what a working but silent microphone shows, so the two must not look alike.
|
||||
- Microphone permission has not been granted yet — most likely when the menu is opened in the lobby before joining.
|
||||
- Another application or browser tab holds the microphone exclusively, so the level indicator receives no signal even though the device is fine. Rare on current operating systems, which share the microphone between applications; where it does happen, the indicator shows a greyed-out state.
|
||||
- Sound-effects volume is set to zero: effects are silent but the rest of the call audio is unaffected.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The audio quick menu MUST be opened from the chevron next to the microphone button and MUST present its controls under a single "Audio controls" heading.
|
||||
- **FR-002**: The menu MUST list the available microphones, mark the active one, and switch input immediately on selection while staying open.
|
||||
- **FR-003**: The menu MUST list the available audio outputs, mark the active one, and switch output immediately on selection while staying open.
|
||||
- **FR-004**: The microphone, speaker and sound-effects controls MUST be visually separated from one another within the menu.
|
||||
- **FR-005**: When the audio output cannot be changed — the browser does not permit choosing an output, or only one output exists — the menu MUST still show the active output as a non-selectable row.
|
||||
- **FR-006**: The output selected in the menu and the output selected in settings MUST be one and the same selection; a change in either MUST be reflected in the other.
|
||||
- **FR-007**: The menu MUST show a live indicator of the sound level at the selected microphone.
|
||||
- **FR-008**: The indicator MUST respond while the microphone is muted as well as while it is unmuted.
|
||||
- **FR-009**: The indicator MUST become live when the menu opens and MUST stop capturing from the microphone when the menu closes.
|
||||
- **FR-010**: Selecting a different microphone MUST re-point the indicator at that microphone without closing the menu.
|
||||
- **FR-011**: When the selected microphone produces no signal, the indicator MUST show an idle state rather than an error.
|
||||
- **FR-012**: Users MUST be able to change the sound-effects volume from the menu, taking effect from the next sound effect onward.
|
||||
- **FR-013**: Sound-effects volume MUST remain adjustable in settings, and both controls MUST read and write the same stored value.
|
||||
- **FR-014**: The menu MUST be available before joining and during a call on web and desktop, and before joining on every platform — the pre-join chevron already exists everywhere and MUST show the full menu.
|
||||
- **FR-015**: During a call on mobile, the microphone chevron MUST behave as it does today; the menu is not introduced there.
|
||||
- **FR-016**: Every control in the menu MUST be reachable and operable by keyboard alone.
|
||||
- **FR-017**: The level indicator MUST be focusable, and while it holds focus it MUST announce its current state to a screen reader.
|
||||
- **FR-018**: When microphone permission has been denied, the menu MUST show a hint saying so, in place of a silent idle indicator.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Changing the audio output never requires opening a dialog that covers the call: the other participants remain visible from the moment the participant reaches for the control until the new output is in use.
|
||||
- **SC-002**: With the menu open, speech produces visible movement in the level indicator while the person is still speaking. No millisecond target: perceived latency is explicitly not critical for this indicator.
|
||||
- **SC-003**: Every control in the menu, including the sound-effects slider, is reachable and operable with the keyboard alone, verified on the supported desktop browsers.
|
||||
@@ -1,415 +0,0 @@
|
||||
# FEATURE_SPEC — Agent Instructions
|
||||
|
||||
This folder holds **feature specs distilled from experiments**. A spec is the durable
|
||||
artifact of an exploration; the exploration branch is not. Each spec must contain
|
||||
enough information for an agent to produce mergeable PRs against a *current* `main`
|
||||
without access to the original branch.
|
||||
|
||||
## What this folder is not
|
||||
|
||||
- Not a design-doc archive. Specs that are not intended to be implemented do not
|
||||
belong here.
|
||||
- Not a place for narrative. Prose that does not constrain the implementation or its
|
||||
verification should be cut.
|
||||
- Not a substitute for the PR description. The spec explains *why* and *what must be
|
||||
true*; the PR explains *this diff*.
|
||||
|
||||
## File naming
|
||||
|
||||
`YYYY-MM_Snake_Case_Title.md` — e.g. `2026-09_Raise_Hand_Improvements.md`
|
||||
|
||||
The filename is immutable once committed. Renames break links from PRs, issues and
|
||||
the drift log. If scope changes fundamentally, write a new spec and set
|
||||
`supersedes:` / `superseded_by:`.
|
||||
|
||||
A sidecar `YYYY-MM_Snake_Case_Title.product.md` holds the product spec — user stories,
|
||||
functional requirements, success criteria. Copy `_TEMPLATE.product.md`. Product owns
|
||||
it; see "Brainstorming: the product spec".
|
||||
|
||||
An optional sidecar `YYYY-MM_Snake_Case_Title.notes.md` holds lessons learned,
|
||||
narrative, dead-end logs and anything else that is worth keeping but does not
|
||||
constrain the implementation. Rules:
|
||||
|
||||
- The sidecar is **never load-bearing**. Agents do not read it to derive
|
||||
implementation, and nothing in it may contradict or extend the spec. If a note
|
||||
constrains the work, it belongs in `## Decisions`, `## Rejected alternatives` or
|
||||
`## Measurements` instead.
|
||||
- Agents may append to it (an observation from a drift check, a wrong turn during
|
||||
implementation) and must never rewrite or delete existing content.
|
||||
- An agent that is `blocked` may read the sidecar to draft an open question, but
|
||||
quotes what it found rather than acting on it.
|
||||
- No sidecar is required. An empty one is worse than none.
|
||||
|
||||
## Style: facts only
|
||||
|
||||
Applies to the spec file only. The product spec is prose by design and the notes
|
||||
sidecar is unconstrained. The spec is re-read on every regeneration, where prose costs context
|
||||
and dilutes the contract, so:
|
||||
|
||||
- Bullets, not paragraphs. Declarative present tense. No motivation, no background,
|
||||
no summary of what the code currently does beyond locating the change.
|
||||
- Name things instead of describing them — a path, a symbol, an event type, a command,
|
||||
an event id. A reference is shorter and more precise than a sentence about it. Which
|
||||
kind of reference is allowed depends on the section; see "Reference durability".
|
||||
- One line per decision plus at most one sentence of rationale. If the rationale needs
|
||||
more, the extra goes in the sidecar.
|
||||
- No judgement adjectives (clean, elegant, proper, obviously) and no hedging (should
|
||||
probably, might be nice). Either it is a constraint or it is not in the spec.
|
||||
- `## Problem` is at most five bullets.
|
||||
- Target under 200 lines for the whole spec; over 300 lines, split the feature or move
|
||||
material to the sidecar.
|
||||
|
||||
The test for any line: does it constrain the implementation, constrain its
|
||||
verification, or record a measured fact? If not, delete it or move it to the sidecar.
|
||||
|
||||
## Reference durability
|
||||
|
||||
The spec must stay implementable against a `main` nobody has seen yet, so which
|
||||
references are allowed depends on how fast they rot. Most to least durable:
|
||||
|
||||
1. Protocol identifiers — `m.reaction`, `m.call.member`, `m.relates_to`, a reaction
|
||||
key. MSC-bound; effectively permanent.
|
||||
2. Observable behaviour. Permanent.
|
||||
3. Concept names — membership, participation, session.
|
||||
4. Test names. Survive file moves and renames, because the test moves with the code.
|
||||
5. Exported symbols. Survive refactors sometimes.
|
||||
6. File paths. Rot fast.
|
||||
7. Line numbers. Never.
|
||||
|
||||
Per section:
|
||||
|
||||
- `## Problem`, `## Measurements`, `## Slicing plan`, `## Drift log` — any reference,
|
||||
paths and internal symbols included. These describe the codebase **as of `base`**. A
|
||||
reference going stale here is a finding for the drift check, not a defect, and
|
||||
removing the concrete references would leave the drift check with nothing to verify.
|
||||
- `## Decisions`, `## Acceptance criteria` — levels 1 to 4 only. No file paths, no
|
||||
internal symbols. This is the part that has to hold against a future `main`.
|
||||
- An acceptance criterion that is not user-observable is tagged `[internal]` and
|
||||
anchored by **its test name**, not by the symbol under test. The test name moves with
|
||||
a refactor; the symbol does not.
|
||||
|
||||
`[internal]` ACs are legitimate and expected: an invariant that no current consumer can
|
||||
observe is exactly what protects the consumer that does not exist yet. Tagging them
|
||||
keeps visible which ACs defend a product requirement and which defend a code
|
||||
invariant.
|
||||
|
||||
## Required frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
status: draft # see state machine below
|
||||
blocked_from: null # state to return to when leaving `blocked`
|
||||
title: Raise hand improvements
|
||||
owner: <human handle> # a human, always
|
||||
signoff: # join condition for leaving exploration.signoff
|
||||
eng: pending
|
||||
design: pending
|
||||
product: pending
|
||||
experiment_ref: <branch name or commit sha of the spike, if any>
|
||||
base: <sha of main the spec was validated against>
|
||||
repos: [matrix-js-sdk, element-call] # in dependency order
|
||||
specs: [MSC4143] # protocol documents touched, if any
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
---
|
||||
```
|
||||
|
||||
`base` is mandatory and is what makes drift detectable. `owner` is a human even when
|
||||
every section was drafted by an agent.
|
||||
|
||||
## Brainstorming: the product spec
|
||||
|
||||
The usual entry point is a human handing an agent a one-line feature idea. The output
|
||||
of that session is `<stem>.product.md`, not code and not a feature spec. Copy
|
||||
`_TEMPLATE.product.md` and fill it.
|
||||
|
||||
Ownership: an agent may create and fill this file during `brainstorm`. From `draft`
|
||||
onwards **product owns it and agents do not edit it** — not to fix wording, not to
|
||||
resolve a marker, not to reconcile it with the code. Agents raise mismatches in the
|
||||
feature spec's `## Open questions`.
|
||||
|
||||
If product's source of truth lives outside the repo (a tracker, a design doc), put its
|
||||
URL in the header `**Source**:` line and keep the requirement text and ids in the repo
|
||||
anyway. The ids are what the feature spec references; a link alone cannot be traced.
|
||||
|
||||
Agent behaviour during the session:
|
||||
|
||||
- **Ask, do not assume.** An unanswered question is recorded as
|
||||
`[NEEDS CLARIFICATION: <the question>]`, inline where the answer would go. Filling a
|
||||
gap with a plausible guess is the main failure mode of this state — the marker is a
|
||||
deliverable, not a defect.
|
||||
- Ask in one round of at most five questions, ordered by how much the answer changes
|
||||
the shape of the feature. Do not interview.
|
||||
- **No implementation in the product spec.** No file paths, symbols, libraries, event
|
||||
types, API shapes or framework names. If a requirement cannot be stated without them,
|
||||
it is not a product requirement.
|
||||
- Every user story must be independently deployable and demonstrable on its own. A
|
||||
story that only makes sense once another story ships is not a story; merge it.
|
||||
- More than four priority levels means more than one feature. Split before `draft`.
|
||||
- Never invent an SC number. Ask for the target, or mark it
|
||||
`[NEEDS CLARIFICATION]`.
|
||||
|
||||
For protocol and infrastructure work, success criteria may be expressed as observable
|
||||
client behaviour or a wire-level invariant ("a client joining mid-session converges
|
||||
within one sync"), but still never as a code path or an internal API.
|
||||
|
||||
`FR-###` and `SC-###` ids are immutable once assigned. Product may revise the document
|
||||
at any time, including after `in_production`, but a changed requirement gets a **new
|
||||
id** and the old one is marked superseded — never edited in place. Renumbering silently
|
||||
breaks every AC that references it, and there is no way to detect that afterwards.
|
||||
|
||||
A new or changed FR after `implement` does not amend the current spec's scope. It is
|
||||
either a new feature spec or the next iteration; the mapping table simply shows an
|
||||
unmapped FR, which is the intended signal.
|
||||
|
||||
## Traceability
|
||||
|
||||
The product spec states intent; the feature spec states what must be true and how it is
|
||||
checked. They are kept in sync by ids, not by duplication.
|
||||
|
||||
- Every `FR-###` maps to at least one acceptance criterion. Tag the AC with its
|
||||
origin: `AC2 [FR-003] — <observable behaviour>`.
|
||||
- Every `SC-###` maps to an entry in `## Measurements` with a method. An SC with no
|
||||
measurement blocks `exploration.signoff`.
|
||||
- Every user story maps to at least one slice. **Slice order follows story priority**
|
||||
(P1 first) unless a technical dependency overrides it, and the override is recorded
|
||||
in the slicing plan with its reason.
|
||||
- An AC with no `FR` origin is allowed and expected — implementation-level invariants
|
||||
have no counterpart in the product spec. The converse is not: an unmapped FR is an
|
||||
incomplete spec.
|
||||
- Agents do not resolve a mismatch between the two. The product spec wins on intent,
|
||||
the feature spec wins on verification; a genuine contradiction is `blocked`.
|
||||
|
||||
## Status state machine
|
||||
|
||||
Transitions are explicit; an agent may perform only the ones marked (agent).
|
||||
|
||||
```
|
||||
brainstorm ─► draft ─► exploration ─► implement ─► qa ─► in_production
|
||||
│ │ │
|
||||
└──────────────┴────────┴──► blocked ──► (blocked_from)
|
||||
│
|
||||
└──► abandoned
|
||||
```
|
||||
|
||||
Exploration has sub-states, written dotted in `status`:
|
||||
|
||||
```
|
||||
exploration.scoping ─► exploration.iterating ─► exploration.signoff
|
||||
▲ │ │
|
||||
└───────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- `brainstorm` → `draft`: human. Requires `<stem>.product.md` to exist with at least one
|
||||
P1 story carrying acceptance scenarios, every FR and SC id assigned, and every
|
||||
remaining `[NEEDS CLARIFICATION]` copied into the feature spec's `## Open questions`.
|
||||
- `draft` → `exploration.scoping`: human. Requires `## Problem` and `## Out of scope`.
|
||||
Scoping before the branch exists is what keeps exploration from becoming the
|
||||
long-lived branch this folder replaces.
|
||||
- `exploration.scoping` → `.iterating`: human. `experiment_ref` must name the branch.
|
||||
- `.iterating` → `.signoff`: human. Requires `## Decisions`, `## Acceptance criteria`,
|
||||
`## Rejected alternatives` and `## Measurements` to be complete, no
|
||||
`[NEEDS CLARIFICATION]` left in the product spec, and full traceability (below).
|
||||
- `.signoff` → `implement`: **only when all three sign-offs in `signoff:` are `ok`.**
|
||||
This is a join, not a sequence — a single reviewer cannot advance it. On entering
|
||||
`implement`, the exploration branch stops being a base: it is reference material,
|
||||
and implementation branches off current `main` (see "Turning a spec into PRs").
|
||||
- `.signoff` → `.iterating`: human, on any sign-off returning changes. Reset the
|
||||
rejecting role to `pending`; leave the others as they are.
|
||||
- `implement` → `qa`: (agent) when every acceptance criterion is covered, as recorded
|
||||
in `## PRs`, and the feature flag is enabled in the QA environment.
|
||||
- `qa` → `in_production`: human. The flag is on by default in production.
|
||||
- `qa` → `implement`: (agent) when a QA finding needs code. See "QA feedback".
|
||||
- any active state → `blocked`: (agent) when drift or an unresolved question prevents
|
||||
progress. Requires a drift-log entry and `blocked_from: <state>`. Do not guess and
|
||||
continue. Leaving `blocked` returns to `blocked_from`, never to a fixed state.
|
||||
- any state → `abandoned`: human only.
|
||||
|
||||
`in_production` is terminal. A production regression does not reopen the spec: it
|
||||
either is an ordinary bug fix with no spec, or it is a new spec. The acceptance
|
||||
criteria remain as the regression contract (see "QA feedback").
|
||||
|
||||
## QA feedback
|
||||
|
||||
The point of the `qa` state is not to record that testing happened. It is to make the
|
||||
spec a better regeneration source than it was before implementation. So:
|
||||
|
||||
- Every reproduced QA finding becomes **either** a new acceptance criterion **or** a
|
||||
new entry in `## Rejected alternatives`. Never a prose note, never only a bug
|
||||
tracker link.
|
||||
- (agent) may **append** ACs during `qa`, with provenance:
|
||||
`AC7 [qa: <issue ref>] — <observable behaviour>` plus its check. Appending is
|
||||
allowed; rewriting or reordering existing ACs is not.
|
||||
- A finding that invalidates a decision does not become an AC. It sets `blocked` — a
|
||||
decision is human-owned in every state.
|
||||
- A finding that is out of scope goes to `## Out of scope` with its issue ref, so the
|
||||
next regeneration does not treat it as a gap.
|
||||
|
||||
This also applies after `in_production`: a production bug that reveals a missing AC is
|
||||
appended with `[prod: <ref>]` provenance without any state change.
|
||||
|
||||
## Required sections
|
||||
|
||||
Order matters — agents read top-down and the first three sections are the contract.
|
||||
|
||||
1. **`## Problem`** — up to five bullets. What breaks today, for whom, observably,
|
||||
with the file or symbol where it breaks.
|
||||
2. **`## Decisions`** — the choices the exploration settled, each with a one-line
|
||||
rationale. **Human-owned. An agent must never add, remove or reword a decision.**
|
||||
If a decision is missing, set `blocked` and ask.
|
||||
3. **`## Acceptance criteria`** — the load-bearing section. Each item must be
|
||||
mechanically checkable and name its check:
|
||||
- `AC1 [FR-002] — <observable behaviour>` / `check: yarn vitest run src/reactions/ReactionsReader.test.tsx -t "backfill"`
|
||||
- `AC2 [FR-002] — <observable behaviour>` / `check: manual, <exact steps>`
|
||||
- `AC3 [internal] — <invariant>` / `check: yarn vitest run <file> -t "<test name>"`
|
||||
An acceptance criterion without a check is a wish; reject the spec (`blocked`).
|
||||
4. **`## Rejected alternatives`** — what the experiment tried and why it failed,
|
||||
including measurements. This exists so regeneration does not re-walk dead ends. If
|
||||
this section is empty for a spec with an `experiment_ref`, the spec is incomplete.
|
||||
5. **`## Measurements`** — numbers from the experiment with the method used to obtain
|
||||
them (command, hardware, load profile). Numbers without a method are not usable as
|
||||
regression baselines and must be marked `unverified`. A baseline states its direction: a
|
||||
count that unrelated work grows — tests passing, test files — is a floor (`≥ n`); a
|
||||
cost — latency, CPU, bundle size — is a ceiling (`≤ n`). An exact count is stale as
|
||||
soon as `main` moves and is not a drift finding.
|
||||
6. **`## Out of scope`** — explicit non-goals, so agents stop expanding the diff.
|
||||
7. **`## Slicing plan`** — ordered, independently mergeable PRs (see below).
|
||||
8. **`## Drift log`** — append-only, agent-written.
|
||||
9. **`## PRs`** — append-only list of `#<n> — <state> — <ACs covered>`.
|
||||
|
||||
Optional: `## Open questions` (blocks `exploration.signoff`), `## Migration / compat` (mandatory
|
||||
whenever wire format, event schema, or a published API changes).
|
||||
|
||||
## Turning a spec into PRs
|
||||
|
||||
Run these steps in order. Do not skip step 2.
|
||||
|
||||
1. **Fetch and pin.** `git fetch origin` and record `origin/main`'s sha as `head`.
|
||||
Never start from the exploration branch, and never rebase it — the branch is
|
||||
reference material, not a base.
|
||||
2. **Drift check** against `base..head`:
|
||||
- Do all files, symbols and modules named in the spec still exist?
|
||||
- Have the invariants in `## Decisions` been contradicted by changes in main?
|
||||
- Do the commands in `## Acceptance criteria` still run?
|
||||
Write the result as a drift-log entry. On a contradicted decision: set `blocked`,
|
||||
do not implement.
|
||||
3. **Re-derive the slicing plan** if drift changed it. Each slice must:
|
||||
- build and pass CI on its own,
|
||||
- be reviewable in one sitting (target < 400 changed lines, hard stop at 800 —
|
||||
split further rather than exceeding it),
|
||||
- map to at least one acceptance criterion, and
|
||||
- land behind a flag or be inert if it is not the last slice of a user-visible
|
||||
change.
|
||||
4. **Implement one slice at a time**, branch `spec/<spec-slug>/<n>-<short>` off the
|
||||
pinned `head`. Tests for the slice's ACs go in the same PR as the code. A PR merges
|
||||
only when every check of every AC it lists passes on that PR: automated checks in
|
||||
CI, manual checks performed by a human reviewer with the result recorded in the PR
|
||||
description. An AC is *covered* once such a PR has merged.
|
||||
5. **Open the PR** linking the spec path and listing the ACs it covers. Update
|
||||
`## PRs` and `status`.
|
||||
6. **Do not touch merged or reviewed slices.** See the regeneration rule.
|
||||
|
||||
## The regeneration rule
|
||||
|
||||
Regeneration from spec is only allowed **before a human has reviewed the PR**.
|
||||
|
||||
Once review has started, the PR is a shared artifact: rebase, fix forward, push
|
||||
incremental commits. Regenerating discards the reviewer's context and silently
|
||||
invalidates their approvals. If regeneration is unavoidable after review, close the
|
||||
old PR with an explanation and open a new one — never force-push a fresh generation
|
||||
over a reviewed branch.
|
||||
|
||||
## Cross-repo and protocol work
|
||||
|
||||
When `repos` has more than one entry, PRs are opened in `repos` order and each PR
|
||||
body states which upstream PR it depends on. Merging out of order is a spec
|
||||
violation, not an optimisation.
|
||||
|
||||
When `specs` is non-empty:
|
||||
- the implementation PR references the section of the MSC it implements,
|
||||
- unmerged MSCs require the implementation to be feature-flagged and documented as
|
||||
unstable (unstable prefixes, not stable identifiers), and
|
||||
- any deviation from the MSC text is recorded in `## Drift log`, not silently
|
||||
resolved in favour of the code.
|
||||
|
||||
## Drift-log format
|
||||
|
||||
Append-only, newest last:
|
||||
|
||||
```
|
||||
### 2026-09-14 — drift check (base 4f2a1c9 → head 9b17e02)
|
||||
- `ReactionsReader.getLastReactionEvent` now takes a `CallMembership` instead of an
|
||||
event id → AC2 and AC3 check commands still valid, slice 3 needs rework
|
||||
- BLOCKING: decision D2 assumes a membership event change may keep a hand raised; main
|
||||
now clears all reaction relations on membership replacement. Needs owner decision.
|
||||
```
|
||||
|
||||
Drift entries are never rewritten or deleted, including entries that were later
|
||||
resolved. The log is the record of why the implementation diverged from the
|
||||
exploration.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Never fill a `[NEEDS CLARIFICATION]` marker with a guess, in any state.
|
||||
- Never put file paths, symbols or library names in the product spec.
|
||||
- Never reference a file path or an internal symbol in `## Decisions` or
|
||||
`## Acceptance criteria`. Behaviour, protocol identifiers, concepts and test names
|
||||
only.
|
||||
- Never edit `<stem>.product.md` after `draft`, and never renumber an FR or SC id.
|
||||
- Never add a line that does not constrain the implementation, constrain its
|
||||
verification, or record a measured fact. It goes in the sidecar or nowhere.
|
||||
- Never modify `## Decisions`, `## Problem`, or `## Acceptance criteria`. These are
|
||||
the human contract. Propose changes in `## Open questions` and set `blocked`.
|
||||
- Never invent measurements or acceptance checks. Absent is better than plausible.
|
||||
- Never widen scope past `## Out of scope`, even when the fix looks trivial.
|
||||
- Never open a PR while `status` is `draft`, any `exploration.*`, or `blocked`.
|
||||
- Never delete a spec file. Terminal states are `in_production` or `abandoned`.
|
||||
- If the spec and current `main` disagree about how the system works, **main wins as
|
||||
description, the spec wins as intent** — record the difference and stop.
|
||||
|
||||
## Spec template
|
||||
|
||||
```markdown
|
||||
---
|
||||
status: draft
|
||||
blocked_from: null
|
||||
title:
|
||||
owner:
|
||||
signoff:
|
||||
eng: pending
|
||||
design: pending
|
||||
product: pending
|
||||
experiment_ref:
|
||||
base:
|
||||
repos: []
|
||||
specs: []
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
## Decisions
|
||||
- D1 — <decision> — <why>
|
||||
|
||||
## Acceptance criteria
|
||||
- AC1 [FR-###] — <observable behaviour>
|
||||
- check: <command or exact manual steps>
|
||||
- AC2 [internal] — <invariant>
|
||||
- check: <command, anchored by test name>
|
||||
|
||||
## Rejected alternatives
|
||||
- <approach> — <why it failed> — <evidence>
|
||||
|
||||
## Measurements
|
||||
- <metric>: <value> (method: <command / setup>)
|
||||
|
||||
## Out of scope
|
||||
|
||||
## Slicing plan
|
||||
1. <slice> — covers AC1 — depends on: none
|
||||
|
||||
## Drift log
|
||||
|
||||
## PRs
|
||||
```
|
||||
@@ -1,117 +0,0 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Created**: [DATE]
|
||||
**Feature Spec**: `[YYYY-MM_Title.md]` <!-- status lives in the feature spec's frontmatter, not here -->
|
||||
**Owner**: [product handle]
|
||||
**Source**: [URL of the tracker/doc this mirrors, or "this file"]
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
|
||||
## ✨ Key Features
|
||||
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
|
||||
organize the flow of the meeting.
|
||||
organize the flow of the meeting.
|
||||
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||
engagement and interactivity to the conversation.
|
||||
|
||||
@@ -309,6 +309,23 @@ root. The host imports the component from
|
||||
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service-othersite"
|
||||
as_token: "ec_dev_as_token_othersite_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver-1" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server-1:16080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service"
|
||||
as_token: "ec_dev_as_token_main_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_main_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server:6080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server:6080"
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* What a host needs in order to talk about Element Call, without Element Call:
|
||||
* the component's props and the types they are made of, the handle and the
|
||||
* host bridge, the intents a call can be started with and the configuration
|
||||
* each implies.
|
||||
*
|
||||
* This is an entry point of its own, `@element-hq/element-call-component/api`,
|
||||
* so that a host can import it on a path that must stay light — a call model
|
||||
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
|
||||
* itself, and everything it brings, only when a call is shown. The main entry
|
||||
* point re-exports all of it, so a host that does not care may import
|
||||
* everything from there.
|
||||
*
|
||||
* Nothing in here may reach anything that is not a type or a constant: the
|
||||
* point of this module is what it does not import.
|
||||
*/
|
||||
|
||||
import type { Ref } from "react";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { UrlProperties } from "../src/UrlParams";
|
||||
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "../src/UrlConfiguration";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
|
||||
export type { JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// below, which is per call
|
||||
export type { ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent, and
|
||||
// what each intent means by default
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
};
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of the
|
||||
* `supportedLanguages` the main entry point exports, or something that falls
|
||||
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
+6
-96
@@ -36,14 +36,12 @@ import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -62,10 +60,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
@@ -79,102 +75,16 @@ import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
import { useComponentHostBridge } from "./host";
|
||||
import { type ElementCallProps } from "./api";
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
} from "../src/HostBridge";
|
||||
export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of
|
||||
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
|
||||
* to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||
// the host bridge, the intents and what each means — also available without
|
||||
// the component itself from `@element-hq/element-call-component/api`
|
||||
export * from "./api";
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
+28
-4
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -15,6 +15,14 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the main homeserver
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -25,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -37,6 +45,14 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the other homeserver.
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -94,7 +110,10 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -107,12 +126,16 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp:/data:Z
|
||||
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -125,6 +148,7 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp_othersite:/data:Z
|
||||
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
|
||||
@@ -3,17 +3,23 @@
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cbb7be9a54352997748d02c36dc70ee877c705ee3518be31497717c9a2ff0395
|
||||
|
||||
@@ -76,4 +76,7 @@ Code that builds in only one is a bug.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry
|
||||
points: `index.tsx` (the component) and `api.ts` (types, enums and
|
||||
`configurationForIntent`, for a host that must not load the component). `api.ts`
|
||||
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ lines a PR touches.
|
||||
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
|
||||
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
|
||||
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
|
||||
- How often something redraws is testable: drive the frames and count commits, not
|
||||
render calls. An effect with no dependency array runs once per commit.
|
||||
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
|
||||
|
||||
## Storybook
|
||||
|
||||
@@ -39,10 +39,12 @@ Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
|
||||
## Commit and PR readiness
|
||||
|
||||
- Commit once the user confirms direction, or asks for one. Not before.
|
||||
- Before committing, every gate in [AGENTS.md](../../AGENTS.md#gates) is green and
|
||||
the change is covered at the layers [testing.md](./testing.md) asks for. Read the
|
||||
diff against [code-style.md](./code-style.md).
|
||||
- Re-run the whole checklist after any fix. Commit only on green.
|
||||
- Commit once the user confirms direction, or asks for one. Not before. Where the
|
||||
setup has no git identity, a sandbox included, write the message to
|
||||
`commit-msg.txt` and hand it over instead.
|
||||
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
|
||||
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
|
||||
against [code-style.md](./code-style.md) first.
|
||||
- Re-run the whole checklist after any fix. Only on green, either way.
|
||||
- Once a human has started reviewing, fix forward — never force-push a
|
||||
regeneration over a review in progress.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
|
||||
+8
-9
@@ -23,15 +23,6 @@
|
||||
"upload_file": "Upload file"
|
||||
},
|
||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
||||
"audio_menu": {
|
||||
"mic_absent": "No microphone found",
|
||||
"mic_level_active": "Picking up sound",
|
||||
"mic_level_label": "Microphone level",
|
||||
"mic_level_silent": "No sound detected",
|
||||
"mic_permission_denied": "Microphone access is blocked. Allow it in your browser settings.",
|
||||
"mic_unavailable": "Microphone unavailable",
|
||||
"title": "Audio controls"
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Create account",
|
||||
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
|
||||
@@ -172,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",
|
||||
@@ -224,12 +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
-3
@@ -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",
|
||||
|
||||
+203
-197
@@ -5,219 +5,225 @@ 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, test } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
import { installFakeDevices } from "./utils/fake-devices.ts";
|
||||
|
||||
test("audio menu leaves participants visible while switching output", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"Firefox's fake media stream enumerates no audio outputs, so there is nothing to switch.",
|
||||
);
|
||||
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);
|
||||
|
||||
// Reduced motion disables the animations that make these tests flaky.
|
||||
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const page = await creatorContext.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Inviter", "Audio menu", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(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();
|
||||
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Guest");
|
||||
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+/);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
|
||||
// The chevron beside the microphone button opens the audio menu in place.
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(
|
||||
menu.getByRole("heading", { name: "Audio controls" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Pick another output where the browser offers one; where it does not, the
|
||||
// speaker group still names the output in use.
|
||||
const outputs = menu
|
||||
.getByTestId("audio_menu_scroll")
|
||||
.locator('> [role="menuitemradio"]');
|
||||
const count = await outputs.count();
|
||||
if (count > 1) {
|
||||
// Pinned by position: a locator on "the unchecked row" would re-resolve to
|
||||
// the previously active row once the click has moved the check mark.
|
||||
const other = outputs.nth(await firstUncheckedIndex(outputs, count));
|
||||
await other.click();
|
||||
await expect(other).toHaveAttribute("aria-checked", "true");
|
||||
await expect(menu).toBeVisible();
|
||||
} else {
|
||||
await expect(menu.getByTestId("speaker_readonly")).toBeVisible();
|
||||
}
|
||||
|
||||
// Nothing covered the call: the other participant is still on screen with
|
||||
// the menu open, and after it closes.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
});
|
||||
|
||||
test("level indicator moves with microphone input", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"Firefox has no audio backend on the CI runner, so the level stays at zero there. It passes against Firefox elsewhere, including CI's own Docker image over plain HTTP.",
|
||||
);
|
||||
const context = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Speaker", "Level meter", true);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(1);
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
const meter = page.getByRole("menu").getByRole("meter");
|
||||
await expect(meter).toBeVisible();
|
||||
|
||||
// The browser's fake microphone plays a tone, so the meter has to leave its
|
||||
// resting level while the menu is open.
|
||||
await expect
|
||||
.poll(async () => Number(await meter.getAttribute("aria-valuenow")), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("audio menu is keyboard operable in a real browser", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
'Firefox headless drives page.keyboard.press("Tab") unreliably, as reconnect.spec.ts records.',
|
||||
);
|
||||
const context = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Keys", "Keyboard menu", true);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(1);
|
||||
|
||||
// Open from the chevron; the first device row takes focus.
|
||||
await page.getByRole("button", { name: "Microphone" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
const rows = menu.getByRole("menuitemradio");
|
||||
await expect(rows.first()).toBeFocused();
|
||||
|
||||
// Arrow keys walk the device rows, where the browser lists more than one.
|
||||
if ((await rows.count()) > 1) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(rows.nth(1)).toBeFocused();
|
||||
}
|
||||
|
||||
// Tab reaches the meter and then the slider; arrows adjust the slider and
|
||||
// leave the menu open.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(menu.getByRole("meter")).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
const slider = menu.getByRole("slider");
|
||||
await expect(slider).toBeFocused();
|
||||
const before = Number(await slider.getAttribute("aria-valuenow"));
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect
|
||||
.poll(async () => Number(await slider.getAttribute("aria-valuenow")))
|
||||
.toBeGreaterThan(before);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Shift+Tab goes back; Escape closes.
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(menu.getByRole("meter")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("audio menu stays inside a short window", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
viewport: { width: 1280, height: 560 },
|
||||
// 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");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Devices", "Long lists", true);
|
||||
await page.getByTestId("videoTile").first().waitFor();
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
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");
|
||||
|
||||
// However many devices the browser reports, the menu fits the window and
|
||||
// the heading and the slider are on screen with it. How many devices that
|
||||
// is differs between browsers, so the case where the lists actually
|
||||
// overflow is covered by the CallFooter "With Many Devices" story, which
|
||||
// fixes the device count.
|
||||
await expect(menu).toBeInViewport({ ratio: 1 });
|
||||
await expect(
|
||||
menu.getByRole("heading", { name: "Audio controls" }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(menu.getByRole("slider")).toBeInViewport({ ratio: 1 });
|
||||
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);
|
||||
|
||||
// The menu itself never becomes the scroller: that would carry the heading
|
||||
// out of view, which is what the scroll area exists to prevent.
|
||||
await expect
|
||||
.poll(async () => menu.evaluate((el) => el.scrollHeight <= el.clientHeight))
|
||||
.toBe(true);
|
||||
});
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Microphone", "Fake Microphone 2");
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Speaker", "Fake Speaker 2");
|
||||
|
||||
test("the focus border follows the keyboard and not the pointer", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Focus", "Focus border", true);
|
||||
await page.getByTestId("videoTile").first().waitFor();
|
||||
// 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 page.getByRole("button", { name: "Microphone" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
|
||||
// However many devices this browser reports, there is at least one row.
|
||||
const row = menu.getByRole("menuitemradio").first();
|
||||
const box = await row.boundingBox();
|
||||
if (box === null) throw new Error("Expected the row to be laid out");
|
||||
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
const borderOfFocused = async (): Promise<string> =>
|
||||
page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el === null ? "none" : getComputedStyle(el).outlineStyle;
|
||||
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;
|
||||
});
|
||||
|
||||
// Reached with the pointer: the hover background carries it, no border.
|
||||
await page.mouse.move(centre.x, centre.y);
|
||||
await expect.poll(borderOfFocused).toBe("none");
|
||||
await expect(meter).toBeInViewport();
|
||||
await expectPinnedInside(meter, list);
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Reached with the keyboard: a border marks where the keyboard is. The menu
|
||||
// moves focus to whatever the pointer is over, so the browser cannot tell
|
||||
// these two apart on its own.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect.poll(borderOfFocused).toBe("solid");
|
||||
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);
|
||||
|
||||
// And gone again on the next movement of the pointer. A point the pointer
|
||||
// is not already at, so that the move is one.
|
||||
await page.mouse.move(centre.x + 4, centre.y + 2);
|
||||
await expect.poll(borderOfFocused).toBe("none");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
async function firstUncheckedIndex(
|
||||
rows: Locator,
|
||||
count: number,
|
||||
): Promise<number> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if ((await rows.nth(i).getAttribute("aria-checked")) === "false") return i;
|
||||
}
|
||||
throw new Error("every output row is marked active");
|
||||
/** 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;
|
||||
}
|
||||
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("button", { name: "Open room" }).click();
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("no audio menu during a call on mobile", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("home_callName").fill("Audio menu");
|
||||
await page.getByTestId("home_displayName").fill("Mobile");
|
||||
await page.getByTestId("home_go").click();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Before joining, the microphone chevron exists and opens the full menu.
|
||||
const chevron = page.getByRole("button", { name: "Microphone" });
|
||||
await expect(chevron).toBeVisible();
|
||||
await chevron.click();
|
||||
// On mobile the menu is a drawer named after its title rather than headed
|
||||
// by it.
|
||||
const menu = page.getByRole("menu", { name: "Audio controls" });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("meter")).toBeVisible();
|
||||
await expect(menu.getByRole("slider")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
|
||||
// During the call, the microphone button stands alone: no chevron, no menu.
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await expect(page.getByTestId("incall_mute")).toBeVisible();
|
||||
await expect(page.getByTestId("incall_leave")).toBeVisible();
|
||||
await expect(chevron).toHaveCount(0);
|
||||
});
|
||||
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
@@ -17,6 +19,12 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The reconnecting state is entered via the probablyLeft timer, which
|
||||
// mirrors the delayed leave event's timeout. With delegation that timeout
|
||||
// is one hour, putting it out of reach of the clock fast-forward below.
|
||||
// Keep the leave client-managed so its short timeout applies.
|
||||
await SpaHelpers.disableLeaveDelegation(page);
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("Test call");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
|
||||
@@ -19,6 +19,7 @@ async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
opts: { disableGuestLeaveDelegation?: boolean } = {},
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
@@ -52,6 +53,9 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
if (opts.disableGuestLeaveDelegation)
|
||||
await SpaHelpers.disableLeaveDelegation(guestPage);
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
@@ -102,7 +106,13 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
// With delegation, the backend sends the guest's delayed leave event within
|
||||
// moments of the improper leave, so the stale membership this test needs
|
||||
// would be cleaned up before the rejoin. Keep the guest's leave
|
||||
// client-managed so the stale membership lingers.
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
|
||||
disableGuestLeaveDelegation: true,
|
||||
});
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
@@ -111,6 +111,24 @@ async function setRtcModeFromSettings(
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the delayed-leave delegation support probes fail so that the client
|
||||
* manages its delayed leave event itself instead of delegating it to the
|
||||
* backend.
|
||||
*
|
||||
* Must be installed before the page joins a call.
|
||||
*/
|
||||
async function disableLeaveDelegation(page: Page): Promise<void> {
|
||||
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
|
||||
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
|
||||
await page.route("**/delegate_delayed_leave", async (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
@@ -133,5 +151,6 @@ export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
disableLeaveDelegation,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
type Locator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -203,6 +204,16 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Expand all sections" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Already expanded or button not present
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -253,9 +264,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -350,6 +361,17 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a room in the room list by its name.
|
||||
*
|
||||
* Matches on the aria-label prefix because the item's role differs between
|
||||
* the flat room list (`option`) and the sectioned room list (`button`), and
|
||||
* the label may carry a suffix such as " invitation.".
|
||||
*/
|
||||
public static roomListItem(page: Page, roomName: string): Locator {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
@@ -359,7 +381,8 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
|
||||
Generated
+41
-57
@@ -39,13 +39,13 @@ importers:
|
||||
version: 11.7.12
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^0.10.18
|
||||
version: 0.10.35
|
||||
@@ -131,11 +131,11 @@ importers:
|
||||
specifier: ^10.2.11
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^10.0.0
|
||||
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@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.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
@@ -183,7 +183,7 @@ importers:
|
||||
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
|
||||
livekit-client:
|
||||
specifier: ^2.18.1
|
||||
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
@@ -192,7 +192,7 @@ importers:
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.19.0
|
||||
@@ -202,9 +202,6 @@ importers:
|
||||
normalize.css:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
observable-hooks:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2)
|
||||
oxfmt:
|
||||
specifier: ^0.66.0
|
||||
version: 0.66.0
|
||||
@@ -1197,8 +1194,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
|
||||
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -3229,8 +3226,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.0.0':
|
||||
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
@@ -3246,8 +3243,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.0.1':
|
||||
resolution: {integrity: sha512-CT3aGMalPSf53o3JUbBF7EA10Rfp+auftIuBwOUSna+hfZ/zPxGLTFcuCuOJ7L8hrzijM+V4I8L0PUkfyOczpg==}
|
||||
'@vector-im/compound-web@10.1.0':
|
||||
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -3662,8 +3659,8 @@ packages:
|
||||
constants-browserify@1.0.0:
|
||||
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
||||
|
||||
content-type@3.0.0:
|
||||
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
|
||||
content-type@3.1.1:
|
||||
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
@@ -4605,8 +4602,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
livekit-client@2.22.2:
|
||||
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
|
||||
livekit-client@2.22.3:
|
||||
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
@@ -4683,9 +4680,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
|
||||
version: 42.2.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
|
||||
resolution: {gitHosted: true, integrity: sha512-qF8++74MX36KS1KEw6Lc8HHXgZ6nHD1oUU4yBZUj5vVPvZTIspk1lOGztkFmizE5vmLAHd5tdadL9Xu1Asy4bA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999}
|
||||
version: 42.3.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.19.0:
|
||||
@@ -4816,13 +4813,6 @@ packages:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
observable-hooks@4.2.4:
|
||||
resolution: {integrity: sha512-FdTQgyw1h5bG/QHCBIqctdBSnv9VARJCEilgpV6L2qlw1yeLqFIwPm4U15dMtl5kDmNN0hSt+Nl6iYbLFwEcQA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
rxjs: '>=6.0.0'
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -4905,8 +4895,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.0:
|
||||
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -7079,21 +7069,21 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
loglevel: 1.9.1
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
@@ -7105,13 +7095,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.35
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
|
||||
dependencies:
|
||||
@@ -8695,12 +8685,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.0.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.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8712,7 +8702,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.0.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 +9163,7 @@ snapshots:
|
||||
|
||||
constants-browserify@1.0.0: {}
|
||||
|
||||
content-type@3.0.0: {}
|
||||
content-type@3.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
@@ -10180,7 +10170,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
|
||||
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
@@ -10259,17 +10249,17 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
content-type: 3.0.0
|
||||
content-type: 3.1.1
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.19.0
|
||||
p-retry: 8.0.0
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10410,12 +10400,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
observable-hooks@4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
rxjs: 7.8.2
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -10600,7 +10584,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type MediaDevices } from "./state/MediaDevices";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
||||
undefined,
|
||||
@@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): {
|
||||
volume: number;
|
||||
} => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const audioOutput = useBehavior(devices.audioOutput.selected$);
|
||||
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
||||
return {
|
||||
// We use only the right speaker (pan = 1) for the earpiece.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How Element Call can be told to behave, and what each intent means.
|
||||
*
|
||||
* Kept apart from {@link UrlParams} on purpose: everything here is constants,
|
||||
* types and one function of them, so that a host of the component can import
|
||||
* these — the enums it needs as values, the defaults an intent implies — without
|
||||
* loading Element Call itself. UrlParams, which reads them from a URL, needs
|
||||
* the router, the config and the rest of the app; this module must not.
|
||||
* `component/api.ts` is built from it as an entry point of its own.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { platform } from "./Platform";
|
||||
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallVoice = "start_call_voice",
|
||||
JoinExistingCallVoice = "join_existing_voice",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for the app, which can be set via URL parameters.
|
||||
* Those property are different to the UrlProperties, since they are all optional
|
||||
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||
* the same context but with different accounts/users.
|
||||
*
|
||||
* Their defaults can be controlled by the `intent` property.
|
||||
*/
|
||||
export interface UrlConfiguration {
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||
* hides the header entirely, and "app_bar" produces a header with a back
|
||||
* button like you might see in mobile apps. The callback for the back button
|
||||
* is window.controls.onBackButtonPressed.
|
||||
*/
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
* with the join widget action.
|
||||
*/
|
||||
skipLobby: boolean;
|
||||
/**
|
||||
* Setting this flag makes element call show the lobby after leaving a call.
|
||||
* This is useful for video rooms.
|
||||
*/
|
||||
returnToLobby: boolean;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whether the app should automatically leave the call when there
|
||||
* is no one left in the call.
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
+17
-222
@@ -9,47 +9,36 @@ Please see LICENSE in the repository root for full details.
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { pickBy } from "lodash-es";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { platform } from "./Platform";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
import { redact } from "./utils/redact";
|
||||
|
||||
// Moved to their own module, so that a host can import them without the rest
|
||||
// of this one; re-exported here as they always were
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
roomId: string | null;
|
||||
viaServers: string[];
|
||||
}
|
||||
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallVoice = "start_call_voice",
|
||||
JoinExistingCallVoice = "join_existing_voice",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
@@ -166,109 +155,6 @@ export interface UrlProperties {
|
||||
*/
|
||||
background: BackgroundStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for the app, which can be set via URL parameters.
|
||||
* Those property are different to the UrlProperties, since they are all optional
|
||||
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||
* the same context but with different accounts/users.
|
||||
*
|
||||
* Their defaults can be controlled by the `intent` property.
|
||||
*/
|
||||
export interface UrlConfiguration {
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||
* hides the header entirely, and "app_bar" produces a header with a back
|
||||
* button like you might see in mobile apps. The callback for the back button
|
||||
* is window.controls.onBackButtonPressed.
|
||||
*/
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
* with the join widget action.
|
||||
*/
|
||||
skipLobby: boolean;
|
||||
/**
|
||||
* Setting this flag makes element call show the lobby after leaving a call.
|
||||
* This is useful for video rooms.
|
||||
*/
|
||||
returnToLobby: boolean;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whether the app should automatically leave the call when there
|
||||
* is no one left in the call.
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||
|
||||
class ParamParser {
|
||||
@@ -354,97 +240,6 @@ export const getUrlParams = (
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link UrlProperties} for Element Call running as a component inside a
|
||||
* host application.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
padding-inline-start: var(--cpd-space-4x);
|
||||
/* Stop the bars where the device labels stop rather than at the menu edge:
|
||||
a menu item keeps a 4x margin after its label plus a 2x column reserved
|
||||
for the chevron, on top of the menu's own 4x padding. */
|
||||
padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
}
|
||||
|
||||
.meter:focus-visible {
|
||||
outline: 2px solid var(--cpd-color-border-focused);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
/* The bars share out whatever the gaps leave over, so the gap above and the
|
||||
bar count together set how thick a bar is. Across the inset row that lands
|
||||
near 6px per bar against the 8px gap, as in the design. */
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
transition: background-color 60ms linear;
|
||||
}
|
||||
|
||||
.lit {
|
||||
background-color: var(--cpd-color-icon-accent-primary);
|
||||
}
|
||||
|
||||
.unavailable .icon,
|
||||
.unavailable .bar {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
padding-inline-start: var(--cpd-space-4x);
|
||||
padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
|
||||
color: var(--cpd-color-text-secondary);
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, within } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AudioLevelMeter } from "./AudioLevelMeter";
|
||||
|
||||
const meta = {
|
||||
component: AudioLevelMeter,
|
||||
// The meter is only ever seen inside the audio menu, so give it a comparable
|
||||
// width to judge the bar spacing against.
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<div style={{ width: 300 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AudioLevelMeter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Silent: Story = {
|
||||
args: { state: { type: "active", level: 0 } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const meter = within(canvasElement).getByRole("meter");
|
||||
await expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
|
||||
},
|
||||
};
|
||||
|
||||
export const Speaking: Story = {
|
||||
args: { state: { type: "active", level: 0.45 } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const meter = within(canvasElement).getByRole("meter");
|
||||
await expect(meter).toHaveAttribute("aria-valuetext", "Picking up sound");
|
||||
},
|
||||
};
|
||||
|
||||
export const Loud: Story = {
|
||||
args: { state: { type: "active", level: 0.95 } },
|
||||
};
|
||||
|
||||
/** Named apart from the footer's own "Unavailable Media Devices" story. */
|
||||
export const MicrophoneUnavailable: Story = {
|
||||
args: { state: { type: "unavailable" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const meter = within(canvasElement).getByRole("meter");
|
||||
await expect(meter).toHaveAttribute("data-unavailable", "true");
|
||||
// The greying is the point, and it is done in CSS: an attribute alone
|
||||
// would still be there with the rule gone.
|
||||
const bar = meter.querySelector("span");
|
||||
await expect(Number(getComputedStyle(bar!).opacity)).toBeLessThan(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMicrophone: Story = {
|
||||
args: { state: { type: "absent" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBe(null);
|
||||
await expect(canvas.getByText(/No microphone found/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const PermissionDenied: Story = {
|
||||
args: { state: { type: "denied" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBe(null);
|
||||
await expect(
|
||||
canvas.getByText(/Microphone access is blocked/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { AudioLevelMeter } from "./AudioLevelMeter";
|
||||
|
||||
describe("AudioLevelMeter", () => {
|
||||
test("a missing microphone is named rather than drawn as silence", () => {
|
||||
render(<AudioLevelMeter state={{ type: "absent" }} />);
|
||||
expect(screen.getByTestId("mic_absent")).toHaveTextContent(
|
||||
/no microphone found/i,
|
||||
);
|
||||
// Bars resting at zero would read as a microphone that hears nothing,
|
||||
// which is a different thing and the one people act on.
|
||||
expect(screen.queryByRole("meter")).toBe(null);
|
||||
});
|
||||
|
||||
test("a denied microphone renders a hint instead of the meter", () => {
|
||||
render(<AudioLevelMeter state={{ type: "denied" }} />);
|
||||
expect(screen.getByTestId("mic_level_denied")).toHaveTextContent(
|
||||
/microphone access is blocked/i,
|
||||
);
|
||||
// A hint replaces the meter rather than sitting next to a dead one.
|
||||
expect(screen.queryByRole("meter")).toBe(null);
|
||||
});
|
||||
|
||||
test("level indicator greys out when the microphone is unavailable", () => {
|
||||
render(<AudioLevelMeter state={{ type: "unavailable" }} />);
|
||||
const meter = screen.getByRole("meter");
|
||||
expect(meter).toHaveAttribute("data-unavailable", "true");
|
||||
expect(meter).toHaveAttribute("aria-valuetext", "Microphone unavailable");
|
||||
});
|
||||
|
||||
test("level indicator reports its level to assistive technology", () => {
|
||||
const { rerender } = render(
|
||||
<AudioLevelMeter state={{ type: "active", level: 0 }} />,
|
||||
);
|
||||
const meter = screen.getByRole("meter");
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "0");
|
||||
expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
|
||||
|
||||
rerender(<AudioLevelMeter state={{ type: "active", level: 0.7 }} />);
|
||||
expect(screen.getByRole("meter")).toHaveAttribute(
|
||||
"aria-valuetext",
|
||||
"Picking up sound",
|
||||
);
|
||||
});
|
||||
|
||||
test("level indicator announces its state only while focused", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AudioLevelMeter state={{ type: "active", level: 0.7 }} />);
|
||||
const meter = screen.getByRole("meter");
|
||||
|
||||
// Nothing is announced until the user puts the meter in focus, so the
|
||||
// level does not talk over the rest of the menu.
|
||||
const announcer = meter.querySelector("[aria-live]");
|
||||
expect(announcer).toHaveAttribute("aria-live", "off");
|
||||
await user.tab();
|
||||
expect(meter).toHaveFocus();
|
||||
expect(announcer).toHaveAttribute("aria-live", "polite");
|
||||
expect(announcer).toHaveTextContent("Picking up sound");
|
||||
await user.tab();
|
||||
expect(announcer).toHaveAttribute("aria-live", "off");
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MicOnIcon,
|
||||
MicOffIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./AudioLevelMeter.module.css";
|
||||
import {
|
||||
useMicrophoneLevel,
|
||||
type MicrophoneLevelState,
|
||||
} from "./useMicrophoneLevel";
|
||||
|
||||
// The level is quantised into this many bars. They share the row, so the
|
||||
// count also sets how thick they are.
|
||||
const BAR_COUNT = 18;
|
||||
/**
|
||||
* Level below which the meter reads as silent. Above the noise floor of a
|
||||
* typical desk microphone, so an idle room does not announce itself as sound.
|
||||
*/
|
||||
const SPEECH_THRESHOLD = 0.06;
|
||||
|
||||
export interface AudioLevelMeterProps {
|
||||
state: MicrophoneLevelState;
|
||||
}
|
||||
|
||||
/**
|
||||
* A live indicator of the signal level at the selected microphone.
|
||||
*
|
||||
* Focusable, so that a screen reader user can put it in focus and hear whether
|
||||
* the microphone is picking anything up; the bars alone carry that information
|
||||
* for everyone else.
|
||||
*/
|
||||
export const AudioLevelMeter: FC<AudioLevelMeterProps> = ({ state }) => {
|
||||
const { t } = useTranslation();
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
// Bars resting at zero would read as a microphone that hears nothing, which
|
||||
// is a different thing and the one people act on.
|
||||
if (state.type === "absent")
|
||||
return (
|
||||
<div className={styles.message} data-testid="mic_absent">
|
||||
<MicOffIcon width={24} height={24} aria-hidden />
|
||||
<span>{t("audio_menu.mic_absent")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (state.type === "denied")
|
||||
return (
|
||||
<div className={styles.message} data-testid="mic_level_denied">
|
||||
<MicOffIcon width={24} height={24} aria-hidden />
|
||||
<span>{t("audio_menu.mic_permission_denied")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const unavailable = state.type === "unavailable";
|
||||
const level = state.type === "active" ? state.level : 0;
|
||||
const litBars = unavailable ? 0 : Math.round(level * BAR_COUNT);
|
||||
const speaking = !unavailable && level >= SPEECH_THRESHOLD;
|
||||
const stateText = unavailable
|
||||
? t("audio_menu.mic_unavailable")
|
||||
: speaking
|
||||
? t("audio_menu.mic_level_active")
|
||||
: t("audio_menu.mic_level_silent");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.meter, {
|
||||
[styles.unavailable]: unavailable,
|
||||
})}
|
||||
data-testid="mic_level_meter"
|
||||
data-unavailable={unavailable || undefined}
|
||||
role="meter"
|
||||
// The meter is the only place the "is my microphone working?" answer
|
||||
// lives, so it has to be reachable without a pointer even though a
|
||||
// meter is not an interactive control.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
aria-label={t("audio_menu.mic_level_label")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={1}
|
||||
aria-valuenow={level}
|
||||
aria-valuetext={stateText}
|
||||
>
|
||||
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
|
||||
<div className={styles.bars}>
|
||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={classNames(styles.bar, { [styles.lit]: i < litBars })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Announces changes only while the meter holds focus, so the level does
|
||||
not talk over a screen reader reading the rest of the menu. The text
|
||||
stays in the DOM either way: removing it on blur would hand the
|
||||
menu's focus trap an empty active element mid-Tab, and it would pull
|
||||
focus back into the menu instead of letting it reach the slider. */}
|
||||
<span className={styles.srOnly} aria-live={focused ? "polite" : "off"}>
|
||||
{stateText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface MicrophoneLevelProps {
|
||||
/** The microphone to follow, or undefined if none is selected. */
|
||||
deviceId: string | undefined;
|
||||
/** Whether to hold a capture and report a level at all. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level meter, following a microphone of its own.
|
||||
*
|
||||
* Re-renders when the level moves to another bar, and only itself.
|
||||
*/
|
||||
export const MicrophoneLevel: FC<MicrophoneLevelProps> = ({
|
||||
deviceId,
|
||||
active,
|
||||
}) => (
|
||||
<AudioLevelMeter state={useMicrophoneLevel(deviceId, active, BAR_COUNT)} />
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, fn, screen, userEvent, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type JSX, type ReactNode } from "react";
|
||||
import { Link } from "@vector-im/compound-web";
|
||||
@@ -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";
|
||||
@@ -103,8 +104,6 @@ const meta = {
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
selectAudioOutputOption: fnArgType,
|
||||
setSoundEffectVolume: fnArgType,
|
||||
},
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
@@ -139,16 +138,14 @@ export const Default: Story = {
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
audioOutputOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
audioOutputOptions: [],
|
||||
selectedAudioOutput: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
soundEffectVolume: 0.5,
|
||||
setSoundEffectVolume: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
@@ -165,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: {
|
||||
@@ -361,29 +389,10 @@ export const MobileLayout: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Devices and controls of the audio menu, as the lobby offers them. */
|
||||
const audioMenuArgs = {
|
||||
audioOptions: [
|
||||
{ label: { type: "name", name: "MacBook Pro Microphone" }, id: "1" },
|
||||
{ label: { type: "name", name: "Jabra Evolve 65" }, id: "2" },
|
||||
],
|
||||
selectedAudio: "1",
|
||||
selectAudioButtonOption: fn(),
|
||||
audioOutputOptions: [
|
||||
{ label: { type: "default", name: "MacBook Pro Speakers" }, id: "" },
|
||||
{ label: { type: "name", name: "Jabra Evolve 65" }, id: "2" },
|
||||
],
|
||||
selectedAudioOutput: "",
|
||||
selectAudioOutputOption: fn(),
|
||||
soundEffectVolume: 0.5,
|
||||
setSoundEffectVolume: fn(),
|
||||
} satisfies Partial<FooterSnapshot>;
|
||||
|
||||
export const Lobby: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
...audioMenuArgs,
|
||||
showLogo: false,
|
||||
openSettings: undefined,
|
||||
layout: null,
|
||||
@@ -398,7 +407,6 @@ export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
...audioMenuArgs,
|
||||
showLogo: false,
|
||||
|
||||
layout: null,
|
||||
@@ -442,105 +450,3 @@ export const LobbyRecentButtonMobile: Story = {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
/** The microphone chevron opens the audio menu: microphones and speakers. */
|
||||
export const WithAudioMenu: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
...audioMenuArgs,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const menu = within(await screen.findByRole("menu"));
|
||||
await expect(
|
||||
menu.getByRole("heading", { name: "Audio controls" }),
|
||||
).toBeInTheDocument();
|
||||
// The headset appears in both groups under the same name, as a real one
|
||||
// does. The speaker rows are the scroll area's own children; the
|
||||
// microphone rows sit one level deeper, inside their group.
|
||||
const scroll = menu.getByTestId("audio_menu_scroll");
|
||||
const outputs = [...scroll.children].filter(
|
||||
(el) => el.getAttribute("role") === "menuitemradio",
|
||||
);
|
||||
await userEvent.click(outputs[1]);
|
||||
await expect(args.selectAudioOutputOption).toHaveBeenCalledWith("2");
|
||||
// The menu stays open after a selection.
|
||||
await expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
await expect(
|
||||
menu.getByRole("slider", { name: /Sound effect volume/ }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** One output only: the speaker group names it without offering a choice. */
|
||||
export const WithSingleAudioOutput: Story = {
|
||||
...WithAudioMenu,
|
||||
args: {
|
||||
...WithAudioMenu.args,
|
||||
audioOutputOptions: [
|
||||
{ label: { type: "default", name: "MacBook Pro Speakers" }, id: "" },
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId("speaker_readonly"),
|
||||
).toHaveTextContent("MacBook Pro Speakers");
|
||||
await expect(
|
||||
screen.queryByRole("menuitemradio", { name: /Speakers/ }),
|
||||
).toBe(null);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* More devices than fit on screen: the heading and the slider stay put while
|
||||
* the device lists scroll between them.
|
||||
*/
|
||||
export const WithManyDevices: Story = {
|
||||
...WithAudioMenu,
|
||||
args: {
|
||||
...WithAudioMenu.args,
|
||||
audioOptions: Array.from({ length: 12 }, (_, i) => ({
|
||||
label: { type: "name", name: `Microphone ${i + 1} (USB Audio Device)` },
|
||||
id: `mic-${i}`,
|
||||
})),
|
||||
selectedAudio: "mic-0",
|
||||
audioOutputOptions: Array.from({ length: 8 }, (_, i) => ({
|
||||
label: { type: "name", name: `Speaker ${i + 1} (DisplayPort)` },
|
||||
id: `out-${i}`,
|
||||
})),
|
||||
selectedAudioOutput: "out-0",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = await screen.findByRole("menu");
|
||||
const heading = within(menu).getByRole("heading", {
|
||||
name: "Audio controls",
|
||||
});
|
||||
const slider = within(menu).getByRole("slider");
|
||||
const scroll = within(menu).getByTestId("audio_menu_scroll");
|
||||
|
||||
// The menu slides in; measure once it has settled.
|
||||
await Promise.all(
|
||||
menu.getAnimations({ subtree: true }).map(async (a) => a.finished),
|
||||
);
|
||||
|
||||
// The whole menu is on screen, so the heading and the slider are too.
|
||||
const menuBox = menu.getBoundingClientRect();
|
||||
await expect(menuBox.top).toBeGreaterThanOrEqual(0);
|
||||
await expect(menuBox.bottom).toBeLessThanOrEqual(window.innerHeight);
|
||||
await expect(heading.getBoundingClientRect().top).toBeGreaterThanOrEqual(0);
|
||||
await expect(slider.getBoundingClientRect().bottom).toBeLessThanOrEqual(
|
||||
window.innerHeight,
|
||||
);
|
||||
// Only the device lists scroll.
|
||||
await expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { Root as Form, TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type JSX, type ReactNode } from "react";
|
||||
|
||||
import { CallFooter } from "./CallFooter";
|
||||
import { DeviceSelection } from "../settings/DeviceSelection";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
type MediaDevices,
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "../state/MediaDevices";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
import {
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
|
||||
describe("CallFooter", () => {
|
||||
afterEach(() => soundEffectVolumeSetting.setValue(0.5));
|
||||
|
||||
test("sound effects volume from the menu applies to the next effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFooter(mediaDevicesWithOutputs());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
await screen.findByRole("menu");
|
||||
await user.keyboard("[ArrowDown]");
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
await user.keyboard("[ArrowRight]");
|
||||
|
||||
// The committed value lands in the setting the sound-effect player reads,
|
||||
// so the next effect plays at the new level.
|
||||
expect(soundEffectVolumeSetting.value$.value).toBe(0.51);
|
||||
});
|
||||
|
||||
test("sound effects volume is shared between menu and settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFooter(mediaDevicesWithOutputs(), <SettingsVolume />);
|
||||
|
||||
// Set from the menu, shown in settings.
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
await screen.findByRole("menu");
|
||||
await user.keyboard("[ArrowDown]");
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
await user.keyboard("[ArrowRight]");
|
||||
await user.keyboard("[Escape]");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("51%");
|
||||
|
||||
// Set from settings, shown in the menu.
|
||||
await user.click(screen.getByRole("button", { name: "Set to 20%" }));
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
expect(
|
||||
await screen.findByRole("slider", { name: /Sound effect volume/ }),
|
||||
).toHaveAttribute("aria-valuenow", "0.2");
|
||||
});
|
||||
|
||||
test("audio menu is headed Audio controls", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFooter(mediaDevicesWithOutputs());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(
|
||||
within(menu).getByRole("heading", { name: "Audio controls" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("audio output selection is shared between menu and settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mediaDevices = mediaDevicesWithOutputs();
|
||||
renderFooter(
|
||||
mediaDevices,
|
||||
<Form>
|
||||
<DeviceSelection
|
||||
device={mediaDevices.audioOutput}
|
||||
title="Speaker"
|
||||
numberedLabel={(n) => `Speaker ${n}`}
|
||||
/>
|
||||
</Form>,
|
||||
);
|
||||
|
||||
// Chosen in the menu, shown in settings. The open menu hides the rest of
|
||||
// the page from assistive technology, so it closes before settings is read.
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitemradio", { name: "Headset" }),
|
||||
);
|
||||
await user.keyboard("[Escape]");
|
||||
expect(screen.getByRole("radio", { name: "Headset" })).toBeChecked();
|
||||
|
||||
// Chosen in settings, shown in the menu.
|
||||
await user.click(screen.getByRole("radio", { name: "Built-in Speakers" }));
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
expect(
|
||||
await screen.findByRole("menuitemradio", { name: "Built-in Speakers" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Headset" }),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
/** The in-call footer over its real view model, with settings beside it. */
|
||||
function renderFooter(
|
||||
mediaDevices: MediaDevices,
|
||||
settings?: ReactNode,
|
||||
): void {
|
||||
const { footerVm } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
);
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions: false,
|
||||
toggleRaisedHand: async (): Promise<void> => Promise.resolve(),
|
||||
sendReaction: async (): Promise<void> => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<CallFooter vm={footerVm} />
|
||||
{settings}
|
||||
</ReactionsSenderContext>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads and writes the volume the way the settings dialog does. */
|
||||
function SettingsVolume(): JSX.Element {
|
||||
const [volume, setVolume] = useSetting(soundEffectVolumeSetting);
|
||||
return (
|
||||
<>
|
||||
<output>{Math.round(volume * 100)}%</output>
|
||||
<button onClick={() => setVolume(0.2)}>Set to 20%</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two outputs whose selection is one shared value, as in MediaDevices. */
|
||||
function mediaDevicesWithOutputs(): MediaDevices {
|
||||
const selected$ = new BehaviorSubject<
|
||||
SelectedAudioOutputDevice | undefined
|
||||
>({ id: "out-1", virtualEarpiece: false });
|
||||
return mockMediaDevices({
|
||||
requestDeviceNames: vi.fn(),
|
||||
audioOutput: {
|
||||
available$: constant(
|
||||
new Map<string, AudioOutputDeviceLabel>([
|
||||
["out-1", { type: "name", name: "Built-in Speakers" }],
|
||||
["out-2", { type: "name", name: "Headset" }],
|
||||
]),
|
||||
),
|
||||
selected$,
|
||||
select: (id: string): void =>
|
||||
selected$.next({ id, virtualEarpiece: false }),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -26,7 +25,6 @@ import styles from "./CallFooter.module.css";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
type OutputMenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
@@ -101,24 +99,16 @@ 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;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
|
||||
/**
|
||||
* The audio outputs offered by the audio menu. Empty where the browser does
|
||||
* not allow choosing one; the menu then names the default output.
|
||||
*/
|
||||
audioOutputOptions: OutputMenuOptions[];
|
||||
selectedAudioOutput: string | undefined;
|
||||
/** Also controls whether the microphone chevron opens the audio menu */
|
||||
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||
soundEffectVolume: number;
|
||||
/** Also controls whether the microphone chevron opens the audio menu */
|
||||
setSoundEffectVolume: ((volume: number) => void) | undefined;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
@@ -133,7 +123,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
@@ -158,12 +147,10 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const soundEffectVolume = useBehavior(vm.soundEffectVolume$);
|
||||
const setSoundEffectVolume = useBehavior(vm.setSoundEffectVolume$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
@@ -185,24 +172,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// The audio menu exists wherever its actions do. It names the output in use
|
||||
// even when there is no microphone to list.
|
||||
const audioControls =
|
||||
selectAudioOutputOption === undefined || setSoundEffectVolume === undefined
|
||||
? undefined
|
||||
: {
|
||||
outputOptions: audioOutputOptions ?? [],
|
||||
selectedOutput: selectedAudioOutput,
|
||||
onSelectOutput: selectAudioOutputOption,
|
||||
micDeviceId: selectedAudio,
|
||||
soundEffectVolume: soundEffectVolume ?? 0,
|
||||
onSoundEffectVolumeCommit: setSoundEffectVolume,
|
||||
};
|
||||
|
||||
if ((audioOptions?.length ?? 0) > 0 || audioControls !== undefined) {
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={audioControls ? t("audio_menu.title") : "Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
@@ -212,7 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
audioControls={audioControls}
|
||||
outputOptions={audioOutputOptions}
|
||||
selectedOutputOption={selectedAudioOutput}
|
||||
onSelectOutput={selectAudioOutputOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
@@ -232,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}
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
@@ -13,18 +13,9 @@ import { constant } from "../state/Behavior";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type {
|
||||
AudioOutputDeviceLabel,
|
||||
DeviceLabel,
|
||||
} from "../state/MediaDevices";
|
||||
import {
|
||||
createCallFooterViewModel,
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { soundEffectVolume } from "../settings/settings";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -39,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
|
||||
@@ -104,89 +100,63 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
},
|
||||
});
|
||||
|
||||
const selectOutput = vi.fn();
|
||||
const twoOutputsMediaDevices = mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(
|
||||
new Map<string, AudioOutputDeviceLabel>([
|
||||
["", { type: "default", name: "Built-in Speakers" }],
|
||||
["out2", { type: "name", name: "Headset" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant({ id: "out2", virtualEarpiece: false }),
|
||||
select: selectOutput,
|
||||
},
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("audio menu", () => {
|
||||
afterEach(() => soundEffectVolume.setValue(0.5));
|
||||
|
||||
function createVm(
|
||||
platform: string,
|
||||
layout: Layout,
|
||||
): ViewModel<FooterSnapshot> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(layout),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoOutputsMediaDevices,
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("offers no audio menu when the platform is iOS", () => {
|
||||
const vm = createVm("ios", gridLayout);
|
||||
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([]);
|
||||
expect(vm.selectAudioOutputOption$.value).toBeUndefined();
|
||||
expect(vm.setSoundEffectVolume$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers no audio menu when the layout is pip", () => {
|
||||
const vm = createVm("desktop", pipLayout);
|
||||
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||
expect(vm.selectAudioOutputOption$.value).toBeUndefined();
|
||||
expect(vm.setSoundEffectVolume$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("audio menu is present pre-join on every platform", () => {
|
||||
for (const platform of ["desktop", "android", "ios"]) {
|
||||
platformMock.mockReturnValue(platform);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoOutputsMediaDevices,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
expect(vm.audioOutputOptions$.value).toHaveLength(2);
|
||||
expect(vm.selectAudioOutputOption$.value).toBeDefined();
|
||||
expect(vm.setSoundEffectVolume$.value).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and writes the sound-effect volume setting on desktop", () => {
|
||||
const vm = createVm("desktop", gridLayout);
|
||||
expect(vm.soundEffectVolume$.value).toBe(0.5);
|
||||
|
||||
vm.setSoundEffectVolume$.value?.(0.2);
|
||||
expect(soundEffectVolume.value$.value).toBe(0.2);
|
||||
expect(vm.soundEffectVolume$.value).toBe(0.2);
|
||||
});
|
||||
|
||||
it("lists the outputs and the selection on desktop", () => {
|
||||
const vm = createVm("desktop", gridLayout);
|
||||
expect(vm.audioOutputOptions$.value).toEqual([
|
||||
{ id: "", label: { type: "default", name: "Built-in Speakers" } },
|
||||
{ id: "out2", label: { type: "name", name: "Headset" } },
|
||||
]);
|
||||
expect(vm.selectedAudioOutput$.value).toBe("out2");
|
||||
|
||||
vm.selectAudioOutputOption$.value?.("");
|
||||
expect(selectOutput).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,19 +5,16 @@ 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,
|
||||
type OutputMenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
@@ -58,10 +55,8 @@ function buildMuteBehaviors(
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices and the sound-effect setting into every
|
||||
* device behavior FooterSnapshot needs, for the switcher menus and the audio
|
||||
* menu alike. The output list is empty wherever the browser cannot switch
|
||||
* output; the menu then names the default rather than hiding the group.
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
@@ -73,54 +68,50 @@ function buildDeviceBehaviors(
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "soundEffectVolume$"
|
||||
| "setSoundEffectVolume$"
|
||||
> {
|
||||
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)),
|
||||
),
|
||||
@@ -137,45 +128,6 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
),
|
||||
videoBlurEnabled$: backgroundBlurSettings.value$,
|
||||
audioOutputOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as OutputMenuOptions[])
|
||||
: mediaDevices.audioOutput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioOutputOption$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
map((disable) =>
|
||||
disable
|
||||
? undefined
|
||||
: (id: string): void => mediaDevices.audioOutput.select(id),
|
||||
),
|
||||
),
|
||||
),
|
||||
soundEffectVolume$: soundEffectVolumeSetting.value$,
|
||||
setSoundEffectVolume$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
map((disable) =>
|
||||
disable
|
||||
? undefined
|
||||
: (volume: number): void =>
|
||||
soundEffectVolumeSetting.setValue(volume),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,15 +265,16 @@ 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),
|
||||
// Nothing is gated before joining: the chevron already exists on every
|
||||
// platform there, and a device check matters most before a call starts.
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,91 +36,59 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
/* The speaker group's one row where there is no choice to make: it names the
|
||||
output in use wherever audio cannot be redirected. Laid out like a menu item
|
||||
so it lines up with the rows around it. */
|
||||
.readOnlyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-4x);
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
}
|
||||
|
||||
.micSection {
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* Opaque, so the device rows scrolling underneath do not show through. */
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
padding-block-start: var(--cpd-space-1x);
|
||||
/* The menu draws its border as an outline inset by one border width. This
|
||||
row is positioned and opaque, so without the matching inset it paints over
|
||||
that border at the left and right edges. */
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Bound the whole menu to the space Radix reports for it, so that the scroll
|
||||
area below can shrink while the heading and the slider, its flex siblings,
|
||||
keep their size and stay on screen. The bound has to cover the menu's own
|
||||
padding, or the padding lands outside it and carries the heading off the
|
||||
top of the viewport; the 2x keeps a small gap to the viewport edge. */
|
||||
.menu {
|
||||
box-sizing: border-box;
|
||||
max-block-size: calc(
|
||||
var(--radix-dropdown-menu-content-available-height) - var(--cpd-space-2x)
|
||||
);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
/* The menu lays its children out as a flex column; the scroll area has to
|
||||
repeat that for the children it now owns. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.volumeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.volumeLabel {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.volumeControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The menu moves focus onto whichever item the pointer is over, so the
|
||||
browser's own focus ring shows up during mouse use. Keyboard navigation
|
||||
gets the border; the pointer gets the hover background and nothing else. */
|
||||
.menu [role^="menuitem"]:focus {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.keyboardNav [role^="menuitem"]:focus {
|
||||
.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);
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,30 +6,29 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ComponentType,
|
||||
useCallback,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
useEffect,
|
||||
type JSX,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTitle,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOnIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
VolumeOnIcon,
|
||||
} 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";
|
||||
@@ -38,41 +37,16 @@ import {
|
||||
type DeviceLabel,
|
||||
} from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { MicrophoneLevel } from "./AudioLevelMeter";
|
||||
import { Slider } from "../Slider";
|
||||
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 OutputMenuOptions {
|
||||
label: AudioOutputDeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls that turn the microphone chevron menu into the audio menu: a
|
||||
* speaker group and the sound-effect volume below the microphone list. Absent
|
||||
* for the camera menu.
|
||||
*/
|
||||
export interface AudioControls {
|
||||
/**
|
||||
* The audio outputs to choose from. Empty where the browser does not allow
|
||||
* choosing one; the menu then names the default output instead.
|
||||
*/
|
||||
outputOptions: OutputMenuOptions[];
|
||||
selectedOutput: string | undefined;
|
||||
onSelectOutput: (id: string) => void;
|
||||
/** The microphone the level meter follows. */
|
||||
micDeviceId: string | undefined;
|
||||
soundEffectVolume: number;
|
||||
onSoundEffectVolumeCommit: (volume: number) => void;
|
||||
}
|
||||
|
||||
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 */
|
||||
@@ -84,10 +58,14 @@ 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;
|
||||
/** When present, the menu renders the speaker group below the microphone list. */
|
||||
audioControls?: AudioControls;
|
||||
/**
|
||||
* For any toggle and option this method will be called.
|
||||
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||
@@ -96,163 +74,240 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
}
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
/** The keys a slider reads; inside the menu they must not move focus. */
|
||||
const SLIDER_KEYS = new Set([
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
]);
|
||||
|
||||
/** 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,
|
||||
audioControls,
|
||||
}) => {
|
||||
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);
|
||||
// Which of the two reached the current item, since `:focus-visible` cannot
|
||||
// tell: the menu focuses whatever the pointer is over.
|
||||
const [keyboardNav, setKeyboardNav] = useState(false);
|
||||
const modality = {
|
||||
onKeyDown: (): void => setKeyboardNav(true),
|
||||
onPointerMove: (): void => setKeyboardNav(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 deviceItems = options?.map(({ label, id }) => {
|
||||
let labelText: string;
|
||||
const labelText = (
|
||||
label: MenuOptions["label"],
|
||||
numbered: (n: number) => string,
|
||||
): string => {
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
labelText = label.name;
|
||||
break;
|
||||
return label.name;
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
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");
|
||||
}
|
||||
return (
|
||||
};
|
||||
|
||||
// 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
|
||||
label={labelText}
|
||||
disabled={disabled}
|
||||
label={labelText(label, numbered)}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
// 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 === selectedOption) return;
|
||||
setPlannedSelection(id);
|
||||
onSelect?.(id);
|
||||
if (id === selected) return;
|
||||
setPlannedSelection({ kind, id });
|
||||
select?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedOption === id}
|
||||
{...modality}
|
||||
aria-checked={selected === id}
|
||||
>
|
||||
{selectedOption === id && (
|
||||
<CheckIcon
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden // A label would be redundant to aria-checked above
|
||||
/>
|
||||
)}
|
||||
{selectedOption !== id && plannedSelection === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
@@ -264,13 +319,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
<Menu
|
||||
className={classNames(styles.menu, {
|
||||
[styles.keyboardNav]: keyboardNav,
|
||||
})}
|
||||
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
|
||||
@@ -283,71 +337,77 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
kind={"tertiary"}
|
||||
size="lg"
|
||||
aria-label={optionsButtonLabel}
|
||||
onKeyDown={() => setKeyboardNav(true)}
|
||||
onPointerDown={() => setKeyboardNav(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{audioControls ? (
|
||||
// Only the device lists scroll; the title above and the slider below
|
||||
// stay put. Tab moves between the device rows, the meter and the
|
||||
// slider; see keepTabInsideMenu.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.scrollArea}
|
||||
data-testid="audio_menu_scroll"
|
||||
onKeyDown={keepTabInsideMenu}
|
||||
>
|
||||
<div
|
||||
className={styles.micSection}
|
||||
data-testid="audio_menu_mic_section"
|
||||
>
|
||||
{deviceItems}
|
||||
{/* The meter reads the microphone, so it travels with the
|
||||
microphone list rather than sitting among the output
|
||||
controls; pinned to the foot of the scroll port, it stays on
|
||||
screen for as long as any microphone is. */}
|
||||
<div className={styles.stickyMeter} {...modality}>
|
||||
{/* The capture is bound to the menu being open, so the
|
||||
microphone is only ever held while the user is looking at
|
||||
the level. */}
|
||||
<MicrophoneLevel
|
||||
deviceId={audioControls.micDeviceId}
|
||||
active={menuOpen}
|
||||
/>
|
||||
<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
|
||||
}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<SpeakerSection
|
||||
options={audioControls.outputOptions}
|
||||
selected={audioControls.selectedOutput}
|
||||
onSelect={audioControls.onSelectOutput}
|
||||
modality={modality}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
deviceItems
|
||||
)}
|
||||
{audioControls && (
|
||||
<>
|
||||
<hr />
|
||||
<SoundEffectVolume
|
||||
volume={audioControls.soundEffectVolume}
|
||||
onCommit={audioControls.onSoundEffectVolumeCommit}
|
||||
onPointerMove={modality.onPointerMove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
</div>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
{...modality}
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled ?? false}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
@@ -356,163 +416,24 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface SpeakerSectionProps {
|
||||
options: OutputMenuOptions[];
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
/** Reports whether a row was reached by pointer or by keyboard. */
|
||||
modality: {
|
||||
onKeyDown: () => void;
|
||||
onPointerMove: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The speaker group of the audio menu.
|
||||
*
|
||||
* With more than one output to choose from this is a radio group. With one or
|
||||
* none it still names the output in use, as a plain row: knowing where audio
|
||||
* goes is useful even where it cannot be redirected, as in browsers that do
|
||||
* not support choosing an output at all.
|
||||
*/
|
||||
function SpeakerSection({
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
modality,
|
||||
}: SpeakerSectionProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const labelText = (label: AudioOutputDeviceLabel): string => {
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
return label.name;
|
||||
case "number":
|
||||
return t("settings.devices.speaker_numbered", { n: label.number });
|
||||
case "speaker":
|
||||
return t("settings.devices.loudspeaker");
|
||||
case "earpiece":
|
||||
return t("settings.devices.handset");
|
||||
case "default":
|
||||
return label.name === null
|
||||
? t("settings.devices.default")
|
||||
: t("settings.devices.default_named_plain", { name: label.name });
|
||||
}
|
||||
};
|
||||
const icon = (
|
||||
<VolumeOnIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
|
||||
if (options.length <= 1) {
|
||||
const only = options[0];
|
||||
return (
|
||||
<div className={styles.readOnlyRow} data-testid="speaker_readonly">
|
||||
{icon}
|
||||
<span>
|
||||
{only ? labelText(only.label) : t("settings.devices.default")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map(({ id, label }) => (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
key={id}
|
||||
label={labelText(label)}
|
||||
Icon={icon}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id !== selected) onSelect(id);
|
||||
}}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected === id}
|
||||
{...modality}
|
||||
>
|
||||
{selected === id && <CheckIcon width={24} height={24} aria-hidden />}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
/** 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();
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
interface SoundEffectVolumeProps {
|
||||
volume: number;
|
||||
onCommit: (volume: number) => void;
|
||||
onPointerMove: () => void;
|
||||
}
|
||||
|
||||
/** The sound-effect volume slider: the same stored value as in settings. */
|
||||
function SoundEffectVolume({
|
||||
volume,
|
||||
onCommit,
|
||||
onPointerMove,
|
||||
}: SoundEffectVolumeProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const label = t("settings.audio_tab.effect_volume_label");
|
||||
// Tracked locally so dragging is smooth; only the committed value is
|
||||
// stored. A change made in settings while the menu is open resets it.
|
||||
const [raw, setRaw] = useState(volume);
|
||||
const [committed, setCommitted] = useState(volume);
|
||||
if (volume !== committed) {
|
||||
setCommitted(volume);
|
||||
setRaw(volume);
|
||||
}
|
||||
|
||||
return (
|
||||
// The menu treats the slider's keys as navigation between its items, which
|
||||
// would otherwise stop the slider from ever receiving them. The handler has
|
||||
// to sit on the wrapper because Slider takes no key handler of its own.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<div
|
||||
className={styles.volumeRow}
|
||||
data-testid="sound_effect_volume"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
onPointerMove={onPointerMove}
|
||||
onKeyDown={(e) => {
|
||||
if (SLIDER_KEYS.has(e.key)) e.stopPropagation();
|
||||
else keepTabInsideMenu(e);
|
||||
}}
|
||||
>
|
||||
<span className={styles.volumeLabel}>{label}</span>
|
||||
<div className={styles.volumeControl}>
|
||||
<VolumeOnIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={label}
|
||||
value={raw}
|
||||
onValueChange={setRaw}
|
||||
onValueCommit={onCommit}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
// Slider names its thumb after the tooltip, so the tooltip has to
|
||||
// carry what the control is, not just its value.
|
||||
tooltipFormatter={(v) => `${label}: ${Math.round(v * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets Tab move focus between the device rows, the level meter and the
|
||||
* sound-effect slider. The menu swallows Tab so that its items are walked
|
||||
* with the arrow keys; kept from it, the browser's own focus order takes over,
|
||||
* and the menu's focus trap keeps that order inside the menu.
|
||||
*/
|
||||
function keepTabInsideMenu(e: KeyboardEvent<HTMLElement>): void {
|
||||
if (e.key === "Tab") e.stopPropagation();
|
||||
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)));
|
||||
}
|
||||
@@ -5,367 +5,37 @@ 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, test, vi } from "vitest";
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
useMicrophoneLevel,
|
||||
type MicrophoneLevelState,
|
||||
} from "./useMicrophoneLevel";
|
||||
|
||||
/** However many levels a caller says it can draw; the meter asks for 18. */
|
||||
const STEPS = 18;
|
||||
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", () => {
|
||||
test("level indicator follows the microphone signal level", async () => {
|
||||
const { stream } = fakeStream();
|
||||
const getUserMedia = vi.fn().mockResolvedValue(stream);
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
afterEach(restoreAudioCapture);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
expect(result.current).toEqual({ type: "active", level: 0 });
|
||||
test("holds no capture while the meter is not shown", () => {
|
||||
const capture = stubAudioCapture();
|
||||
|
||||
// Speech arrives.
|
||||
amplitude = 0.2;
|
||||
tick(4);
|
||||
expect(result.current.type).toBe("active");
|
||||
const loud = result.current as { type: "active"; level: number };
|
||||
expect(loud.level).toBeGreaterThan(0.3);
|
||||
renderHook(() => useMicrophoneLevel("mic1", false));
|
||||
|
||||
// ...and stops. The level eases back down rather than snapping to zero.
|
||||
amplitude = 0;
|
||||
tick(30);
|
||||
const quiet = result.current as { type: "active"; level: number };
|
||||
expect(quiet.level).toBeLessThan(loud.level);
|
||||
expect(capture.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("metering resumes a capture the browser starts suspended", async () => {
|
||||
const { stream } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
|
||||
// A suspended context reports silence however loud the microphone is,
|
||||
// which is what Firefox hands us when the page has no user activation.
|
||||
expect(resumed).toBe(1);
|
||||
});
|
||||
|
||||
test("a silent microphone re-renders nothing", async () => {
|
||||
const { stream } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
const probe = renderProbe();
|
||||
await waitFor(() => expect(probe.state().type).toBe("active"));
|
||||
|
||||
// The capture keeps sampling, but a frame that would draw the same bars
|
||||
// must not re-render the menu the meter sits in.
|
||||
const before = probe.renders();
|
||||
tick(30);
|
||||
expect(probe.renders() - before).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("a steady tone re-renders only until the level settles", async () => {
|
||||
const { stream } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
const probe = renderProbe();
|
||||
await waitFor(() => expect(probe.state().type).toBe("active"));
|
||||
|
||||
amplitude = 0.2;
|
||||
tick(20);
|
||||
expect(probe.state()).toEqual({
|
||||
type: "active",
|
||||
level: expect.any(Number),
|
||||
});
|
||||
|
||||
const before = probe.renders();
|
||||
tick(20);
|
||||
expect(probe.renders() - before).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("the reported level is one the meter can draw", async () => {
|
||||
const { stream } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
|
||||
amplitude = 0.2;
|
||||
tick(6);
|
||||
const { level } = result.current as { level: number };
|
||||
expect(level * STEPS).toBeCloseTo(Math.round(level * STEPS), 9);
|
||||
});
|
||||
|
||||
test("level indicator stays idle for a silent microphone", async () => {
|
||||
const { stream } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
tick(10);
|
||||
expect(result.current).toEqual({ type: "active", level: 0 });
|
||||
});
|
||||
|
||||
test("capture is re-pointed when the microphone changes", async () => {
|
||||
const first = fakeStream();
|
||||
const second = fakeStream();
|
||||
const getUserMedia = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first.stream)
|
||||
.mockResolvedValueOnce(second.stream);
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
test("starts from nothing rather than the previous device's level", () => {
|
||||
const capture = stubAudioCapture();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS),
|
||||
{ initialProps: { id: "mic-1" } },
|
||||
({ id }: { id: string }) => useMicrophoneLevel(id, true),
|
||||
{ initialProps: { id: "mic1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||
audio: { deviceId: { exact: "mic-1" } },
|
||||
});
|
||||
capture.grant();
|
||||
|
||||
rerender({ id: "mic-2" });
|
||||
await waitFor(() =>
|
||||
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||
audio: { deviceId: { exact: "mic-2" } },
|
||||
}),
|
||||
);
|
||||
// The capture of the microphone we left must not survive the switch.
|
||||
expect(first.stop).toHaveBeenCalled();
|
||||
expect(second.stop).not.toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
|
||||
test("menu capture is released when closed during a device switch", async () => {
|
||||
const { stream, stop } = fakeStream();
|
||||
let resolveCapture: (s: MediaStream) => void = () => {};
|
||||
const capture = new Promise<MediaStream>((resolve) => {
|
||||
resolveCapture = resolve;
|
||||
});
|
||||
const getUserMedia = vi.fn().mockReturnValue(capture);
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS),
|
||||
{ initialProps: { on: true } },
|
||||
);
|
||||
|
||||
// The menu closes before the microphone finishes opening.
|
||||
rerender({ on: false });
|
||||
await act(async () => {
|
||||
resolveCapture(stream);
|
||||
await capture;
|
||||
});
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(result.current).toEqual({ type: "inactive" });
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("microphone capture is closed when metering stops", async () => {
|
||||
const { stream, stop } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on, STEPS),
|
||||
{ initialProps: { on: true } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.type).toBe("active"));
|
||||
|
||||
rerender({ on: false });
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(closed).toBe(1);
|
||||
expect(result.current).toEqual({ type: "inactive" });
|
||||
});
|
||||
|
||||
test("a denied microphone is distinguished from one that cannot be opened", async () => {
|
||||
const denied = Object.assign(new Error("no"), { name: "NotAllowedError" });
|
||||
const busy = Object.assign(new Error("busy"), { name: "NotReadableError" });
|
||||
const getUserMedia = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(denied)
|
||||
.mockRejectedValueOnce(busy);
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useMicrophoneLevel(id, true, STEPS),
|
||||
{ initialProps: { id: "mic-1" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current).toEqual({ type: "denied" }));
|
||||
|
||||
rerender({ id: "mic-2" });
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({ type: "unavailable" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("level indicator is unavailable where the page has no media devices", () => {
|
||||
vi.stubGlobal("navigator", {});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
expect(result.current).toEqual({ type: "unavailable" });
|
||||
});
|
||||
|
||||
test("a capture is released when the audio graph fails to build", async () => {
|
||||
const { stream, stop } = fakeStream();
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
|
||||
});
|
||||
// Whatever the browser does after the microphone is already open.
|
||||
vi.stubGlobal(
|
||||
"AudioContext",
|
||||
class {
|
||||
public constructor() {
|
||||
throw new Error("no audio backend");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", true, STEPS),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({ type: "unavailable" }),
|
||||
);
|
||||
|
||||
// Otherwise the microphone stays open, and its in-use light on, behind a
|
||||
// meter that says it is unavailable.
|
||||
expect(stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a missing microphone is reported as absent, not as silence", () => {
|
||||
const getUserMedia = vi.fn();
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel(undefined, true, STEPS),
|
||||
);
|
||||
expect(result.current).toEqual({ type: "absent" });
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("no capture is taken while metering is disabled", () => {
|
||||
const getUserMedia = vi.fn();
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMicrophoneLevel("mic-1", false, STEPS),
|
||||
);
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual({ type: "inactive" });
|
||||
});
|
||||
|
||||
let amplitude = 0;
|
||||
let frames: (() => void)[] = [];
|
||||
let closed = 0;
|
||||
let resumed = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
amplitude = 0;
|
||||
frames = [];
|
||||
closed = 0;
|
||||
resumed = 0;
|
||||
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {
|
||||
frames = [];
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"AudioContext",
|
||||
class {
|
||||
public createAnalyser(): unknown {
|
||||
return {
|
||||
fftSize: 1024,
|
||||
getFloatTimeDomainData: (out: Float32Array): void => {
|
||||
out.fill(amplitude);
|
||||
},
|
||||
};
|
||||
}
|
||||
public createMediaStreamSource(): { connect: () => void } {
|
||||
return { connect: (): void => {} };
|
||||
}
|
||||
public async resume(): Promise<void> {
|
||||
resumed++;
|
||||
await Promise.resolve();
|
||||
}
|
||||
public async close(): Promise<void> {
|
||||
closed++;
|
||||
await Promise.resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
/** A microphone that reports a constant amplitude on every sample. */
|
||||
function fakeStream(): {
|
||||
stream: MediaStream;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const stop = vi.fn();
|
||||
const stream = {
|
||||
getTracks: () => [{ stop }],
|
||||
} as unknown as MediaStream;
|
||||
return { stream, stop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component around the hook and counts how often it renders, which
|
||||
* is what the menu around the meter would pay on every animation frame.
|
||||
*/
|
||||
function renderProbe(): {
|
||||
renders: () => number;
|
||||
state: () => MicrophoneLevelState;
|
||||
} {
|
||||
let renders = 0;
|
||||
let state: MicrophoneLevelState = { type: "inactive" };
|
||||
function Probe(): null {
|
||||
const current = useMicrophoneLevel("mic-1", true, STEPS);
|
||||
// An effect with no dependencies runs once per commit, so a frame
|
||||
// React bails out of is not counted.
|
||||
useEffect(() => {
|
||||
renders++;
|
||||
state = current;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return { renders: () => renders, state: () => state };
|
||||
}
|
||||
|
||||
/** Runs one animation frame, if the hook has asked for one. */
|
||||
function tick(times = 1): void {
|
||||
for (let i = 0; i < times; i++) {
|
||||
const pending = frames;
|
||||
frames = [];
|
||||
act(() => {
|
||||
pending.forEach((f) => f());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,179 +6,29 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
/**
|
||||
* The state of the microphone level indicator.
|
||||
*
|
||||
* `inactive` is the resting state: nothing is being captured, because nothing
|
||||
* has asked for a level yet. `absent` means there is no microphone to capture
|
||||
* from, which is not the same as one that hears nothing. `active` means we
|
||||
* hold a capture and `level` is the current signal level in the range 0..1.
|
||||
* `denied` and `unavailable` are the two failure modes we distinguish, because
|
||||
* they need different UI: the first is recoverable by the user, the second is
|
||||
* not.
|
||||
*/
|
||||
export type MicrophoneLevelState =
|
||||
| { type: "inactive" }
|
||||
| { type: "absent" }
|
||||
| { type: "active"; level: number }
|
||||
| { type: "denied" }
|
||||
| { type: "unavailable" };
|
||||
import {
|
||||
type MicrophoneState,
|
||||
observeMicrophoneState$,
|
||||
} from "../state/MicrophoneLevel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
/** Signal at or below this many dBFS reads as silence. */
|
||||
const FLOOR_DB = -60;
|
||||
const IDLE: MicrophoneState = { type: "level", level$: constant(0) };
|
||||
|
||||
/**
|
||||
* Smoothing applied to the displayed level. Rises are followed almost
|
||||
* immediately so speech registers at once; falls are eased so the bars do not
|
||||
* flicker between syllables.
|
||||
*/
|
||||
const ATTACK = 0.5;
|
||||
const RELEASE = 0.12;
|
||||
|
||||
/**
|
||||
* Observes the signal level of a microphone, for as long as `enabled` holds.
|
||||
*
|
||||
* The capture is owned by this hook and is torn down whenever `enabled` goes
|
||||
* false, the device changes, or the component unmounts, including when any of
|
||||
* those happen while the capture is still being acquired. Nothing outlives the
|
||||
* caller.
|
||||
*
|
||||
* @param deviceId - The microphone to observe, or undefined if none is selected.
|
||||
* @param enabled - Whether to hold a capture at all.
|
||||
* @param steps - How many levels the caller can tell apart. The level is
|
||||
* rounded to one of them, and movement within a step reports nothing new.
|
||||
*/
|
||||
/** The live level of a microphone, captured only while `active`. */
|
||||
export function useMicrophoneLevel(
|
||||
deviceId: string | undefined,
|
||||
enabled: boolean,
|
||||
steps: number,
|
||||
): MicrophoneLevelState {
|
||||
const [state, setState] = useState<MicrophoneLevelState>({
|
||||
type: "inactive",
|
||||
});
|
||||
active: boolean,
|
||||
): MicrophoneState {
|
||||
const [state, setState] = useState<MicrophoneState>(IDLE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ type: "inactive" });
|
||||
return;
|
||||
}
|
||||
if (deviceId === undefined) {
|
||||
setState({ type: "absent" });
|
||||
return;
|
||||
}
|
||||
// Insecure contexts have no media devices at all; nothing can be metered.
|
||||
if (!("mediaDevices" in navigator)) {
|
||||
setState({ type: "unavailable" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Guards every asynchronous continuation below: the effect can be cleaned
|
||||
// up while getUserMedia is still in flight, and the stream it eventually
|
||||
// resolves with must be stopped rather than left running.
|
||||
let disposed = false;
|
||||
let stream: MediaStream | undefined;
|
||||
let context: AudioContext | undefined;
|
||||
let frame: number | undefined;
|
||||
|
||||
/** Gives back whatever has been acquired so far. */
|
||||
const release = (): void => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
// close() rejects if the context is already closed, which is possible if
|
||||
// the browser tore it down with the page.
|
||||
context?.close().catch(() => {});
|
||||
stream = undefined;
|
||||
context = undefined;
|
||||
frame = undefined;
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
disposed = true;
|
||||
release();
|
||||
};
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: { deviceId: { exact: deviceId } } })
|
||||
.then((acquired) => {
|
||||
if (disposed) {
|
||||
acquired.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
stream = acquired;
|
||||
context = new AudioContext();
|
||||
// Firefox starts a context suspended unless the page has user
|
||||
// activation, and a suspended context feeds the analyser silence
|
||||
// rather than the microphone. Resuming one that already runs is a
|
||||
// no-op.
|
||||
void context.resume().catch(() => {});
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
context.createMediaStreamSource(acquired).connect(analyser);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
|
||||
let smoothed = 0;
|
||||
const tick = (): void => {
|
||||
if (disposed) return;
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
const level = amplitudeToLevel(rms(samples));
|
||||
smoothed +=
|
||||
(level - smoothed) * (level > smoothed ? ATTACK : RELEASE);
|
||||
// Keep the last value when the level has not moved a whole step:
|
||||
// a frame that would redraw the same picture must not re-render
|
||||
// anything. Silence therefore costs nothing at all, since the level
|
||||
// rests at zero.
|
||||
const stepped = Math.round(smoothed * steps) / steps;
|
||||
setState((previous) =>
|
||||
previous.type === "active" && previous.level === stepped
|
||||
? previous
|
||||
: { type: "active", level: stepped },
|
||||
);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
setState({ type: "active", level: 0 });
|
||||
frame = requestAnimationFrame(tick);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (disposed) return;
|
||||
// The microphone may already be open: building the audio graph can
|
||||
// fail after getUserMedia has resolved, and the capture would then
|
||||
// outlive its own failure, holding the microphone-in-use indicator on
|
||||
// behind a meter that says the microphone is unavailable.
|
||||
release();
|
||||
rootLogger
|
||||
.getChild("[useMicrophoneLevel]")
|
||||
.warn("Could not open microphone for level metering", e);
|
||||
setState(classifyError(e));
|
||||
});
|
||||
|
||||
return dispose;
|
||||
}, [deviceId, enabled, steps]);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Root mean square of a block of samples, as a linear amplitude. */
|
||||
function rms(samples: Float32Array): number {
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s * s;
|
||||
return Math.sqrt(sum / samples.length);
|
||||
}
|
||||
|
||||
/** Maps a linear amplitude onto 0..1 over a fixed dBFS window. */
|
||||
function amplitudeToLevel(amplitude: number): number {
|
||||
if (amplitude <= 0) return 0;
|
||||
const db = 20 * Math.log10(amplitude);
|
||||
if (db <= FLOOR_DB) return 0;
|
||||
return Math.min(1, db / -FLOOR_DB + 1);
|
||||
}
|
||||
|
||||
function classifyError(e: unknown): MicrophoneLevelState {
|
||||
const name = e instanceof Error ? e.name : "";
|
||||
// NotAllowedError is the modern name; SecurityError is what older WebKit
|
||||
// raises for the same situation.
|
||||
if (name === "NotAllowedError" || name === "SecurityError")
|
||||
return { type: "denied" };
|
||||
return { type: "unavailable" };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
LoggerNames as LKLoggerNames,
|
||||
} from "livekit-client";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
@@ -226,6 +227,9 @@ export class Initializer {
|
||||
|
||||
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
||||
setLKLogLevel(enabled ? "trace" : "info");
|
||||
// ICE candidate types and connection states are what you need to diagnose
|
||||
// "could not establish pc connection", so always keep those in the rageshake
|
||||
setLKLogLevel("debug", LKLoggerNames.ICE);
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -24,10 +24,12 @@ import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
@@ -131,6 +133,57 @@ it("should render for member", () => {
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
function spyOnWarn(): ReturnType<typeof vi.fn> {
|
||||
const warn = vi.fn();
|
||||
vi.spyOn(rootLogger, "getChild").mockReturnValue({
|
||||
warn,
|
||||
} as unknown as typeof rootLogger);
|
||||
return warn;
|
||||
}
|
||||
|
||||
it("should not render or warn for the local participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
|
||||
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
|
||||
const { queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should warn only once per unexpected participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const { rerender } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
[
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
rerender(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
@@ -60,6 +60,9 @@ export function LivekitRoomAudioRenderer({
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
// Identities we have already warned about, so that re-renders (which happen
|
||||
// on every active speaker update) don't repeat the warning.
|
||||
const warnedIdentities = useRef(new Set<string>());
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -72,22 +75,27 @@ export function LivekitRoomAudioRenderer({
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Only keep remote audio tracks (we never render our own audio)
|
||||
.filter(
|
||||
(ref) =>
|
||||
ref.publication.kind === Track.Kind.Audio && !ref.participant.isLocal,
|
||||
)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
const { identity } = ref.participant;
|
||||
const isValid = validIdentities.includes(identity);
|
||||
if (!isValid && !warnedIdentities.current.has(identity)) {
|
||||
warnedIdentities.current.add(identity);
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
logger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`Audio track ${identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
return false;
|
||||
} else if (isValid) {
|
||||
warnedIdentities.current.delete(identity);
|
||||
}
|
||||
return true;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import {
|
||||
type BackgroundOptions,
|
||||
type ProcessorWrapper,
|
||||
} from "@livekit/track-processors";
|
||||
|
||||
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const processor = {} as ProcessorWrapper<BackgroundOptions>;
|
||||
|
||||
function mockTrack(
|
||||
readyState: MediaStreamTrackState,
|
||||
current?: ProcessorWrapper<BackgroundOptions>,
|
||||
): LocalVideoTrack {
|
||||
return {
|
||||
mediaStreamTrack: { readyState },
|
||||
getProcessor: vi.fn().mockReturnValue(current),
|
||||
setProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
stopProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as LocalVideoTrack;
|
||||
}
|
||||
|
||||
describe("applyProcessor", () => {
|
||||
it("attaches the processor to a live track", () => {
|
||||
const track = mockTrack("live");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
|
||||
it("does not attach the processor to an ended track", () => {
|
||||
const track = mockTrack("ended");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected setProcessor", async () => {
|
||||
const track = mockTrack("live");
|
||||
vi.mocked(track.setProcessor).mockRejectedValue(
|
||||
new TypeError("Input track cannot be ended"),
|
||||
);
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, processor);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the processor when none is wanted", () => {
|
||||
const track = mockTrack("live", processor);
|
||||
applyProcessor(track, undefined);
|
||||
expect(track.stopProcessor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected stopProcessor", async () => {
|
||||
const track = mockTrack("live", processor);
|
||||
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, undefined);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackProcessorSync", () => {
|
||||
it("applies the processor to the current track", () => {
|
||||
const track = mockTrack("live");
|
||||
trackProcessorSync(
|
||||
testScope(),
|
||||
constant(track),
|
||||
constant({ supported: true, processor }),
|
||||
);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
@@ -30,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.
|
||||
@@ -51,18 +52,36 @@ 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],
|
||||
);
|
||||
export function useTrackProcessorState$(): Behavior<ProcessorState> {
|
||||
return useValueBehavior(useTrackProcessor());
|
||||
}
|
||||
|
||||
return state$;
|
||||
/**
|
||||
* Attaches or detaches the processor so that the track matches the desired
|
||||
* state, without throwing.
|
||||
*/
|
||||
export function applyProcessor(
|
||||
videoTrack: LocalVideoTrack,
|
||||
processor: ProcessorWrapper<BackgroundOptions> | undefined,
|
||||
): void {
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
// A MediaStreamTrackProcessor cannot be constructed on an ended track
|
||||
// (e.g. the camera was stopped while the processor was being applied),
|
||||
// and setProcessor rejects with a TypeError. The track is going away
|
||||
// anyway, so there is nothing to attach to.
|
||||
if (videoTrack.mediaStreamTrack.readyState === "ended") {
|
||||
logger.debug("Not attaching video processor to an ended track");
|
||||
return;
|
||||
}
|
||||
videoTrack.setProcessor(processor).catch((e) => {
|
||||
logger.warn("Failed to attach video processor", e);
|
||||
});
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
videoTrack.stopProcessor().catch((e) => {
|
||||
logger.warn("Failed to stop video processor", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,13 +97,7 @@ export const trackProcessorSync = (
|
||||
.subscribe(([videoTrack, processorState]) => {
|
||||
if (!processorState) return;
|
||||
if (!videoTrack) return;
|
||||
const { processor } = processorState;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
applyProcessor(videoTrack, processorState.processor);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,12 +107,7 @@ export const useTrackProcessorSync = (
|
||||
const { processor } = useTrackProcessor();
|
||||
useEffect(() => {
|
||||
if (!videoTrack) return;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
applyProcessor(videoTrack, processor);
|
||||
}, [processor, videoTrack]);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import waveSoundOgg from "../sound/reactions/wave.ogg?url";
|
||||
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
|
||||
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
|
||||
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
|
||||
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
|
||||
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
|
||||
|
||||
export const ElementCallReactionEventType = "io.element.call.reaction";
|
||||
|
||||
@@ -202,6 +204,15 @@ export const ReactionSet: ReactionOption[] = [
|
||||
mp3: baduntssSoundMp3,
|
||||
},
|
||||
},
|
||||
{
|
||||
emoji: "🛎️",
|
||||
name: "bell",
|
||||
alias: ["ding"],
|
||||
sound: {
|
||||
ogg: bellSoundOgg,
|
||||
mp3: bellSoundMp3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface RaisedHandInfo {
|
||||
|
||||
+8
-12
@@ -20,8 +20,6 @@ import {
|
||||
import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -77,10 +75,11 @@ import {
|
||||
useAppBarSubtitle,
|
||||
} from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useValueBehavior } from "../useValueBehavior.ts";
|
||||
import { constant } from "../state/Behavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
@@ -121,7 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
const trackProcessorState$ = useTrackProcessorState$();
|
||||
// The element we have to draw the call in: the page, or the container a host
|
||||
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||
const rootElement = useRootElement();
|
||||
@@ -151,7 +150,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
scope.behavior(trackProcessorState$),
|
||||
trackProcessorState$,
|
||||
);
|
||||
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||
vm.join();
|
||||
@@ -252,7 +251,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
useWakeLock();
|
||||
// TODO-MULTI-SFU This is unused now??
|
||||
// const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
||||
// const connectionState = useBehavior(vm.livekitConnectionState$);
|
||||
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
@@ -374,10 +373,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
edgeToEdge,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable$ = useObservable(
|
||||
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
|
||||
[gridBounds],
|
||||
);
|
||||
const gridBounds$ = useValueBehavior(gridBounds);
|
||||
|
||||
useAppBarHidden(!showHeader);
|
||||
useAppBarSubtitle(
|
||||
@@ -538,7 +534,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
const inputs = { minBounds$: gridBoundsObservable$ };
|
||||
const inputs = { minBounds$: gridBounds$ };
|
||||
return {
|
||||
grid: makeGridLayout(inputs),
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
@@ -547,7 +543,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
||||
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable$]);
|
||||
}, [gridBounds$]);
|
||||
|
||||
const showFooter = useBehavior(footerVm.showFooter$);
|
||||
const renderContent = (): JSX.Element => {
|
||||
|
||||
@@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { of } from "rxjs";
|
||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -18,7 +20,13 @@ import {
|
||||
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { mockMediaDevices, mockMuteStates } from "../utils/test";
|
||||
import {
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
restoreAudioCapture,
|
||||
stubAudioCapture,
|
||||
} from "../utils/test";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
@@ -77,9 +85,10 @@ function renderLobbyView(
|
||||
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
||||
withAppBar = false,
|
||||
platform = "android",
|
||||
devices: Partial<MediaDevices> = {},
|
||||
): ReturnType<typeof render> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
const mediaDevices = mockMediaDevices(devices);
|
||||
const muteStates = mockMuteStates();
|
||||
const hideHeader = withAppBar ? true : false;
|
||||
const lobbyView = (
|
||||
@@ -178,3 +187,30 @@ describe("LobbyView", () => {
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LobbyView microphone level", () => {
|
||||
afterEach(restoreAudioCapture);
|
||||
|
||||
it("shows the microphone level meter", async () => {
|
||||
const capture = stubAudioCapture();
|
||||
capture.grant();
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderLobbyView({}, false, "desktop", {
|
||||
requestDeviceNames: (): void => {},
|
||||
audioInput: {
|
||||
available$: of(
|
||||
new Map([["mic1", { type: "name", name: "Microphone 1" }]]),
|
||||
),
|
||||
selected$: of({ id: "mic1" }),
|
||||
select: (): void => {},
|
||||
},
|
||||
} as unknown as Partial<MediaDevices>);
|
||||
|
||||
// Pre-join reaches the meter through the same chevron as a call.
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("meter", { name: "Microphone level" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -125,9 +124,7 @@ export const LobbyView: FC<Props> = ({
|
||||
);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const videoInputId = useObservableEagerState(
|
||||
devices.videoInput.selected$,
|
||||
)?.id;
|
||||
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
|
||||
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
// we're not doing anything with the audio anyway so we don't need to
|
||||
|
||||
@@ -328,67 +328,36 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="_container_e649de"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_l_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_q_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_s_"
|
||||
aria-labelledby="_r_l_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_q_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -413,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_11_"
|
||||
aria-labelledby="_r_v_"
|
||||
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -436,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_19_"
|
||||
aria-labelledby="_r_17_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -464,8 +433,8 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
data-size="lg"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="_r_1f_"
|
||||
name="_r_1e_"
|
||||
aria-labelledby="_r_1d_"
|
||||
name="_r_1c_"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
/>
|
||||
@@ -484,9 +453,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="_r_1k_"
|
||||
aria-labelledby="_r_1i_"
|
||||
checked=""
|
||||
name="_r_1e_"
|
||||
name="_r_1c_"
|
||||
type="radio"
|
||||
value="grid"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_3c_"
|
||||
aria-labelledby="_r_36_"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
@@ -102,7 +102,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_3i_"
|
||||
aria-labelledby="_r_3c_"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
@@ -133,7 +133,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_3n_"
|
||||
aria-labelledby="_r_3h_"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -154,67 +154,36 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="_container_e649de"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_3s_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_41_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_43_"
|
||||
aria-labelledby="_r_3m_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_3r_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -236,7 +205,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_48_"
|
||||
aria-labelledby="_r_40_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -270,7 +239,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_4i_"
|
||||
aria-labelledby="_r_4a_"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
@@ -365,7 +334,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_4o_"
|
||||
aria-labelledby="_r_4g_"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
@@ -396,7 +365,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_4t_"
|
||||
aria-labelledby="_r_4l_"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -417,67 +386,36 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="_container_e649de"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_52_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_57_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_59_"
|
||||
aria-labelledby="_r_4q_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_4v_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -499,7 +437,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_5e_"
|
||||
aria-labelledby="_r_54_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -830,67 +768,36 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="_container_e649de"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_g_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_l_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_n_"
|
||||
aria-labelledby="_r_g_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_l_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -912,7 +819,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_s_"
|
||||
aria-labelledby="_r_q_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -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.
@@ -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,
|
||||
@@ -39,11 +38,12 @@ import {
|
||||
throttleTime,
|
||||
timer,
|
||||
takeUntil,
|
||||
from,
|
||||
} from "rxjs";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
@@ -112,7 +112,7 @@ import {
|
||||
TransportState,
|
||||
} from "./localMember/LocalMember.ts";
|
||||
import {
|
||||
createLocalTransport$,
|
||||
getLocalTransport,
|
||||
type LocalTransport,
|
||||
} from "./localMember/LocalTransport.ts";
|
||||
import {
|
||||
@@ -563,20 +563,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 +599,7 @@ export function createCallViewModel$(
|
||||
const connectionManager = createConnectionManager$({
|
||||
scope: scope,
|
||||
connectionFactory: connectionFactory,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport.active$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
logger.info(
|
||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||
e,
|
||||
);
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
),
|
||||
localTransport$,
|
||||
remoteTransports$: membershipsAndTransports.transports$,
|
||||
logger: logger,
|
||||
ownMembershipIdentity,
|
||||
@@ -628,7 +624,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
muteStates,
|
||||
joinMatrixRTC: (
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||
) => {
|
||||
return enterRTCSession(
|
||||
@@ -659,7 +655,7 @@ export function createCallViewModel$(
|
||||
connectionManager,
|
||||
client,
|
||||
matrixRTCSession,
|
||||
localTransport,
|
||||
localTransport$,
|
||||
roomId: matrixRoom.roomId,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
|
||||
@@ -210,11 +210,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
connectionState$,
|
||||
windowSize$,
|
||||
localTransport: {
|
||||
active$: constant({
|
||||
transport: exampleTransport,
|
||||
sfuConfig: exampleSfuConfig,
|
||||
}),
|
||||
advertised$: constant(exampleTransport),
|
||||
transport: exampleTransport,
|
||||
sfuConfig: exampleSfuConfig,
|
||||
},
|
||||
connectionFactory: {
|
||||
createConnection(
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
Status as RTCMemberStatus,
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
import { BehaviorSubject, map, of, Subject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LocalParticipant, type LocalTrack } from "livekit-client";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -50,10 +50,7 @@ import {
|
||||
TrackState,
|
||||
watchScreenShareToggle,
|
||||
} from "./LocalMember";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
MatrixRTCTransportMissingError,
|
||||
} from "../../../utils/errors";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { constant } from "../../Behavior";
|
||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||
@@ -61,10 +58,7 @@ import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
import { nullHostBridge } from "../../../HostBridge";
|
||||
import {
|
||||
type LocalTransport,
|
||||
type LocalTransportWithSFUConfig,
|
||||
} from "./LocalTransport";
|
||||
import { type LocalTransport } from "./LocalTransport";
|
||||
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||
|
||||
initializeWidget();
|
||||
@@ -127,8 +121,7 @@ const mockedClient = {
|
||||
};
|
||||
|
||||
describe("enterRTCSession", () => {
|
||||
const transport: LivekitTransportConfig = {
|
||||
livekit_alias: "roomId",
|
||||
const transport: LivekitTransport = {
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -145,7 +138,7 @@ describe("enterRTCSession", () => {
|
||||
client: mockedClient,
|
||||
},
|
||||
memberships: [],
|
||||
joinRTCSession: vi.fn(),
|
||||
join: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
beforeEach(() =>
|
||||
@@ -157,19 +150,18 @@ describe("enterRTCSession", () => {
|
||||
it("It joins the correct Session", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
{
|
||||
deviceId: "DEVICE",
|
||||
memberId: "@alice:example.org:DEVICE",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
[],
|
||||
transport,
|
||||
[transport],
|
||||
expect.objectContaining({ manageMediaKeys: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
|
||||
it("passes keyRotationParticipantLimit from config to join", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
matrix_rtc_session: {
|
||||
@@ -182,9 +174,8 @@ describe("enterRTCSession", () => {
|
||||
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
[],
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
keyRotationParticipantLimit: 50,
|
||||
@@ -195,8 +186,7 @@ describe("enterRTCSession", () => {
|
||||
it("uses the specified delayed leave timings", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -216,7 +206,7 @@ describe("LocalMembership", () => {
|
||||
}),
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
||||
leaveRoomSession: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
} as unknown as MatrixRTCSession,
|
||||
muteStates: mockMuteStates(),
|
||||
trackProcessorState$: constant({
|
||||
@@ -261,8 +251,8 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||
const localTransport$ = scope.behavior<null | LivekitTransport>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
@@ -277,16 +267,15 @@ describe("LocalMembership", () => {
|
||||
),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: localTransport$,
|
||||
active$: behavior("a", { a: null }),
|
||||
};
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: hot(
|
||||
"1ms #",
|
||||
{},
|
||||
new MatrixRTCTransportMissingError("domain.com"),
|
||||
),
|
||||
});
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
||||
@@ -296,61 +285,9 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Should not publish to active transport if advertised has errors", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
|
||||
null,
|
||||
);
|
||||
|
||||
// Populate a connection for active
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportBConnected, []);
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([bTransport])),
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
};
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$,
|
||||
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
|
||||
};
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
return {} as unknown as Publisher;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
});
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("n-e", {
|
||||
n: TransportState.Waiting,
|
||||
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
|
||||
});
|
||||
|
||||
// Should not have created any publisher
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs if callIntent cannot be updated", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const mockConnectionManager = {
|
||||
transports$: constant(new Epoch([])),
|
||||
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
|
||||
@@ -363,10 +300,10 @@ describe("LocalMembership", () => {
|
||||
...defaultCreateLocalMemberValues,
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockImplementation(reject),
|
||||
leaveRoomSession: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
},
|
||||
connectionManager: mockConnectionManager,
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
const expextedLog =
|
||||
"'not connected yet' while updating the call intent (this is expected on startup)";
|
||||
@@ -378,33 +315,19 @@ describe("LocalMembership", () => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
const aTransport = {
|
||||
const mockTransportConfig = {
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransportConfig;
|
||||
} as LivekitTransport;
|
||||
|
||||
const aTransportWithSFUConfig = {
|
||||
transport: aTransport,
|
||||
const mockTransport = {
|
||||
transport: mockTransportConfig,
|
||||
sfuConfig: {
|
||||
jwt: "foo",
|
||||
livekitAlias: "bar",
|
||||
livekitIdentity: "baz",
|
||||
url: "bro",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
|
||||
const bTransport = {
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const bTransportWithSFUConfig = {
|
||||
transport: bTransport,
|
||||
sfuConfig: {
|
||||
jwt: "foo2",
|
||||
livekitAlias: "bar2",
|
||||
livekitIdentity: "baz2",
|
||||
url: "bro2",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
} as LocalTransport;
|
||||
|
||||
const connectionTransportAConnected = {
|
||||
livekitRoom: mockLivekitRoom({
|
||||
@@ -414,18 +337,13 @@ describe("LocalMembership", () => {
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport,
|
||||
} as unknown as Connection;
|
||||
transport: mockTransportConfig,
|
||||
} as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
state$: constant(ConnectionState.LivekitConnecting),
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
const connectionTransportBConnected = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: bTransport,
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const authCallSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
@@ -459,10 +377,7 @@ describe("LocalMembership", () => {
|
||||
),
|
||||
},
|
||||
joinMatrixRTC,
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
delayId$,
|
||||
});
|
||||
|
||||
@@ -471,7 +386,7 @@ describe("LocalMembership", () => {
|
||||
await flushPromises();
|
||||
// Joins with timings appropriate for the level of delegation support
|
||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||
aTransport,
|
||||
mockTransportConfig,
|
||||
delayedLeaveTimings,
|
||||
);
|
||||
|
||||
@@ -505,75 +420,7 @@ describe("LocalMembership", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
let seed = 0;
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
const a = seed;
|
||||
seed += 1;
|
||||
logger.info(`creating [${a}]`);
|
||||
const p = {
|
||||
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
|
||||
// all the cleanup we need.
|
||||
destroy: vi.fn(),
|
||||
stopPublishing: vi.fn().mockImplementation(() => {
|
||||
logger.info(`stopPublishing [${a}]`);
|
||||
}),
|
||||
stopTracks: vi.fn(),
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
connectionManagerData.add(connectionTransportBConnected, []);
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
});
|
||||
await flushPromises();
|
||||
activeTransport$.next({
|
||||
...aTransportWithSFUConfig,
|
||||
transport: bTransport,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(publisherFactory).toHaveBeenCalledTimes(2);
|
||||
expect(publishers.length).toBe(2);
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[1].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[1].stopTracks).toHaveBeenCalled();
|
||||
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
|
||||
});
|
||||
|
||||
it("only start tracks if requested", async () => {
|
||||
it("only starts tracks if requested", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
@@ -602,11 +449,6 @@ describe("LocalMembership", () => {
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
};
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
// connectionManagerData.add(connectionTransportB, []);
|
||||
@@ -616,7 +458,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||
@@ -637,16 +479,8 @@ describe("LocalMembership", () => {
|
||||
//
|
||||
it("tracks livekit state correctly", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
|
||||
const activeTransport$ =
|
||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||
|
||||
const aLocalTransport: LocalTransport = {
|
||||
advertised$: constant(aTransport),
|
||||
active$: activeTransport$,
|
||||
};
|
||||
const localTransport$ = new Subject<LocalTransport>();
|
||||
|
||||
const connectionManagerData$ = new BehaviorSubject(
|
||||
new Epoch(connectionManagerData),
|
||||
@@ -687,14 +521,14 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$,
|
||||
},
|
||||
localTransport: aLocalTransport,
|
||||
localTransport$,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual(
|
||||
TransportState.Waiting,
|
||||
);
|
||||
activeTransport$.next(aTransportWithSFUConfig);
|
||||
localTransport$.next(mockTransport);
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
@@ -719,7 +553,7 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
(
|
||||
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||
connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next(ConnectionState.LivekitConnected);
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
@@ -822,10 +656,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -862,10 +693,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -913,10 +741,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -957,10 +782,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
@@ -1026,10 +848,7 @@ describe("LocalMembership", () => {
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport: {
|
||||
advertised$: constant(aTransport),
|
||||
active$: constant(aTransportWithSFUConfig),
|
||||
},
|
||||
localTransport$: constant(mockTransport),
|
||||
});
|
||||
return { scope, localMembership };
|
||||
};
|
||||
@@ -1039,7 +858,7 @@ describe("LocalMembership", () => {
|
||||
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
|
||||
const connection = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport,
|
||||
transport: mockTransportConfig,
|
||||
livekitRoom: mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({
|
||||
isScreenShareEnabled: false,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
Status as RTCSessionStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
@@ -38,6 +37,10 @@ import {
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
NEVER,
|
||||
concat,
|
||||
race,
|
||||
Subject,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
@@ -77,8 +80,6 @@ import {
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { type LocalTransport } from "./LocalTransport.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { or$ } from "../../../utils/observable.ts";
|
||||
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
|
||||
|
||||
export enum TransportState {
|
||||
@@ -142,18 +143,15 @@ interface Props {
|
||||
connectionManager: IConnectionManager;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
joinMatrixRTC: (
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
|
||||
) => void;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
roomId: string;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
localTransport: LocalTransport;
|
||||
localTransport$: Observable<LocalTransport>;
|
||||
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
>;
|
||||
matrixRTCSession: Pick<MatrixRTCSession, "updateCallIntent" | "leave">;
|
||||
/** Whether to hide the screen-sharing button. */
|
||||
hideScreensharing: boolean;
|
||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||
@@ -194,7 +192,7 @@ interface Props {
|
||||
export const createLocalMembership$ = ({
|
||||
scope,
|
||||
connectionManager,
|
||||
localTransport,
|
||||
localTransport$: localTransportWithErrors$,
|
||||
homeserverConnected,
|
||||
createPublisherFactory,
|
||||
joinMatrixRTC,
|
||||
@@ -252,20 +250,24 @@ export const createLocalMembership$ = ({
|
||||
const logger = parentLogger.getChild("[LocalMembership]");
|
||||
logger.debug(`Creating local membership..`);
|
||||
|
||||
// We consider error on the transport as fatal.
|
||||
// Whether it is the active transport or the preferred transport.
|
||||
const handleTransportError = (e: unknown): Observable<null> => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error ? e : new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
setTransportError(error);
|
||||
return of(null);
|
||||
};
|
||||
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||
const fatalTransportError$ = new Subject<ElementCallError>();
|
||||
const localTransport$ = localTransportWithErrors$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
let error: ElementCallError;
|
||||
if (e instanceof ElementCallError) {
|
||||
error = e;
|
||||
} else {
|
||||
error = new UnknownCallError(
|
||||
e instanceof Error
|
||||
? e
|
||||
: new Error("Unknown error from localTransport"),
|
||||
);
|
||||
}
|
||||
fatalTransportError$.next(error);
|
||||
return NEVER; // Make this Observable swallow the error
|
||||
}),
|
||||
);
|
||||
|
||||
async function checkDelegationSupport(
|
||||
endpointUrl: string,
|
||||
@@ -303,58 +305,40 @@ export const createLocalMembership$ = ({
|
||||
|
||||
// The transport that we will advertise in our membership, paired with info as
|
||||
// to whether delayed event delegation is supported
|
||||
const joinParams$ = localTransport.advertised$.pipe(
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
switchMap((transport) => {
|
||||
if (transport === null) return of(null);
|
||||
const transportSupportsDelegation = checkDelegationSupport(
|
||||
transport.livekit_service_url + "/delegate_delayed_leave",
|
||||
`transport ${transport.livekit_service_url}`,
|
||||
);
|
||||
return or$(
|
||||
from(homeserverSupportsDelegation),
|
||||
from(transportSupportsDelegation),
|
||||
).pipe(
|
||||
map((delegationSupported) => ({ transport, delegationSupported })),
|
||||
startWith(null),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||
const activeTransport$ = scope.behavior(
|
||||
combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
|
||||
map(([active, advertised]) => {
|
||||
// Our policy is to not publish to another transport if our prefered transport is miss-configured
|
||||
if (advertised == null) return null;
|
||||
|
||||
return active?.transport ?? null;
|
||||
const joinParams$ = scope.behavior(
|
||||
localTransport$.pipe(
|
||||
switchMap(async ({ transport }) => {
|
||||
const transportSupportsDelegation = checkDelegationSupport(
|
||||
transport.livekit_service_url + "/delegate_delayed_leave",
|
||||
`transport ${transport.livekit_service_url}`,
|
||||
);
|
||||
return {
|
||||
transport,
|
||||
delegationSupported:
|
||||
(await homeserverSupportsDelegation) ||
|
||||
(await transportSupportsDelegation),
|
||||
};
|
||||
}),
|
||||
catchError(handleTransportError),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
// Drop Epoch data here since we will not combine this anymore
|
||||
const localConnection$ = scope.behavior(
|
||||
combineLatest([
|
||||
connectionManager.connectionManagerData$,
|
||||
activeTransport$,
|
||||
localTransport$,
|
||||
]).pipe(
|
||||
map(([{ value: connectionData }, localTransport]) => {
|
||||
if (localTransport === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionData.getConnectionForTransport(localTransport);
|
||||
}),
|
||||
map(([{ value: connectionData }, { transport }]) =>
|
||||
connectionData.getConnectionForTransport(transport),
|
||||
),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
// Tracks error that happen when creating the local tracks.
|
||||
@@ -490,18 +474,6 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
};
|
||||
|
||||
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setTransportError = (e: ElementCallError): void => {
|
||||
if (fatalTransportError$.value !== null) {
|
||||
logger.error("Multiple Transport Errors:", e);
|
||||
} else {
|
||||
fatalTransportError$.next(e);
|
||||
}
|
||||
};
|
||||
|
||||
const localConnectionState$ = localConnection$.pipe(
|
||||
switchMap((connection) => (connection ? connection.state$ : of(null))),
|
||||
);
|
||||
@@ -509,38 +481,29 @@ export const createLocalMembership$ = ({
|
||||
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
|
||||
combineLatest([
|
||||
localConnectionState$,
|
||||
activeTransport$,
|
||||
joinAndPublishRequested$,
|
||||
from(trackStartRequested.promise).pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
localConnectionState,
|
||||
localTransport,
|
||||
shouldPublish,
|
||||
shouldStartTracks,
|
||||
]) => {
|
||||
if (!localTransport) return null;
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
map(([localConnectionState, shouldPublish, shouldStartTracks]) => {
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
},
|
||||
),
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
}),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
@@ -554,30 +517,36 @@ export const createLocalMembership$ = ({
|
||||
};
|
||||
|
||||
const localMemberState$ = scope.behavior<LocalMemberState>(
|
||||
combineLatest([
|
||||
mediaState$,
|
||||
homeserverConnected.rtsSession$,
|
||||
fatalMatrixError$,
|
||||
fatalTransportError$,
|
||||
publishError$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
mediaState,
|
||||
rtcSessionStatus,
|
||||
fatalMatrixError,
|
||||
fatalTransportError,
|
||||
publishError,
|
||||
]) => {
|
||||
if (fatalTransportError !== null) return fatalTransportError;
|
||||
// `mediaState` will be 'null' until the transport/connection appears.
|
||||
if (mediaState && rtcSessionStatus)
|
||||
return {
|
||||
matrix: fatalMatrixError ?? rtcSessionStatus,
|
||||
media: publishError ?? mediaState,
|
||||
};
|
||||
return TransportState.Waiting;
|
||||
},
|
||||
concat(
|
||||
// Waiting until…
|
||||
of(TransportState.Waiting),
|
||||
race(
|
||||
// either there is a fatal transport error
|
||||
fatalTransportError$,
|
||||
// or the transport is available.
|
||||
localTransport$.pipe(
|
||||
switchMap(() =>
|
||||
// Once available, track session/media state.
|
||||
combineLatest([
|
||||
mediaState$,
|
||||
homeserverConnected.rtsSession$,
|
||||
fatalMatrixError$,
|
||||
publishError$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
mediaState,
|
||||
rtcSessionStatus,
|
||||
fatalMatrixError,
|
||||
publishError,
|
||||
]) => ({
|
||||
matrix: fatalMatrixError ?? rtcSessionStatus,
|
||||
media: publishError ?? mediaState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -685,7 +654,7 @@ export const createLocalMembership$ = ({
|
||||
});
|
||||
});
|
||||
|
||||
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
|
||||
// Join and leave the session as needed
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
|
||||
async ([joinParams, shouldConnect]) => {
|
||||
@@ -710,7 +679,7 @@ export const createLocalMembership$ = ({
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
try {
|
||||
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
||||
await matrixRTCSession.leaveRoomSession(1000);
|
||||
await matrixRTCSession.leave(1000);
|
||||
} catch (e) {
|
||||
logger.error("Error leaving RTC session", e);
|
||||
}
|
||||
@@ -993,7 +962,7 @@ interface EnterRTCSessionOptions {
|
||||
export function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
{
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
@@ -1014,21 +983,6 @@ export function enterRTCSession(
|
||||
matrix_rtc_session: sessionConfig,
|
||||
} = Config.get();
|
||||
const retryInterval = sessionConfig.network_error_retry_ms;
|
||||
const multiSFU =
|
||||
matrixRTCMode === MatrixRTCMode.Compatibility ||
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
|
||||
|
||||
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
|
||||
// we add the livekit alias to the transport.
|
||||
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
|
||||
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
|
||||
backwardCompatibleTransport = transport;
|
||||
} else {
|
||||
backwardCompatibleTransport = {
|
||||
livekit_alias: rtcSession.room.roomId,
|
||||
...transport,
|
||||
};
|
||||
}
|
||||
|
||||
// Set maximumNetworkErrorRetryCount such that we will consider the client
|
||||
// disconnected as soon as either it fails to sync for longer than the grace
|
||||
@@ -1041,24 +995,19 @@ export function enterRTCSession(
|
||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||
|
||||
rtcSession.joinRTCSession(
|
||||
ownMembershipIdentity,
|
||||
multiSFU ? [] : [backwardCompatibleTransport],
|
||||
multiSFU ? backwardCompatibleTransport : undefined,
|
||||
{
|
||||
notificationType,
|
||||
callIntent,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs:
|
||||
delayedLeaveTimings.restart_timeout_ms,
|
||||
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
||||
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
},
|
||||
);
|
||||
rtcSession.join(ownMembershipIdentity, [transport], {
|
||||
notificationType,
|
||||
callIntent,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs:
|
||||
delayedLeaveTimings.restart_timeout_ms,
|
||||
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
|
||||
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,19 +14,10 @@ import {
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import { createLocalTransport$ } from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { mockConfig, ownMemberMock } from "../../../utils/test";
|
||||
import { getLocalTransport } from "./LocalTransport";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -47,118 +38,79 @@ describe("LocalTransport", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
roomId: "!room:example.org",
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
|
||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||
// Provide a valid config so makeTransportInternal resolves a transport
|
||||
const scope = new ObservableScope();
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const resolver = Promise.withResolvers<void>();
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
|
||||
async () => {
|
||||
await resolver.promise;
|
||||
throw new FailToGetOpenIdToken(new Error("no openid"));
|
||||
},
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
|
||||
new FailToGetOpenIdToken(new Error("no openid")),
|
||||
);
|
||||
const observations: unknown[] = [];
|
||||
const errors: Error[] = [];
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
active$.subscribe(
|
||||
(o) => observations.push(o),
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
resolver.resolve();
|
||||
await flushPromises();
|
||||
|
||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||
expect(observations).toStrictEqual([null]);
|
||||
expect(errors).toStrictEqual([expectedError]);
|
||||
expect(() => advertised$.value).toThrow(expectedError);
|
||||
expect(() => active$.value).toThrow(expectedError);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
roomId: "!example_room_id",
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
|
||||
});
|
||||
|
||||
it("emits preferred transport after OpenID resolves", async () => {
|
||||
it("returns preferred transport", async () => {
|
||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
|
||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.({
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
|
||||
url: "https://lk.example.org",
|
||||
jwt: "jwt",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||
});
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
|
||||
expect(
|
||||
await getLocalTransport({
|
||||
roomId: "!room:example.org",
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "jwt",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -168,22 +120,19 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
|
||||
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
|
||||
|
||||
describe("transport configuration mechanisms", () => {
|
||||
let localTransportOpts: LocalTransportProps & {
|
||||
client: MockedObject<LocalTransportProps["client"]>;
|
||||
};
|
||||
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
|
||||
localTransportOpts = {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope: testScope(),
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -192,10 +141,6 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
};
|
||||
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -206,19 +151,15 @@ describe("LocalTransport", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -230,12 +171,11 @@ describe("LocalTransport", () => {
|
||||
|
||||
it("supports getting transport via user settings", async () => {
|
||||
customLivekitUrl.setValue("https://lk.example.org");
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(active$.value).toStrictEqual({
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
@@ -253,19 +193,15 @@ describe("LocalTransport", () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
|
||||
openIdResponse,
|
||||
);
|
||||
|
||||
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
@@ -279,38 +215,31 @@ describe("LocalTransport", () => {
|
||||
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
openIdResolver.reject(
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
|
||||
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||
);
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
|
||||
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
|
||||
expect.any(FailToGetOpenIdToken),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no options are available", async () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,11 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
FailToGetOpenIdToken,
|
||||
@@ -27,19 +21,12 @@ import {
|
||||
type SFUConfig,
|
||||
type OpenIDClientParts,
|
||||
} from "../../../livekit/openIDSFU.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { customLivekitUrl } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
|
||||
|
||||
/*
|
||||
* It figures out “which LiveKit focus URL/alias the local user should use,”
|
||||
* and ensures the SFU path is primed before advertising that choice.
|
||||
*/
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
@@ -47,135 +34,65 @@ interface Props {
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
}
|
||||
|
||||
// TODO livekit_alias-cleanup
|
||||
// 1. We need to move away from transports map to connections!!!
|
||||
//
|
||||
// 2. We need to stop sending livekit_alias all together
|
||||
//
|
||||
//
|
||||
// 1.
|
||||
// Transports are just the jwt service adress but do not contain the information which room on this transport to use.
|
||||
// That requires slot and roomId.
|
||||
//
|
||||
// We need one connection per room on the transport.
|
||||
//
|
||||
// We need an object that contains:
|
||||
// transport
|
||||
// roomId
|
||||
// slotId
|
||||
//
|
||||
// To map to the connections. Prosposal: `ConnectionIdentifier`
|
||||
//
|
||||
// 2.
|
||||
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
|
||||
export interface LocalTransportWithSFUConfig {
|
||||
transport: LivekitTransportConfig;
|
||||
export interface LocalTransport {
|
||||
transport: LivekitTransport;
|
||||
sfuConfig: SFUConfig;
|
||||
}
|
||||
|
||||
export function isLocalTransportWithSFUConfig(
|
||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||
): obj is LocalTransportWithSFUConfig {
|
||||
export function isLocalTransport(
|
||||
obj: LivekitTransport | LocalTransport,
|
||||
): obj is LocalTransport {
|
||||
return "transport" in obj && "sfuConfig" in obj;
|
||||
}
|
||||
|
||||
export interface LocalTransport {
|
||||
/**
|
||||
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||
* yet fetched/validated.
|
||||
*/
|
||||
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||
/**
|
||||
* The transport to connect to and publish media on. `null` when not yet known
|
||||
* or available.
|
||||
*/
|
||||
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the JWT service and determines the transports that the local member should use.
|
||||
* Connects to the JWT service and determines the transport that the local member should use.
|
||||
*
|
||||
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||
* (which is expected for non sticky event based rtc member events)
|
||||
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||
* @returns The transport to advertise in our MatrixRTC membership and publish media on.
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
export const createLocalTransport$ = ({
|
||||
scope,
|
||||
memberships$,
|
||||
export async function getLocalTransport({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
matrixRTCMode,
|
||||
}: Props): LocalTransport => {
|
||||
}: Props): Promise<LocalTransport> {
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
resolvedConfig: Config.get(),
|
||||
logger: logger,
|
||||
});
|
||||
const customUrl = customLivekitUrl.value$.value;
|
||||
|
||||
// Get the preferred transport from the current deployment.
|
||||
const discoveredTransport$ = from(
|
||||
transportDiscovery.discoverPreferredTransport(),
|
||||
);
|
||||
// Respect the user's custom URL, if set
|
||||
const transport: LivekitTransport | null = customUrl
|
||||
? { type: "livekit", livekit_service_url: customUrl }
|
||||
: await discovery.discoverPreferredTransport();
|
||||
|
||||
const preferredConfig$ = customLivekitUrl.value$.pipe(
|
||||
switchMap((customUrl) => {
|
||||
if (customUrl) {
|
||||
return of({
|
||||
type: "livekit",
|
||||
livekit_service_url: customUrl,
|
||||
} as LivekitTransportConfig);
|
||||
} else {
|
||||
return discoveredTransport$;
|
||||
}
|
||||
}),
|
||||
map((config) => {
|
||||
if (!config) {
|
||||
// Bubbled up from the preferredConfig$ observable.
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
}
|
||||
return config;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
);
|
||||
if (transport === null)
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
|
||||
const preferredTransport$ = preferredConfig$.pipe(
|
||||
switchMap(async (transport) => {
|
||||
try {
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
matrixRTCMode,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
map((t) => t.transport),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
null,
|
||||
),
|
||||
active$: scope.behavior(preferredTransport$, null),
|
||||
};
|
||||
};
|
||||
try {
|
||||
return await doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
matrixRTCMode,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to ensure the user can authenticate with the SFU.
|
||||
@@ -193,13 +110,13 @@ export const createLocalTransport$ = ({
|
||||
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
||||
*/
|
||||
async function doOpenIdAndJWTFromUrl(
|
||||
transport: LivekitTransportConfig,
|
||||
transport: LivekitTransport,
|
||||
matrixRTCMode: MatrixRTCMode,
|
||||
membership: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
|
||||
logger?: Logger,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
): Promise<LocalTransport> {
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
membership,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type LivekitTransportConfig,
|
||||
type LivekitTransport,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
|
||||
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
||||
|
||||
const backendTransport: LivekitTransportConfig = {
|
||||
const backendTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://backend.example.org",
|
||||
};
|
||||
|
||||
const configTransport: LivekitTransportConfig = {
|
||||
const configTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://config.example.org",
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
isLivekitTransportConfig,
|
||||
type LivekitTransportConfig,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -40,7 +40,7 @@ export class RtcTransportAutoDiscovery {
|
||||
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
||||
}
|
||||
|
||||
public async discoverPreferredTransport(): Promise<LivekitTransportConfig | null> {
|
||||
public async discoverPreferredTransport(): Promise<LivekitTransport | null> {
|
||||
// 1) backend transports
|
||||
const backendTransport = await this.tryBackendTransports();
|
||||
if (backendTransport) {
|
||||
@@ -67,7 +67,7 @@ export class RtcTransportAutoDiscovery {
|
||||
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
||||
* @private
|
||||
*/
|
||||
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
|
||||
private async tryBackendTransports(): Promise<LivekitTransport | null> {
|
||||
const client = this.client;
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
@@ -75,7 +75,7 @@ export class RtcTransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
const first = transportList.find(isLivekitTransportConfig);
|
||||
const first = transportList.find(isLivekitTransport);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
@@ -90,7 +90,7 @@ export class RtcTransportAutoDiscovery {
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryConfigTransport(): LivekitTransportConfig | null {
|
||||
private tryConfigTransport(): LivekitTransport | null {
|
||||
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
||||
if (url) {
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { type IOpenIDToken } from "matrix-js-sdk";
|
||||
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
Connection,
|
||||
@@ -57,7 +57,7 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
|
||||
const livekitFocus: LivekitTransportConfig = {
|
||||
const livekitFocus: LivekitTransport = {
|
||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -228,9 +228,7 @@ describe("Start connection states", () => {
|
||||
capturedState = capturedStates.pop();
|
||||
if (capturedState instanceof Error) {
|
||||
expect(capturedState.message).toEqual("Something went wrong");
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||
}
|
||||
@@ -290,9 +288,7 @@ describe("Start connection states", () => {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||
}
|
||||
@@ -357,9 +353,7 @@ describe("Start connection states", () => {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
"Failed to connect to livekit",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
);
|
||||
expect(connection.transport).toEqual(livekitFocus);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
@@ -394,6 +388,34 @@ describe("Start connection states", () => {
|
||||
expect(connectedState).toEqual(ConnectionState.LivekitConnected);
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: (ConnectionState | Error)[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
// livekit-client rejects a pending connect() when disconnect() is called.
|
||||
const pendingConnect = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
|
||||
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
const stopping = connection.stop();
|
||||
pendingConnect.reject(new Error("Client initiated disconnect"));
|
||||
await stopping;
|
||||
|
||||
// start() resolves rather than rejecting (it is not awaited by the
|
||||
// ConnectionManager, so a rejection would be unhandled).
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped);
|
||||
expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
|
||||
});
|
||||
|
||||
it("shutting down the scope should stop the connection", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -373,6 +373,15 @@ export class Connection {
|
||||
// If we were stopped while connecting, don't proceed to update state.
|
||||
if (this.stopped) return;
|
||||
} catch (error) {
|
||||
if (this.stopped) {
|
||||
// stop() was called while we were connecting, which makes the pending
|
||||
// connect reject. That is the abort we asked for, not a failure, so
|
||||
// don't record an error state on a stopped connection or rethrow it
|
||||
// (start() is not awaited by the ConnectionManager, so a throw here
|
||||
// becomes an unhandled promise rejection).
|
||||
this.logger.debug(`Connect aborted because the connection was stopped`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||
this._state$.next(
|
||||
error instanceof ElementCallError
|
||||
@@ -411,9 +420,11 @@ export class Connection {
|
||||
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
if (this.stopped) return;
|
||||
// Mark as stopped before disconnecting so that a connect() aborted by the
|
||||
// disconnect sees the flag and does not report the abort as an error.
|
||||
this.stopped = true;
|
||||
await this.livekitRoom.disconnect();
|
||||
this._state$.next(ConnectionState.Stopped);
|
||||
this.stopped = true;
|
||||
this.logger.debug(
|
||||
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user