mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7c35cd8f | ||
|
|
ac81a7c0ab | ||
|
|
03f81bcd64 | ||
|
|
9f6c8bb434 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
71593f1a24 | ||
|
|
5d38a13c46 | ||
|
|
fe91162869 | ||
|
|
74328d7e42 | ||
|
|
6046337877 | ||
|
|
16b2c31ebb | ||
|
|
a58a5f615f | ||
|
|
2cac2d7e8b | ||
|
|
1695d3f518 | ||
|
|
96a2e9b481 | ||
|
|
23ba8f3204 | ||
|
|
b284bb00c0 | ||
|
|
cb0ae89143 | ||
|
|
344aac21f9 | ||
|
|
21d7f24f61 | ||
|
|
dbd2423e7b | ||
|
|
426acd222e | ||
|
|
2e5b5cb186 | ||
|
|
8060d10fcc |
@@ -10,6 +10,9 @@ public/config.json
|
||||
backend/synapse_tmp/*
|
||||
backend/synapse_tmp_othersite/*
|
||||
/coverage
|
||||
|
||||
# Transient agent scratch files
|
||||
/agent-workspace/
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md — Element Call
|
||||
|
||||
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
|
||||
Element Web and Element X, as embedded packages, and as a React component in a
|
||||
host's page. It is the MatrixRTC reference implementation.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Features and UI changes need a pre-approved issue. No issue, no review.
|
||||
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
|
||||
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
|
||||
is user-facing. All three.
|
||||
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
|
||||
name what you rejected — never add a new one silently.
|
||||
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
| … | … |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
|
||||
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
|
||||
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
|
||||
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
|
||||
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
|
||||
|
||||
## Gates
|
||||
|
||||
```sh
|
||||
pnpm lint # tsc, oxlint, knip, component externals
|
||||
pnpm format # oxfmt
|
||||
pnpm test # vitest: unit and storybook projects
|
||||
pnpm i18n:check
|
||||
```
|
||||
|
||||
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
|
||||
`knip.ts` `ignoreFiles` with a reason.
|
||||
|
||||
## A good PR
|
||||
|
||||
- One slice, under ~400 changed lines, green on its own, linked to its issue.
|
||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
||||
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
||||
- 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.
|
||||
@@ -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
|
||||
|
||||
+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:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
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:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
|
||||
@@ -9,3 +9,12 @@ This folder contains documentation for setup, usage, and development of Element
|
||||
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
||||
- [Self-Hosting](./self_hosting.md)
|
||||
- [Developing with linked packages](./linking.md)
|
||||
|
||||
### Writing code for Element Call (agent-readable conventions)
|
||||
|
||||
Entry point: [AGENTS.md](../AGENTS.md).
|
||||
|
||||
- [Agent workflow](./agents/workflow.md)
|
||||
- [Architecture](./agents/architecture.md)
|
||||
- [Code style](./agents/code-style.md)
|
||||
- [Testing](./agents/testing.md)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Architecture
|
||||
|
||||
## Logic lives in view models; components render
|
||||
|
||||
- Call logic lives in `src/state/`. A component that derives call state is
|
||||
misplaced logic.
|
||||
- Composing behaviors: a factory taking `(scope, ...deps$)`, returning named
|
||||
behaviors plus callbacks. Copy `src/state/LayoutSwitchViewModel.ts`. Prefer this
|
||||
for anything new.
|
||||
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
|
||||
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
|
||||
don't convert one to the other in passing.
|
||||
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
|
||||
the default for anything a view reads.
|
||||
|
||||
## The view model / view contract
|
||||
|
||||
- Components take `vm: ViewModel<Snapshot>` and read state through `useBehavior`.
|
||||
See `CallFooter`, `InCallView`, `LobbyView`, `SettingsModal`.
|
||||
- A snapshot is `Actions & State`; every field becomes a `foo$` behavior, and none
|
||||
is optional.
|
||||
- An unavailable action is `undefined`, not a separate `canDoThing` flag — the
|
||||
presence of the callback drives the rendering.
|
||||
- Subscribe in an effect only to drive a side effect off an event stream, as
|
||||
`ReactionAudioRenderer` does; never to read state a behavior already holds.
|
||||
|
||||
## Scopes own lifetimes
|
||||
|
||||
- `ObservableScope` bounds every subscription a view model creates.
|
||||
- Reference only the scope defined in the same function; one captured from an
|
||||
enclosing scope outlives its owner, and `no-observablescope-leak` rejects it.
|
||||
|
||||
## Nothing reads the page
|
||||
|
||||
Element Call can be mounted several times inside a host's React tree, so it owns
|
||||
neither window, URL, document nor router. Each seam defaults to the old standalone
|
||||
and widget behaviour.
|
||||
|
||||
| Never | Use |
|
||||
| --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| the `widget` global | `useHostBridge()` — `src/HostBridge.ts` |
|
||||
| `getUrlParams()`, `window.location` | `useUrlParams()` |
|
||||
| `useNavigate("/")`, `<Link to="/">` | `useLeaveToHome()`, `LeaveToHomeLink` |
|
||||
| `document.body` | `useRootElement()` |
|
||||
| `window.innerWidth/Height`, `useMediaQuery` | `useRootSizeMatches()` in views, `windowSize$` in view models |
|
||||
| global `i18next` | the instance in `src/utils/i18n.ts` |
|
||||
| config or analytics reading their environment | `Config.initWith()`, `PosthogAnalytics.configure()` |
|
||||
|
||||
- View models take values as options, never `getUrlParams()`
|
||||
(`callViewModelOptionsFromParams`, `CallViewModelOptions.hostBridge`).
|
||||
- The host bridge is the only channel to the host: `createWidgetHostBridge(widget)`,
|
||||
`nullHostBridge` standalone, `useComponentHostBridge`. State a capability
|
||||
(`supportsReactions`, `supportsProfileChanges`); never infer it from being a
|
||||
widget.
|
||||
- Shortcuts and portals attach to the root element, so two instances don't fight.
|
||||
- Known debt, not precedent: `Grid` measures `window.innerHeight`; `ErrorView`'s
|
||||
reload and `getAbsoluteRoomUrl` use `window.location`; recaptcha appends to
|
||||
`document.body` on the standalone login path.
|
||||
|
||||
## Context differences are options, not checks
|
||||
|
||||
- Named options with per-intent defaults (`configurationForIntent`), never a
|
||||
runtime check for who is hosting.
|
||||
- `controlledAudioDevices`, not the platform, is what makes `MediaDevices` pick
|
||||
`AndroidControlledAudioOutput` / `IOSControlledAudioOutput` over the web
|
||||
`AudioOutput`. Intent presets set it on non-desktop; standalone leaves it off.
|
||||
- URL params are a published contract: change one, update `docs/url_params.md`.
|
||||
|
||||
## Build targets
|
||||
|
||||
Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Code style
|
||||
|
||||
_Clean Code_ (Robert C. Martin): small functions, intention-revealing names, low
|
||||
noise.
|
||||
|
||||
## Reuse before you build
|
||||
|
||||
Look before you write, in this order:
|
||||
|
||||
1. **Compound Web** — `@vector-im/compound-web` for buttons, tooltips, alerts,
|
||||
dialogs, menus, form controls, avatars; icons in
|
||||
`@vector-im/compound-design-tokens/assets/web/icons`.
|
||||
2. **This repo** — `src/button/`, `src/components/`, `src/input/`, `src/form/`,
|
||||
`src/tabs/`, and `Modal`, `Avatar`, `Toast`, `Slider`, `ErrorView`,
|
||||
`FullScreenView` at the root of `src/`.
|
||||
3. **The nearest existing feature** — grep for the same shape first. A hook is
|
||||
often what you want to extract, not a component.
|
||||
|
||||
If nothing fits, name the new shared component in the PR body and say what you
|
||||
rejected and why — never add one silently. Extend or parameterise before forking,
|
||||
and extract shared logic into a hook both callers use before copying it; if you
|
||||
copy, the PR says so.
|
||||
|
||||
## Ordering
|
||||
|
||||
- **Newspaper order.** Headline first, detail down. A file opens with what it
|
||||
exists to provide.
|
||||
- **Stepdown rule.** Caller above callee. A helper used by one function sits
|
||||
beneath it, which needs a `function` declaration, not a `const` arrow. Existing
|
||||
arrow components above their callers are not a pattern to extend — nor to churn.
|
||||
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
||||
rule in new ones, don't reorder old ones.
|
||||
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
||||
`computedLayout$`. Comments explain why, never what.
|
||||
|
||||
## TypeScript, React and RxJS idiom win on a clash
|
||||
|
||||
- Hooks stay unconditional at the top of a component.
|
||||
- `useCallback` / `useMemo` dependency arrays sometimes force inlining.
|
||||
- An RxJS pipeline stays one expression. Name the behavior, not each operator.
|
||||
- A long factory is fine when it reads as a list of named behaviors.
|
||||
`CallViewModel.ts` is long because the domain is.
|
||||
- Marble test tables are dense on purpose.
|
||||
|
||||
## Enforced by lint
|
||||
|
||||
- Copyright header on every file: `Copyright <current year> Element Creations Ltd.`
|
||||
plus the AGPL / commercial SPDX line.
|
||||
- `logger` from `matrix-js-sdk/lib/logger`, never `console`, and no top-level
|
||||
`logger.getChild()`. The `console` ban is only enforced under `src/*/**`.
|
||||
- No floating or misused promises; async functions typed `Promise<T>`.
|
||||
- Inline type imports (`import { type Foo }`), so matrix-js-sdk stays lazily
|
||||
loadable.
|
||||
- Deep-import `matrix-js-sdk/lib/<module>` as the codebase does. Banned is the bare
|
||||
`matrix-js-sdk/lib`, `lib/index` and anything under `src/`.
|
||||
|
||||
## CSS
|
||||
|
||||
- Compound components and `--cpd-*` tokens in CSS modules. A hardcoded colour or px
|
||||
spacing is a design question, not a licence to inline a hex.
|
||||
- Size against the root, not the window: `@container element-call (…)` and
|
||||
`cqw` / `cqh`, never `@media (width)` or `vw` / `vh`. Media queries stay correct
|
||||
only in standalone-only views — home, login.
|
||||
- Style `[data-element-call-root]`, never `body` or `:root`; the component build
|
||||
makes those stand for the root (`component/build/scopeStylesToRoot.ts`).
|
||||
|
||||
## Strings and a11y
|
||||
|
||||
- Strings through `t()`: add the key, run `pnpm i18n`, fill `locales/en/app.json`.
|
||||
Other locales come from Localazy; never hand-edit them.
|
||||
- `t` from `useTranslation()`, or `src/utils/i18n.ts` outside React. Never the
|
||||
`i18next` global — several instances share a page.
|
||||
- Accessible names on controls, `aria-pressed` on toggles, keyboard reachability.
|
||||
`jsx-a11y` rules are errors; `vitest-axe` is available.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Testing
|
||||
|
||||
Three layers; a user-facing feature needs all three. Unit proves the logic,
|
||||
Storybook proves the rendering, e2e proves the feature. Codecov gates 80% on the
|
||||
lines a PR touches.
|
||||
|
||||
## Unit
|
||||
|
||||
- View model tests use `withTestScheduler` + `testScope()` from `src/utils/test.ts`
|
||||
and read as ASCII timelines. Copy `LayoutSwitchViewModel.test.ts`.
|
||||
- Component tests use Testing Library, queried by role and accessible name.
|
||||
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
|
||||
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
|
||||
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
|
||||
- How often something redraws is testable: drive the frames and count commits, not
|
||||
render calls. An effect with no dependency array runs once per commit.
|
||||
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
|
||||
|
||||
## Storybook
|
||||
|
||||
A deliverable, not documentation: `pnpm test:storybook` runs every story as a real
|
||||
browser test in the same CI job as the unit suite. **A UI change without a story is
|
||||
incomplete.** Only three stories exist so far, so copy `CallFooter.stories.tsx`.
|
||||
|
||||
- Drive the component from a snapshot via `useStaticViewModel`, never internals.
|
||||
- One named story per state that matters — loading, error, empty, denied, mobile.
|
||||
- Assert in a `play` function with `userEvent` / `expect` from `storybook/test`.
|
||||
- Mobile is `globals: { viewport: { value: "mobile2" } }`, not a second component.
|
||||
- Expose interesting props through `argTypes`.
|
||||
|
||||
`.storybook/preview.tsx` supplies `TooltipProvider`, `src/index.css` and English
|
||||
translations. The browser is Playwright's, so a fresh clone needs
|
||||
`pnpm playwright install` once.
|
||||
|
||||
## End-to-end
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
3001 that Playwright starts as a second web server. Catches container-relative
|
||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||
|
||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||
right after a reload. Reuse `playwright/spa-helpers.ts`,
|
||||
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
|
||||
|
||||
```sh
|
||||
pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
pnpm test:playwright # or :open
|
||||
pnpm dev:component # component harness, port 3001
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent workflow
|
||||
|
||||
## Scratch files live in `agent-workspace/`
|
||||
|
||||
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
`agent-workspace/<slug>/`.
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
|
||||
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
|
||||
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
|
||||
| `implementation-summary.md` | What was built, decisions, trade-offs |
|
||||
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
|
||||
|
||||
- Nothing here is durable. The folder is ignored, so anything worth keeping is
|
||||
promoted into the PR body, a doc, or a feature spec before the task ends.
|
||||
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
|
||||
`config.json_` files are what this folder prevents.
|
||||
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
|
||||
section.
|
||||
- Commit subjects are plain imperative English. No conventional-commits prefixes.
|
||||
- A `plan.md` and a feature spec sit at different altitudes; neither replaces the
|
||||
other. A spec in `FEATURES_SPEC/` is durable and deliberately abstract —
|
||||
behaviour, decisions, acceptance criteria — so the feature can be rebuilt against
|
||||
a `main` nobody has seen yet. A plan is one slice of it landing on today's
|
||||
`main`: the paths, symbols and edit order the spec's decisions and criteria must
|
||||
not name. `FEATURES_SPEC/AGENTS.md` wins wherever the two genuinely overlap.
|
||||
|
||||
## Hand off before the quality pass
|
||||
|
||||
- Implement the change, run the narrowest check that rules out an obviously broken
|
||||
handoff, then stop and ask whether the direction is right.
|
||||
- Full `pnpm lint`, the whole suite, coverage and benchmarks come after the
|
||||
direction is confirmed.
|
||||
- Wider refactors, extra tests and documentation polish are follow-up work, not
|
||||
part of the first handoff.
|
||||
- If a check is needed before feedback, keep it to the touched code and say why.
|
||||
|
||||
## Commit and PR readiness
|
||||
|
||||
- Commit once the user confirms direction, or asks for one. Not before. Where the
|
||||
setup has no git identity, a sandbox included, write the message to
|
||||
`commit-msg.txt` and hand it over instead.
|
||||
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
|
||||
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
|
||||
against [code-style.md](./code-style.md) first.
|
||||
- Re-run the whole checklist after any fix. Only on green, either way.
|
||||
- Once a human has started reviewing, fix forward — never force-push a
|
||||
regeneration over a review in progress.
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
+22
-22
@@ -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.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@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
|
||||
@@ -3229,8 +3229,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': '*'
|
||||
@@ -4605,8 +4605,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
|
||||
|
||||
@@ -7079,21 +7079,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,11 +7105,11 @@ 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': {}
|
||||
|
||||
@@ -8695,12 +8695,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.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
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 +8712,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)
|
||||
@@ -10180,7 +10180,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
|
||||
|
||||
@@ -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,6 +19,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
|
||||
@@ -65,6 +66,34 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates your video tracks to always use the given processor.
|
||||
*/
|
||||
@@ -78,13 +107,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 +117,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]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -563,8 +563,14 @@ 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 =
|
||||
|
||||
@@ -394,6 +394,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();
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
// Create a virtual default audio output for browsers that don't have one
|
||||
// (Firefox, Safari). Its device ID must be the empty string because
|
||||
// that's what setSinkId recognizes. It goes first so that it is the
|
||||
// fallback when no output has been explicitly chosen (or the chosen
|
||||
// one disappears), rather than pinning the first physical device
|
||||
// with setSinkId: pinned sinks are not re-routed by the browser, and
|
||||
// Firefox leaves the audio elements silent when a pinned sink goes
|
||||
// away (e.g. a Bluetooth headset switching profile when its
|
||||
// microphone is opened). We can't know which physical device the
|
||||
// browser default resolves to, so the entry carries no name.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
available = new Map<string, AudioOutputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
|
||||
Reference in New Issue
Block a user