mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Merge pull request #4319 from element-hq/toger5/use-sdk
[MatrixRTCSdk] use the matrixRtcSdk
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
# Merging PR #4331 onto the SDK / Element Call split
|
||||
|
||||
How [element-hq/element-call#4331](https://github.com/element-hq/element-call/pull/4331)
|
||||
("LiveKit updates": MSC4195 client-server API tokens, `url` transports, delegation
|
||||
through the homeserver, a transport dev override) lands on `toger5/use-sdk`, where
|
||||
the MatrixRTC logic lives in `sdk/` and Element Call consumes it through
|
||||
`sdk/src/api.ts`. The PR was written against `main`, where all of this still sat
|
||||
in `src/state/CallViewModel/`. This document goes through the PR commit by commit,
|
||||
says whether each one lands in `sdk/` or `src/`, and how; the section
|
||||
[Changes to `api.ts`](#changes-to-apits) covers the public interface in full. The
|
||||
end state: this branch behaves exactly as the PR does, with the split and the
|
||||
`api.ts` seam intact.
|
||||
|
||||
The PR's branch is checked out at `agent-workspace/pr4331-merge/worktree` for
|
||||
comparison. The `matrix-js-sdk` dependency is pinned to the same `develop`
|
||||
commit the PR uses (`4df2f2bf`, 43.0.0), which brings `UnstableLivekitTransport`,
|
||||
`CallMembership.backendIdentities`, `_unstable_getLivekitToken` and
|
||||
`_unstable_delegateDelayedLeave`. That bump also renamed
|
||||
`MatrixRTCSession.leaveRoomSession` to `leave`, which the SDK now calls.
|
||||
|
||||
## Where each commit lands
|
||||
|
||||
| # | Commit | Lands in | How |
|
||||
| --- | ------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Remove stray pause and improve logging for hung e2e tests | e2e | Applied as is: `playwright.config.ts`, `playwright/widget/federated-call.test.ts`. |
|
||||
| 2 | Fix typo in the name of the createJWTToken Playwright fixture | e2e | Applied as is. |
|
||||
| 3 | Advertise the loopback address from the dev LiveKit SFUs | backend | Applied as is: `backend/dev_livekit*.yaml`. |
|
||||
| 4 | Match LiveKit participants against all backend identities | sdk | `RTCMember.backendIdentities` (new, see api.ts). `LivekitBackend.mediaFor` matches `member.backendIdentities.includes(p.identity)`; `RTCParticipation.data$` maps a packet's sender the same way. `mockRtcMembership` in both `sdk/src/utils/test.ts` and `src/utils/test.ts` takes the new 4-argument constructor and a `backendIdentities` override. |
|
||||
| 5 | Model ConnectionState as a discriminated union | sdk | `Connection.ts` state is `{state: "initialized" \| "authenticating" \| "authenticated" (+ `livekitState`) \| "error" (+ `error`) \| "stopped"}`. Internal to the LiveKit backend: `connectionState.ts` maps it onto the backend-neutral `MediaConnectionState`, so nothing above the backend changes. `DataChannel.ts` and `ConnectionFactory.ts` follow. |
|
||||
| 6 | Show the whole transport in debug UI and logs | sdk + src | SDK log lines use `JSON.stringify(transport)`. Element Call: `focusUrl$: Behavior<string>` on the member media view models becomes `transport$: Behavior<Transport>` (`TransportMetadata.raw`), carried through `GridTile`, `SpotlightTile`, `MediaView` to `RTCConnectionStats`, which prints the JSON. The old code showed `extractDomain(transport.id)`, which on this branch was already a JSON string, so this also fixes a latent bug. |
|
||||
| 7 | Group livekitRoomItems$ by connection | n/a | `livekitRoomItems$` and `LivekitRoomAudioRenderer` do not exist on this branch: audio renders from `tracks$` (`MemberAudioRenderer`). Nothing to port. |
|
||||
| 8 | Move SFU authentication into livekit/auth and choose the endpoint by role | sdk | `sdk/src/media-backend/livekit/auth/{index,openID,csApi,types}.ts` replace `openIDSFU.ts`. Publisher → legacy `/sfu/get`; subscriber → `/get_token`. `slotId` replaces the hard-coded `m.call#ROOM`: `MediaBackendContext.slotId`, taken from the js-sdk session in `RTCParticipation`. The `tokenEndpoint` option of `createLivekitBackend` and the derivation from `MatrixRTCMode` are gone. |
|
||||
| 9 | Let the publishing Connection authenticate itself | sdk | Every `Connection` has a `role` and fetches its own token; `existingSFUConfig` is gone. `prepareLocalTransport` no longer fetches a token itself: it starts the publisher connection (through the connection map) and resolves once that connection reports `authenticated`, rejecting with its error otherwise. Since `LocalMembership` joins only after `preparedTransport$` resolves, the join waits for the publisher's token exactly as the PR's `authenticated$` gate does. |
|
||||
| 10 | Reduce ConnectionManager to a ConnectionMap | sdk | `ConnectionMap.ts` replaces `ConnectionManager.ts`; `createConnectionMap$` takes a resolved local transport, and `LivekitBackend` wraps it in `mapScoped` over the local transport, as the PR's `CallViewModel` did. `LocalMedia.ts`, `DataChannel.ts` take `connectionMap$`. |
|
||||
| 11 | Use the unstable LiveKit transport type | sdk + src | `UnstableLivekitTransport` / `isUnstableLivekitTransport` throughout the backend; `src/settings/settings.ts`, `DeveloperSettingsTab`, test helpers. Transport discovery in `sdk/src/matrixrtc/` is now backend neutral (filters by `type`, see commit 15). |
|
||||
| 12 | Identify transports by server name as well as URLs | sdk | `TransportLocator {transport, serverName}` in `auth/types.ts`; `ConnectionMap.keyFor` keys on `(url, livekit_service_url, serverName)`. The server name is derived from the user id (`serverNameOf`) for remote members and from our own membership identity for the local transport, so neither `api.ts` nor the backend options need it. |
|
||||
| 13 | Pin matrix-js-sdk to the member_id branch | superseded | By commit 19; the lockfile pins the PR's final `develop` commit. |
|
||||
| 14 | Get SFU tokens and delegate delayed leaves through the MSC4195 CS API | sdk | `auth/csApi.ts` (`_unstable_getLivekitToken`, `_unstable_delegateDelayedLeave`), `auth/index.ts` facade with the fallback rules. `LivekitBackend.delegateDelayedLeave` goes through the facade; the delegation probe asks only the service the publisher flow uses. `LivekitBackendOptions.client` picks the two new client methods. |
|
||||
| 15 | Let developers override the whole transport | sdk + src | `RTCParticipationOptions.transport` / `fallbackTransport` take a `Transport` instead of `transportUrl` / `fallbackTransportUrl` (see api.ts). Element Call: setting `custom-transport` (JSON) replaces `custom-livekit-url`; `DeveloperSettingsTab` edits JSON and validates through `authenticateWithTransport`; `InCallView` turns `config.livekit.livekit_service_url` into the fallback transport. Locale keys `developer_mode.custom_transport.*`. |
|
||||
| 16 | Deprecate the livekit.livekit_service_url config option | src | Applied as is in `ConfigOptions.ts`. |
|
||||
| 17 | Cover url transports end to end | e2e + backend | `playwright/url-transport.spec.ts`, `backend/app-service*.yaml`, `docker-compose-dev.yml` (lk-jwt-service 0.8.0) copied as is. |
|
||||
| 18 | Retain compatibility in compatibility mode | superseded | Reverted by commit 23 inside the PR; only its e2e spec edits survive, in the final spec file. |
|
||||
| 19 | Switch JS SDK back to develop | deps | `pnpm-lock.yaml` pins `4df2f2bf` and `matrix-widget-api` 1.20.0; `package.json` keeps `#develop`. |
|
||||
| 20 | Fall back to get_token as subscriber on widget API errors | sdk | `isCsApiUnavailable` in `auth/index.ts`; `doNetworkOperationWithRetry(op, shouldRetry)` in `sdk/src/utils/network.ts` with `isNetworkError` in `csApi.ts`. |
|
||||
| 21 | Share a connection between homeservers using the same JWT service | sdk | `keyFor` ignores the server name for transports without a `url`; `removeDuplicateTransports` keeps the first locator so the local one wins. |
|
||||
| 22 | Avoid null in transport dev setting | src | `transportText()` in `DeveloperSettingsTab.tsx`. |
|
||||
| 23 | Make the auth choice mode independent again | sdk | `chooseAuthFlow(transport, role)` has no mode parameter; `config.ts` documents that `MatrixRTCMode` decides how the membership is written, not how the transport is authenticated. |
|
||||
| 24 | Cover identity matching and delegation across transport shapes | sdk + e2e | Unit tests in `LivekitBackend.test.ts` (see below); `playwright/url-transport.spec.ts` final version. |
|
||||
| 25 | Cover the subscriber token path, widget fallback and server names | sdk + e2e | `auth/openID.test.ts` subscriber cases; `playwright/widget/url-transport.test.ts`; the server-name test lives in `LivekitBackend.test.ts` ("connects to a remote member's transport on the member's homeserver"). |
|
||||
|
||||
## Changes to `api.ts`
|
||||
|
||||
Three changes to the public SDK interface, all forced by the PR's behaviour.
|
||||
|
||||
### `RTCMember.backendIdentities` (new)
|
||||
|
||||
```ts
|
||||
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* Every identity the member may publish under on its transport: the
|
||||
* participant identity the SFU sees, and the sender of its data packets.
|
||||
* One per authentication flow the member's transport allows, so a transport
|
||||
* with both a `url` and a `livekit_service_url` lists the hashed and the
|
||||
* legacy identity, and the backend matches whichever shows up.
|
||||
*/
|
||||
backendIdentities: readonly string[];
|
||||
```
|
||||
|
||||
Commit 4 of the PR. A member on a transport that carries both a `url` and a
|
||||
`livekit_service_url` may have authenticated through either flow and so has
|
||||
two candidate identities; the js-sdk now lists them on the membership. The
|
||||
backend matches media and data packets against the list.
|
||||
`rtcBackendIdentity` stays as the stable per-membership key Element Call uses
|
||||
for tile ids and the debug overlay, and its doc says that it is no longer
|
||||
what the backend matches on. Read off the membership, like the single identity
|
||||
before it; the backend will derive both once the identity scheme is its own.
|
||||
|
||||
### `RTCParticipationOptions.transport` / `fallbackTransport` replace the url options
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Use this transport instead of asking the homeserver. Any shape the
|
||||
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
|
||||
*/
|
||||
transport?: Transport;
|
||||
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||
fallbackTransport?: Transport;
|
||||
```
|
||||
|
||||
Commit 15 of the PR. The developer override has to carry a whole transport so
|
||||
that a `url`-only or combined shape can be tested against a homeserver that
|
||||
does not advertise one yet; a URL cannot say which shape it is. This also
|
||||
removes the one piece of LiveKit knowledge that sat outside the backend:
|
||||
`LocalTransport.ts` and `TransportAutoDiscovery.ts` no longer build a
|
||||
`{type: "livekit", livekit_service_url}` object and filter by `transportType`
|
||||
only, which `LocalTransport.ts` had marked as the moment to do this. The
|
||||
host turns its config URL into a transport (`InCallView.rtcParticipationOptions`).
|
||||
|
||||
### `TransportMetadata` is unchanged, with one documented limit
|
||||
|
||||
`TransportMetadata.id` is still the raw transport serialised with sorted keys.
|
||||
The LiveKit backend now keys its connections on the transport plus the server
|
||||
name where the transport has a `url` (commits 12 and 21), so two homeservers
|
||||
advertising the same `url` get two connections but one `TransportMetadata`.
|
||||
`connectedTransports$` deduplicates, and `resolved$` reports the first
|
||||
connection's resolution. This is diagnostics only; the media and data paths
|
||||
go through the backend's own keys. Extending `TransportMetadata` with the
|
||||
server name was left out to keep the type transport neutral; a developer panel
|
||||
that needs it can be given a `serverName` on `BackendConnection` later.
|
||||
|
||||
### Related, outside `api.ts`
|
||||
|
||||
- `media-backend/api.ts`: `MediaBackendContext.slotId` (commit 8). The
|
||||
client-server API issues a token for a slot, so the backend needs it;
|
||||
`RTCParticipation` reads it from the js-sdk session.
|
||||
- `sdk/index.ts`: `authenticateWithTransport` is now the auth facade's
|
||||
`getSFUConfig({client, membership, transport, serverName, roomId, slotId, role, logger})`
|
||||
(object parameter, whole transport, server name); `AuthenticateWithTransportParams`
|
||||
is exported for it; `TokenEndpoint` is gone.
|
||||
- `createLivekitBackend` options: `tokenEndpoint` is gone; `client` now picks
|
||||
`_unstable_getLivekitToken` and `_unstable_delegateDelayedLeave` as well.
|
||||
- `config.ts`: the `MatrixRTCMode` doc no longer claims the mode picks the
|
||||
JWT endpoint (commit 23).
|
||||
|
||||
## Behaviour, side by side
|
||||
|
||||
| Behaviour | PR on `main` | This branch |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Endpoint choice | `chooseAuthFlow` in `src/livekit/auth` | Same function, `sdk/src/media-backend/livekit/auth/index.ts` |
|
||||
| Join waits for the publisher's token | `authenticated$` gate in `LocalMember.ts` | `prepareLocalTransport` resolves on the publisher connection's `authenticated` state; `LocalMembership` joins after it |
|
||||
| Delegation probe | The flow's service only, in `LocalMember.joinParams$` | The flow's service only, in `prepareLocalTransport` |
|
||||
| Delegation handover | `delegateDelayedLeave` facade from `LocalMember` | Same facade from `LivekitBackend.delegateDelayedLeave`, with the delay timeout from the session timings instead of `Config` |
|
||||
| Transport keys and connection sharing | `ConnectionMap.keyFor` on `TransportLocator` | Same, moved |
|
||||
| Server names | `membershipsAndTransports$` from the user id; `getLocalTransport` from `client.getDomain()` | `serverNameOf(userId)` for members; our own membership identity's user id for the local transport |
|
||||
| Identity matching | `membership.backendIdentities.includes(p.identity)` | `member.backendIdentities.includes(p.identity)` in `mediaFor` and for data packets |
|
||||
| Dev transport override | `customTransport` setting → `getLocalTransport` | `customTransport` setting → `RTCParticipationOptions.transport` |
|
||||
| Config fallback | `RtcTransportAutoDiscovery.tryConfigTransport` | Host builds the transport, `RTCParticipationOptions.fallbackTransport` |
|
||||
| Error for a `url` host without MSC4195 | `NoMatrix2AuthorizationService` from the publisher connection | Same error, surfaced through `prepareLocalTransport` → `state$ failed` → `fromMatrixRTCError` |
|
||||
|
||||
One deliberate difference: on `main` the publisher connection fetches its token
|
||||
on every (re)creation and nothing else does; here it is the same, since the
|
||||
up-front token fetch in `prepareLocalTransport` is gone. There is no second
|
||||
token request per join.
|
||||
|
||||
## Where the PR's new tests went
|
||||
|
||||
| PR test | Here |
|
||||
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/livekit/auth/index.test.ts` | `sdk/src/media-backend/livekit/auth/index.test.ts`. Rewritten on the network layer (fetch-mock for the JWT service, mocked client methods) because a `vi.mock` of the sibling `openID` module does not reach the facade's own import under `sdk/`. Same cases, plus a table for `chooseAuthFlow`. |
|
||||
| `src/livekit/auth/openID.test.ts` | `sdk/src/media-backend/livekit/auth/openID.test.ts`, replacing `openIDSFU.test.ts` |
|
||||
| `ConnectionMap.test.ts` (incl. commit 21's sharing tests) | `sdk/src/media-backend/livekit/ConnectionMap.test.ts`, replacing `ConnectionManager.test.ts`; plus a role-tagging test |
|
||||
| `Connection.test.ts` changes | `sdk/src/media-backend/livekit/Connection.test.ts`; plus a publisher-through-the-JWT-service case |
|
||||
| `LocalMember.test.ts` "joins session with %s delegation support" (by transport shape) | `LivekitBackend.test.ts` "prepares the local transport and asks %s about delegation", "waits for the publisher connection to hold its token before the join", "fails the preparation with the publisher connection's error", "delegates the leave through the flow that authenticated the publisher" |
|
||||
| `MatrixLivekitMembers.test.ts` two-candidate-identity tests | `LivekitBackend.test.ts` › `mediaFor` |
|
||||
| `SessionBehaviors.test.ts` server-name test | `LivekitBackend.test.ts` "connects to a remote member's transport on the member's homeserver" |
|
||||
| `LocalTransport.test.ts` / `RtcTransportAutoDiscovery.test.ts` changes | `sdk/src/matrixrtc/TransportAutoDiscovery.test.ts` (fallback is a transport) |
|
||||
| `DeveloperSettingsTab.test.tsx` custom transport cases | `src/settings/DeveloperSettingsTab.test.tsx`, snapshot updated for the label |
|
||||
| `DataChannel`, `LivekitBackend` registry tests | Adapted to the state union and `TransportLocator` |
|
||||
| `playwright/url-transport.spec.ts`, `playwright/widget/url-transport.test.ts` | Copied as is: they drive the app, which behaves the same |
|
||||
|
||||
The PR's `CallViewModel.test.ts`, `CallViewModelTestUtils.ts`, `LocalMember.test.ts`
|
||||
(the rest) and `MatrixAudioRenderer.test.tsx` edits have no counterpart: those
|
||||
modules were replaced by the SDK before this merge.
|
||||
|
||||
## One fix the e2e run forced
|
||||
|
||||
The bumped js-sdk sets `rtcBackendIdentity` to the legacy `${userId}:${deviceId}`
|
||||
identity for every membership (it is deprecated there; the hashed one now only
|
||||
appears in `backendIdentities`). On this branch the call view model keyed its
|
||||
tiles by `[dup, mediaId, rtcBackendIdentity]`, so a device that rejoined after an
|
||||
improper leave, which has two memberships until the stale one expires, produced
|
||||
duplicate keys and crashed the host's page with `UNKNOWN_ERROR`
|
||||
(`spa-call-sticky.spec.ts`, "rejoin after improper leave"). The key is now the
|
||||
membership's own `memberId`, and `MemberAudioRenderer` and the SDK harness key
|
||||
by it too. The PR on `main` keyed by the participant object as well and did not
|
||||
hit this.
|
||||
|
||||
## Coverage added on the package branch, and one bug it found
|
||||
|
||||
The PR's tests mostly covered the auth layer; the SDK modules under them had
|
||||
little or none. Added on the package branch, which this branch is rebased onto:
|
||||
|
||||
| Module | Test |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `media-backend/livekit/Publisher.ts` | `Publisher.test.ts`: the cases from `main`'s `localMember/Publisher.test.ts` on a real `LocalParticipant` with faked device and signalling calls, plus publish, unpublish and destroy |
|
||||
| `media-backend/livekit/LocalMedia.ts` | `LocalMedia.test.ts`: on a fake connection map; publish before and after the connection, republish on a new connection, mutes remembered, first publish error, output device |
|
||||
| `RTCParticipation.ts` | `RTCParticipation.test.ts`: on a fake media backend; join options, state machine, publishing only while joined, delegation, member ids per mode, key replay, data sender check |
|
||||
| `matrixrtc/LocalTransport.ts` | `LocalTransport.test.ts`: override, homeserver, fallback, none |
|
||||
| `matrixrtc/joinJsSdkSession.ts` | `joinJsSdkSession.test.ts`: the js-sdk join options, the retry budget, the intent and sticky events |
|
||||
| `media-backend/livekit/publishOptions.ts` | `publishOptions.test.ts` |
|
||||
| `media-backend/livekit/ConnectionFactory.ts` | `ConnectionFactory.test.ts`: the room options from the requests and media quality (the cases from `main`'s `ECConnectionFactory.test.ts`), `resolved$` |
|
||||
| `LivekitBackend.ts` › `mediaFor` | A member on another transport, a member changing transport, a member without one (from `main`'s `MatrixLivekitMembers.test.ts`) |
|
||||
|
||||
`ConnectionFactory.test.ts` reads the options off the real LiveKit room the
|
||||
factory builds instead of mocking `livekit-client`: a `vi.mock` of the package
|
||||
reached the factory's import on the package branch but not on this one.
|
||||
|
||||
The `LocalMedia` tests found a race in `ObservableScope.reconcile`: the loop was
|
||||
marked as running until the promise of its last run had settled, a few
|
||||
microtasks after it had last checked for new values, so a value arriving in
|
||||
that window (a connection appearing at just that moment) was never processed.
|
||||
Fixed in `[SDK] Keep a value that reaches reconcile as its loop winds down`,
|
||||
with a test that drives the window microtask by microtask. Element Call's
|
||||
`src/state/ObservableScope.ts` re-exports the SDK's class, so the fix applies
|
||||
to the app as well.
|
||||
|
||||
## Checked
|
||||
|
||||
- `pnpm lint` (tsc, oxlint, knip, externals), `pnpm format:check`, `pnpm i18n:check`.
|
||||
- `pnpm test`: 119 unit files, 950 tests; 4 storybook files, 45 tests (after the coverage above).
|
||||
- Playwright on Chromium against the local Playwright backend, one worker:
|
||||
- `playwright/url-transport.spec.ts`: 10 of 10 pass, including delegation
|
||||
through the homeserver and the JWT service, the subscriber fallback, the
|
||||
publisher error and the federated token.
|
||||
- `playwright/widget/url-transport.test.ts`: passes.
|
||||
- The rest of `playwright/*.spec.ts`, `playwright/component`, `playwright/sdk`:
|
||||
48 pass, 1 skipped, 1 fails (`reconnect.spec.ts`, which fails on `main`
|
||||
locally too because the local JWT service is slower than the spec's wait).
|
||||
- `playwright/widget`: 7 of 10 pass, including `federated-call`. `huddle-call`
|
||||
fails before any call code runs (the Element Web develop image's room list no
|
||||
longer matches the helper's "Open room" selector), and `screen-share` looks
|
||||
for `video[data-lk-source="screen_share"]`, an attribute the LiveKit React
|
||||
components set and the SDK's plain `<video>` elements do not: a failure of this
|
||||
branch that predates the PR.
|
||||
- Local environment work needed for that: the othersite JWT service and nginx
|
||||
containers had to be recreated from the compose file, two stale containers held
|
||||
port 16080, and the widget helper's hard-coded `https://localhost:3000` was
|
||||
pointed at 3100 for the run only, since port 3000 is taken on this machine.
|
||||
- Standalone, widget and component all go through `InCallView.rtcParticipationOptions`
|
||||
and the SDK, so the change is the same in all three modes; the widget-specific
|
||||
fallback (a host without MSC4533) is in `isCsApiUnavailable`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Coverage comes from a single vitest run (see .github/workflows/test.yaml), so
|
||||
# the app and the SDK are split by path into codecov components rather than
|
||||
# uploaded under separate flags. Each component gets its own coverage figure in
|
||||
# the PR comment and on codecov.io.
|
||||
#
|
||||
# Validate with: curl --data-binary @codecov.yml https://codecov.io/validate
|
||||
|
||||
comment:
|
||||
layout: "reach, diff, flags, components, files"
|
||||
|
||||
component_management:
|
||||
individual_components:
|
||||
- component_id: app
|
||||
name: app
|
||||
paths:
|
||||
- src/**
|
||||
- component_id: sdk
|
||||
name: sdk
|
||||
paths:
|
||||
- sdk/**
|
||||
+1
-1
@@ -74,7 +74,7 @@ import {
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { ProcessorProvider } from "../src/livekit/TrackProcessorContext";
|
||||
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
|
||||
@@ -76,9 +76,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Alice should be in grid mode as she is local sharing
|
||||
@@ -117,9 +117,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(2);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
2,
|
||||
);
|
||||
|
||||
// Expect 2 indicators at the bottom
|
||||
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# SDK mode (EXPERIMENTAL, superseded)
|
||||
# SDK mode (LEGACY)
|
||||
|
||||
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
||||
> `sdk/dev`; see `sdk-plan.md`. This folder still builds, with its own script, until
|
||||
> that package can do what this does.
|
||||
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
|
||||
> keeps building as a thin layer over that package until its consumers have moved
|
||||
> to the package directly. It will then be removed. Its members are the package's
|
||||
> `RTCMember`s now, in place of its own wrapper around the raw membership.
|
||||
|
||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file contains helper functions and types for the MatrixRTC SDK.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { scan } from "rxjs";
|
||||
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.then(() => {
|
||||
logger.info("sticky MatrixRTCSdk");
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("failed to make sticky MatrixRTCSdk", error);
|
||||
});
|
||||
};
|
||||
export const TEXT_LK_TOPIC = "matrixRTC";
|
||||
/**
|
||||
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
|
||||
*
|
||||
* I think there should be a builtin for this but i did not find it...
|
||||
*/
|
||||
export const currentAndPrev = scan<
|
||||
LivekitRoomItem[],
|
||||
{
|
||||
prev: LivekitRoomItem[];
|
||||
current: LivekitRoomItem[];
|
||||
}
|
||||
>(
|
||||
({ current: lastCurrentVal }, items) => ({
|
||||
prev: lastCurrentVal,
|
||||
current: items,
|
||||
}),
|
||||
{
|
||||
prev: [],
|
||||
current: [],
|
||||
},
|
||||
);
|
||||
@@ -30,12 +30,12 @@
|
||||
div.appendChild(child);
|
||||
});
|
||||
|
||||
sdk.members$.subscribe((memberObjects) => {
|
||||
sdk.remoteMembers$.subscribe((memberObjects) => {
|
||||
const div = document.getElementById("members");
|
||||
div.innerHTML = "<h3>Members:</h3>";
|
||||
|
||||
// Create member list
|
||||
const members = memberObjects.map((member) => member.membership.sender);
|
||||
const members = memberObjects.map((member) => member.userId);
|
||||
console.info("members changed", members);
|
||||
for (const m of members) {
|
||||
console.info("member", m);
|
||||
|
||||
@@ -6,131 +6,82 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
* LEGACY, in production use
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call:
|
||||
* `pnpm build:sdk-target-based-on-call-view-model`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
* It takes care of all the tricky bits:
|
||||
* - sending delayed events
|
||||
* - finding the right sfu
|
||||
* - handling the media stream
|
||||
* - sending join/leave state or sticky events
|
||||
* - setting up encryption and scharing keys
|
||||
* The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
|
||||
* bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
|
||||
* exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
|
||||
* Element Call's call view model doing the work underneath. New consumers use
|
||||
* the package directly; this bundle goes away once the existing ones have.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
type TextStreamReader,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
type Behavior,
|
||||
createRTCSlot,
|
||||
E2eeType,
|
||||
type LocalRTCMember,
|
||||
ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
type RTCParticipation,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
// TODO how can this get fixed? to just be part of `livekit-client`
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
initialPublishRequests,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import {
|
||||
captureSettings,
|
||||
rtcParticipationOptions,
|
||||
rtcSlotOptions,
|
||||
} from "../src/room/InCallView";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
* observe connected$ to track the state.
|
||||
* @returns
|
||||
*/
|
||||
/** The data channel topic this bundle has always used, so that old and new builds talk. */
|
||||
const TOPIC = "matrixRTC";
|
||||
|
||||
export interface MatrixRTCSdk {
|
||||
/** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
|
||||
join: () => void;
|
||||
/** @throws on leave errors */
|
||||
leave: () => void;
|
||||
/**
|
||||
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
||||
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
||||
* A new sdk needs to be created via createMatrixRTCSdk.
|
||||
*/
|
||||
/** Ends everything. The sdk cannot be restarted; create a new one. */
|
||||
stop: () => void;
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/**
|
||||
* flattened list of remote members
|
||||
*/
|
||||
remoteMembers$: Behavior<
|
||||
{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
/**
|
||||
* flattened local member
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | null;
|
||||
} | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
/** Our own member, null until our membership has been seen in the room. */
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
/** The other members of the slot, with the media the participation carries for them. */
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** What other members sent with `sendData`. */
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/** Sends to every member on our transport. Rejects while not joined. */
|
||||
sendData: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
application = "m.call",
|
||||
id = "",
|
||||
sticky = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
if (!widget) throw new Error("No widget: this bundle only runs as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
const room = roomId === null ? null : client.getRoom(roomId);
|
||||
if (room === null) throw new Error("No room: the url needs a joined roomId");
|
||||
|
||||
// rtc session
|
||||
const slot = { application, id };
|
||||
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
|
||||
rtcSessionManager.start();
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
@@ -141,243 +92,100 @@ export async function createMatrixRTCSdk(
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
|
||||
const capture = captureSettings();
|
||||
const slot = createRTCSlot(scope, client, room, {
|
||||
...rtcSlotOptions(encryptionSystem),
|
||||
application,
|
||||
id,
|
||||
});
|
||||
let vm: CallViewModel | undefined;
|
||||
const failed =
|
||||
(what: string) =>
|
||||
(e: unknown): void =>
|
||||
logger.error(what, e);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
// The SDK owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
);
|
||||
logger.info("CallViewModelCreated");
|
||||
|
||||
// create data listener
|
||||
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||
|
||||
const lkTextStreamHandlerFunction = async (
|
||||
reader: TextStreamReader,
|
||||
participantInfo: { identity: string },
|
||||
livekitRoom: LivekitRoom,
|
||||
): Promise<void> => {
|
||||
const info = reader.info;
|
||||
logger.info(
|
||||
`Received text stream from ${participantInfo.identity}\n` +
|
||||
` Topic: ${info.topic}\n` +
|
||||
` Timestamp: ${info.timestamp}\n` +
|
||||
` ID: ${info.id}\n` +
|
||||
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
|
||||
);
|
||||
|
||||
const participants = callViewModel.livekitRoomItems$.value.find(
|
||||
(i) => i.livekitRoom === livekitRoom,
|
||||
)?.participants;
|
||||
if (participants && participants.includes(participantInfo.identity)) {
|
||||
const text = await reader.readAll();
|
||||
logger.info(`Received text: ${text}`);
|
||||
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||
} else {
|
||||
logger.warn(
|
||||
"Received text from unknown participant",
|
||||
participantInfo.identity,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
|
||||
.pipe(
|
||||
tap((beforecurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
|
||||
beforecurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
currentAndPrev,
|
||||
tap((aftercurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
|
||||
aftercurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ prev, current }) => {
|
||||
const prevRooms = prev.map((i) => i.livekitRoom);
|
||||
const currentRooms = current.map((i) => i.livekitRoom);
|
||||
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
|
||||
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
|
||||
addedRooms.forEach((r) => {
|
||||
logger.info(`Registering text stream handler for room `);
|
||||
r.registerTextStreamHandler(
|
||||
TEXT_LK_TOPIC,
|
||||
(reader, participantInfo) =>
|
||||
void lkTextStreamHandlerFunction(reader, participantInfo, r),
|
||||
);
|
||||
});
|
||||
removedRooms.forEach((r) => {
|
||||
logger.info(`Unregistering text stream handler for room `);
|
||||
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
logger.info("Livekit room items subscription completed");
|
||||
for (const item of callViewModel.livekitRoomItems$.value) {
|
||||
logger.info("unregistering room item from room", item.transport);
|
||||
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// create sendData function
|
||||
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
|
||||
const over = <T>(
|
||||
pick: (participation: RTCParticipation) => Observable<T>,
|
||||
fallback: T,
|
||||
): Behavior<T> =>
|
||||
scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
switchMap((m) => {
|
||||
if (!m)
|
||||
return of((data: string): never => {
|
||||
throw Error("local membership not yet ready.");
|
||||
});
|
||||
return m.participant.value$.pipe(
|
||||
map((p) => {
|
||||
if (p === null) {
|
||||
return (data: string): never => {
|
||||
throw Error("local participant not yet ready to send data.");
|
||||
};
|
||||
} else {
|
||||
return async (data: string): Promise<TextStreamInfo> =>
|
||||
p.sendText(data, { topic: TEXT_LK_TOPIC });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
slot.participation$.pipe(
|
||||
switchMap((p) => (p === null ? of(fallback) : pick(p))),
|
||||
),
|
||||
);
|
||||
|
||||
const sendData = async (data: unknown): Promise<void> => {
|
||||
const dataString = JSON.stringify(data);
|
||||
logger.info("try sending: ", dataString);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
const info = await sendFn.value(dataString);
|
||||
logger.info(`Sent text with stream ID: ${info.id}`);
|
||||
} catch (e) {
|
||||
logger.error("failed sending: ", dataString, e);
|
||||
}
|
||||
};
|
||||
|
||||
const sendRoomMessage = async (message: string): Promise<void> => {
|
||||
const messageString = JSON.stringify(message);
|
||||
logger.info("try sending to room: ", messageString);
|
||||
try {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
} catch (e) {
|
||||
logger.error("failed sending to room: ", messageString, e);
|
||||
}
|
||||
};
|
||||
|
||||
// after hangup gets called
|
||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||
const leaveResolver = Promise.withResolvers<void>();
|
||||
logger.info("waiting for RTC leave");
|
||||
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
|
||||
logger.info("received RTC join update: ", isJoined);
|
||||
if (!isJoined) leaveResolver.resolve();
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await hostBridge
|
||||
.close?.()
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to ask the host to close", e),
|
||||
);
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
void scheduleWidgetCloseOnLeave();
|
||||
});
|
||||
|
||||
logger.info("createMatrixRTCSdk done");
|
||||
|
||||
return {
|
||||
join: (): void => {
|
||||
// first lets try making the widget sticky
|
||||
if (sticky) tryMakeSticky(widget);
|
||||
callViewModel.join();
|
||||
},
|
||||
leave: (): void => {
|
||||
callViewModel.leave();
|
||||
},
|
||||
stop: (): void => {
|
||||
leaveSubs.unsubscribe();
|
||||
livekitRoomItemsSub.unsubscribe();
|
||||
scope.end();
|
||||
},
|
||||
data$,
|
||||
localMember$: scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
tap((member) =>
|
||||
logger.info("localMatrixLivekitMember$ next: ", member),
|
||||
),
|
||||
switchMap((member) => {
|
||||
if (member === null) return of(null);
|
||||
return combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
tap((member) => logger.info("localMember$ next: ", member)),
|
||||
),
|
||||
),
|
||||
connected$: callViewModel.connected$,
|
||||
remoteMembers$: scope.behavior(
|
||||
callViewModel.remoteMatrixLivekitMembers$.pipe(
|
||||
switchMap((members) => {
|
||||
const listOfMemberObservables = members.map((member) =>
|
||||
combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
// using shareReplay instead of a Behavior here because the behavior would need
|
||||
// a tricky scope.end() setup.
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
if (slot.participation$.value !== null) return;
|
||||
if (sticky)
|
||||
widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.catch(failed("Failed to make the widget sticky"));
|
||||
const participation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
publish: initialPublishRequests(muteStates, mediaDevices, capture),
|
||||
});
|
||||
vm = createCallViewModel$(
|
||||
scope,
|
||||
participation,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem,
|
||||
capture,
|
||||
hostBridge,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
);
|
||||
// Once the hang-up has gone through, unstick and ask the host to close us
|
||||
vm.leave$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
participation.state$.pipe(
|
||||
filter((state) => state.kind === "left"),
|
||||
take(1),
|
||||
),
|
||||
);
|
||||
return combineLatest(listOfMemberObservables);
|
||||
}),
|
||||
),
|
||||
[],
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() => {
|
||||
hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch(failed("Failed to unstick the widget"))
|
||||
.then(() => hostBridge.close?.())
|
||||
.catch(failed("Failed to ask the host to close"));
|
||||
});
|
||||
},
|
||||
leave: (): void => vm?.leave(),
|
||||
stop: (): void => scope.end(),
|
||||
connected$: over(
|
||||
(p) => p.state$.pipe(map((state) => state.kind === "connected")),
|
||||
false,
|
||||
),
|
||||
sendData,
|
||||
sendRoomMessage,
|
||||
localMember$: over((p) => p.localMember$, null),
|
||||
remoteMembers$: over((p) => p.remoteMembers$, []),
|
||||
// Attested members only: the SDK has matched the sender
|
||||
data$: slot.participation$.pipe(
|
||||
switchMap((p) => p?.data$ ?? EMPTY),
|
||||
filter(({ topic }) => topic === TOPIC),
|
||||
map(({ member, text }) => ({
|
||||
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||
data: text,
|
||||
})),
|
||||
),
|
||||
sendData: async (data): Promise<void> => {
|
||||
const participation = slot.participation$.value;
|
||||
if (participation === null) throw new Error("Not joined");
|
||||
await participation.sendData(TOPIC, JSON.stringify(data));
|
||||
},
|
||||
sendRoomMessage: async (message): Promise<void> => {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { ProcessorProvider } from "./tracks/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import {
|
||||
|
||||
@@ -16,12 +16,12 @@ import classNames from "classnames";
|
||||
import { Modal } from "./Modal";
|
||||
import styles from "./RTCConnectionStats.module.css";
|
||||
import mediaViewStyles from "../src/tile/MediaView.module.css";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
interface Props {
|
||||
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
transport?: UnstableLivekitTransport;
|
||||
transport?: Transport;
|
||||
rtcBackendIdentity?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { type ReactNode } from "react";
|
||||
@@ -20,7 +20,6 @@ import { type MockRTCSession } from "../utils/test";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
|
||||
|
||||
|
||||
@@ -6,23 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MatrixRTC mode determines how Element Call interacts with the
|
||||
* MatrixRTC backend and other participants. Selectable via the Developer
|
||||
* Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
|
||||
*/
|
||||
export enum MatrixRTCMode {
|
||||
/** Multi-SFU transport, legacy JWT endpoint, state events. */
|
||||
Compatibility = "compatibility",
|
||||
/**
|
||||
* Multi-SFU transport with:
|
||||
* - sticky events
|
||||
* - hashed RTC backend identity
|
||||
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
|
||||
* - use the hashed identity for the local membership
|
||||
*/
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
import { MatrixRTCMode } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export { MatrixRTCMode };
|
||||
|
||||
export interface DelayedLeaveTimings {
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/*
|
||||
Copyright 2023, 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.
|
||||
*/
|
||||
|
||||
export enum E2eeType {
|
||||
NONE = 0,
|
||||
PER_PARTICIPANT = 1,
|
||||
SHARED_KEY = 2,
|
||||
}
|
||||
export { E2eeType } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector 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, vi } from "vitest";
|
||||
import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { KeyProviderEvent } from "livekit-client";
|
||||
|
||||
import { MatrixKeyProvider } from "./matrixKeyProvider";
|
||||
|
||||
function mockRTCSession(): MatrixRTCSession {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
reemitEncryptionKeys: vi.fn(),
|
||||
} as unknown as MatrixRTCSession;
|
||||
}
|
||||
|
||||
describe("matrixKeyProvider", () => {
|
||||
test("initializes", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
expect(keyProvider).toBeTruthy();
|
||||
});
|
||||
|
||||
test("listens for key requests and emits existing keys", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
|
||||
const session = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session);
|
||||
|
||||
expect(session.on).toHaveBeenCalledWith(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(session.off).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stops listening when session changes", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
|
||||
const session1 = mockRTCSession();
|
||||
const session2 = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session1);
|
||||
expect(session1.off).not.toHaveBeenCalled();
|
||||
|
||||
keyProvider.setRTCSession(session2);
|
||||
expect(session1.off).toHaveBeenCalledWith(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("emits existing keys", () => {
|
||||
const keyProvider = new MatrixKeyProvider();
|
||||
const setKeyListener = vi.fn();
|
||||
keyProvider.on(KeyProviderEvent.SetKey, setKeyListener);
|
||||
|
||||
const session = mockRTCSession();
|
||||
|
||||
keyProvider.setRTCSession(session);
|
||||
|
||||
expect(session.reemitEncryptionKeys).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BaseKeyProvider } from "livekit-client";
|
||||
import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
private rtcSession?: MatrixRTCSession;
|
||||
private logger: Logger;
|
||||
public constructor() {
|
||||
super({ ratchetWindowSize: 10, keyringSize: 256 });
|
||||
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
}
|
||||
|
||||
public setRTCSession(rtcSession: MatrixRTCSession): void {
|
||||
if (this.rtcSession) {
|
||||
this.rtcSession.off(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
this.onEncryptionKeyChanged,
|
||||
);
|
||||
}
|
||||
|
||||
this.rtcSession = rtcSession;
|
||||
|
||||
this.rtcSession.on(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
this.onEncryptionKeyChanged,
|
||||
);
|
||||
|
||||
// The new session could be aware of keys of which the old session wasn't,
|
||||
// so emit key changed events
|
||||
this.rtcSession.reemitEncryptionKeys();
|
||||
}
|
||||
|
||||
private onEncryptionKeyChanged = (
|
||||
encryptionKey: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membershipParts: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
): void => {
|
||||
crypto.subtle
|
||||
.importKey("raw", encryptionKey, "HKDF", false, [
|
||||
"deriveBits",
|
||||
"deriveKey",
|
||||
])
|
||||
.then(
|
||||
(keyMaterial) => {
|
||||
this.onSetEncryptionKey(
|
||||
keyMaterial,
|
||||
rtcBackendIdentity,
|
||||
encryptionKeyIndex,
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
this.logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { E2eeType, type EncryptionSystem } from "@element-hq/matrixrtc-sdk";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
/**
|
||||
@@ -82,10 +82,7 @@ export function getKeyForRoom(roomId: string): string | null {
|
||||
return keyForRoom(roomId, paramsRoomId, password);
|
||||
}
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
export { type EncryptionSystem } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import {
|
||||
getTrackReferenceId,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
type Participant,
|
||||
type RemoteAudioTrack,
|
||||
type Room,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { type ReactNode } from "react";
|
||||
import { useTracks } from "@livekit/components-react";
|
||||
|
||||
import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
exampleTransportLocator,
|
||||
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(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@livekit/components-react", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
|
||||
return (
|
||||
<audio data-testid={"audio"}>
|
||||
{getTrackReferenceId(props.trackRef)}
|
||||
</audio>
|
||||
);
|
||||
},
|
||||
useTracks: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
let tracks: TrackReference[] = [];
|
||||
|
||||
/**
|
||||
* Render the test component with given rtc members and livekit participant identities.
|
||||
*
|
||||
* It is possible to have rtc members that are not in livekit (e.g. not yet joined) and vice versa.
|
||||
*
|
||||
* @param rtcMembers - Array of active rtc members with userId and deviceId.
|
||||
* @param livekitParticipantIdentities - Array of livekit participant (that are publishing).
|
||||
* @param explicitTracks - Array of tracks available in livekit, if not provided, one audio track per livekitParticipantIdentities will be created.
|
||||
* */
|
||||
|
||||
function renderTestComponent(
|
||||
rtcMembers: { userId: string; deviceId: string }[],
|
||||
livekitParticipantIdentities: string[],
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[],
|
||||
): RenderResult {
|
||||
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
|
||||
mockRemoteParticipant({ identity }),
|
||||
);
|
||||
const participants = rtcMembers.flatMap(({ userId, deviceId }) => {
|
||||
const p = liveKitParticipants.find(
|
||||
(p) => p.identity === `${userId}:${deviceId}`,
|
||||
);
|
||||
return p === undefined ? [] : [p];
|
||||
});
|
||||
const livekitRoom = {
|
||||
remoteParticipants: new Map<string, Participant>(
|
||||
liveKitParticipants.map((p) => [p.identity, p]),
|
||||
),
|
||||
} as unknown as Room;
|
||||
|
||||
if ((explicitTracks?.length ?? 0) > 0) {
|
||||
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
|
||||
const participant =
|
||||
liveKitParticipants.find((p) => p.identity === participantId) ??
|
||||
mockRemoteParticipant({ identity: participantId });
|
||||
return mockTrack(participant, kind, source);
|
||||
});
|
||||
} else {
|
||||
tracks = participants.map((p) => mockTrack(p));
|
||||
}
|
||||
|
||||
vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
return render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={participants.map((p) => p.identity)}
|
||||
livekitRoom={livekitRoom}
|
||||
transport={exampleTransportLocator}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("should render for member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@alice", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
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}
|
||||
transport={exampleTransportLocator}
|
||||
/>
|
||||
</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}
|
||||
transport={exampleTransportLocator}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
});
|
||||
|
||||
const TEST_CASES: {
|
||||
name: string;
|
||||
rtcUsers: { userId: string; deviceId: string }[];
|
||||
livekitParticipantIdentities: string[];
|
||||
explicitTracks?: {
|
||||
participantId: string;
|
||||
kind: Track.Kind;
|
||||
source: Track.Source;
|
||||
}[];
|
||||
expectedAudioTracks: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "single user single device",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@alice", deviceId: "DEV1" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@alice:DEV1"],
|
||||
expectedAudioTracks: 3,
|
||||
},
|
||||
// Charlie is a rtc member but not in livekit
|
||||
{
|
||||
name: "Charlie is rtc member but not in livekit",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
{ userId: "@charlie", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
// Charlie is in livekit but not rtc member
|
||||
{
|
||||
name: "Charlie is in livekit but not rtc member",
|
||||
rtcUsers: [
|
||||
{ userId: "@alice", deviceId: "DEV0" },
|
||||
{ userId: "@bob", deviceId: "DEV0" },
|
||||
],
|
||||
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@charlie:DEV0"],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
{
|
||||
name: "no audio track, only video track",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Video,
|
||||
source: Track.Source.Camera,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "Audio track from unknown source",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Unknown,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 1,
|
||||
},
|
||||
{
|
||||
name: "Audio track from other device",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV1",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 0,
|
||||
},
|
||||
{
|
||||
name: "two audio tracks, microphone and screenshare",
|
||||
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
|
||||
livekitParticipantIdentities: ["@alice:DEV0"],
|
||||
explicitTracks: [
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.ScreenShareAudio,
|
||||
},
|
||||
],
|
||||
expectedAudioTracks: 2,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(TEST_CASES)(
|
||||
`should render sound test cases $name`,
|
||||
({
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
expectedAudioTracks,
|
||||
}) => {
|
||||
const { queryAllByTestId } = renderTestComponent(
|
||||
rtcUsers,
|
||||
livekitParticipantIdentities,
|
||||
explicitTracks,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(expectedAudioTracks);
|
||||
},
|
||||
);
|
||||
|
||||
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined);
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1);
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(testAudioContext.gain.gain.value).toEqual(1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(0);
|
||||
});
|
||||
|
||||
it("should setup audioContext gain and pan", () => {
|
||||
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
|
||||
pan: 1,
|
||||
volume: 0.1,
|
||||
});
|
||||
|
||||
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
|
||||
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
expect(audioTrack.setAudioContext).toHaveBeenCalled();
|
||||
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled();
|
||||
|
||||
expect(testAudioContext.gain.gain.value).toEqual(0.1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(1);
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
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, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import * as controls from "../controls";
|
||||
import { type TransportLocator } from "./auth";
|
||||
|
||||
export interface MatrixAudioRendererProps {
|
||||
transport: TransportLocator;
|
||||
livekitRoom: LivekitRoom;
|
||||
/**
|
||||
* The list of participant identities to render audio for.
|
||||
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
|
||||
* that are not expected to be in the rtc session (local user is excluded).
|
||||
*/
|
||||
validIdentities: string[];
|
||||
/**
|
||||
* If set to `true`, mutes all audio tracks rendered by the component.
|
||||
* @remarks
|
||||
* If set to `true`, the server will stop sending audio track data to the client.
|
||||
*/
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
* It also takes care of the earpiece audio configuration for iOS devices.
|
||||
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <MatrixAudioRenderer />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function LivekitRoomAudioRenderer({
|
||||
transport,
|
||||
livekitRoom,
|
||||
validIdentities,
|
||||
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,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.Unknown,
|
||||
],
|
||||
{
|
||||
updateOnlyOn: [],
|
||||
onlySubscribed: true,
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// 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 { 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 ${identity} from ${JSON.stringify(transport)} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
} else if (isValid) {
|
||||
warnedIdentities.current.delete(identity);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
// responsible for mimicking earpiece audio on iPhones.
|
||||
// The Safari audio devices enumeration does not expose an earpiece audio device.
|
||||
// We alternatively use the audioContext pan node to only use one of the stereo channels.
|
||||
|
||||
// This component does get additionally complicated because of a Safari bug.
|
||||
// (see: https://bugs.webkit.org/show_bug.cgi?id=251532
|
||||
// and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878
|
||||
// and https://bugs.webkit.org/show_bug.cgi?id=231105)
|
||||
//
|
||||
// AudioContext gets stopped if the webview gets moved into the background.
|
||||
// Once the phone is in standby audio playback will stop.
|
||||
// So we can only use the pan trick only works is the phone is not in standby.
|
||||
// If earpiece mode is not used we do not use audioContext to allow standby playback.
|
||||
// shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback.
|
||||
|
||||
const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig();
|
||||
const shouldUseAudioContext = stereoPan !== 0;
|
||||
|
||||
// initialize the potentially used audio context.
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
const ctx = new AudioContext();
|
||||
setAudioContext(ctx);
|
||||
return (): void => {
|
||||
void ctx.close();
|
||||
};
|
||||
}, []);
|
||||
const audioNodes = useMemo(
|
||||
() => ({
|
||||
gain: audioContext?.createGain(),
|
||||
pan: audioContext?.createStereoPanner(),
|
||||
}),
|
||||
[audioContext],
|
||||
);
|
||||
|
||||
// Simple effects to update the gain and pan node based on the props
|
||||
useEffect(() => {
|
||||
if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan;
|
||||
}, [audioNodes.pan, stereoPan]);
|
||||
useEffect(() => {
|
||||
if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor;
|
||||
}, [audioNodes.gain, volumeFactor]);
|
||||
|
||||
return (
|
||||
// We add all audio elements into one <div> for the browser developer tool experience/tidyness.
|
||||
<div style={{ display: "none" }}>
|
||||
{tracks.map((trackRef) => (
|
||||
<AudioTrackWithAudioNodes
|
||||
key={getTrackReferenceId(trackRef)}
|
||||
trackRef={trackRef}
|
||||
muted={muted}
|
||||
audioContext={shouldUseAudioContext ? audioContext : undefined}
|
||||
audioNodes={audioNodes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StereoPanAudioTrackProps {
|
||||
muted?: boolean;
|
||||
audioContext?: AudioContext;
|
||||
audioNodes: {
|
||||
gain?: GainNode;
|
||||
pan?: StereoPannerNode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
|
||||
* It main purpose is to remount the AudioTrack component when switching from
|
||||
* audioContext to normal audio playback.
|
||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||
* @param props The component props
|
||||
* @param props.trackRef The track reference
|
||||
* @param props.muted If the track should be muted
|
||||
* @param props.audioContext The audio context to use
|
||||
* @param props.audioNodes The audio nodes to use
|
||||
* @returns
|
||||
*/
|
||||
function AudioTrackWithAudioNodes({
|
||||
trackRef,
|
||||
muted,
|
||||
audioContext,
|
||||
audioNodes,
|
||||
...props
|
||||
}: StereoPanAudioTrackProps &
|
||||
AudioTrackProps &
|
||||
React.RefAttributes<HTMLAudioElement>): ReactNode {
|
||||
// This is used to unmount/remount the AudioTrack component.
|
||||
// Mounting needs to happen after the audioContext is set.
|
||||
// (adding the audio context when already mounted did not work outside strict mode)
|
||||
const [trackReady, setTrackReady] = useReactiveState(
|
||||
() => false,
|
||||
// We only want the track to reset once both (audioNodes and audioContext) are set.
|
||||
// for unsetting the audioContext its enough if one of the two is undefined.
|
||||
[audioContext && audioNodes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackRef || trackReady) return;
|
||||
const track = trackRef.publication.track as RemoteAudioTrack;
|
||||
const useContext = audioContext && audioNodes.gain && audioNodes.pan;
|
||||
track.setAudioContext(useContext ? audioContext : undefined);
|
||||
track.setWebAudioPlugins(
|
||||
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
||||
);
|
||||
setTrackReady(true);
|
||||
controls.setPlaybackStarted();
|
||||
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
||||
|
||||
return (
|
||||
trackReady && <AudioTrack trackRef={trackRef} muted={muted} {...props} />
|
||||
);
|
||||
}
|
||||
@@ -1,137 +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 { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
|
||||
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
|
||||
|
||||
/**
|
||||
* In widget mode the host may refuse an action before any request is made
|
||||
* which surfaces as a plain error. Asking again cannot change its answer,
|
||||
* so only errors from the network are worth retrying.
|
||||
*/
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
return e instanceof HTTPError || e instanceof ConnectionError;
|
||||
}
|
||||
|
||||
export type ClientGetTokenParts = Pick<
|
||||
MatrixClient,
|
||||
"_unstable_getLivekitToken"
|
||||
>;
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientGetTokenParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU for which we wish to get an access token.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The name of the homeserver to which the SFU belongs.
|
||||
*/
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using
|
||||
* the MSC4195 LiveKit client-server API endpoints.
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
const res = await doNetworkOperationWithRetry(
|
||||
async () =>
|
||||
client._unstable_getLivekitToken({
|
||||
url,
|
||||
server_name: serverName,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: membership.memberId,
|
||||
}),
|
||||
isNetworkError,
|
||||
);
|
||||
return extractFullConfigFromToken({ url, jwt: res.jwt });
|
||||
}
|
||||
|
||||
export type ClientDelegationParts = Pick<
|
||||
MatrixClient,
|
||||
"_unstable_delegateDelayedLeave" | "baseUrl"
|
||||
>;
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientDelegationParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU to which we are publishing and wish to
|
||||
* delegate the delayed leave event.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
delayId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given SFU, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the SFU.
|
||||
*/
|
||||
export async function delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
url,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
await doNetworkOperationWithRetry(
|
||||
async () =>
|
||||
client._unstable_delegateDelayedLeave({
|
||||
url,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: membership.memberId,
|
||||
delay_id: delayId,
|
||||
}),
|
||||
isNetworkError,
|
||||
);
|
||||
}
|
||||
@@ -1,306 +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, expect, type Mocked, vi, describe, it } from "vitest";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
aliceDeviceId,
|
||||
aliceId,
|
||||
aliceUserId,
|
||||
createJWTToken,
|
||||
} from "../../utils/test-fixtures";
|
||||
import type * as csApi from "./csApi";
|
||||
import * as openID from "./openID";
|
||||
import { delegateDelayedLeave, getSFUConfig } from ".";
|
||||
import { mocked } from "storybook/test";
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
vi.mock("./openID", () => ({ getSFUConfig: vi.fn() }));
|
||||
vi.useFakeTimers();
|
||||
|
||||
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
|
||||
const openIDJWTToken = createJWTToken(aliceId, "via OpenID");
|
||||
|
||||
mocked(openID).getSFUConfig.mockResolvedValue({
|
||||
url: "mock-openID-url",
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
livekitIdentity: aliceId,
|
||||
});
|
||||
|
||||
const client: Mocked<
|
||||
csApi.ClientGetTokenParts &
|
||||
csApi.ClientDelegationParts &
|
||||
openID.ClientOpenIDParts
|
||||
> = {
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
|
||||
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
|
||||
getOpenIdToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ access_token: "mock-openID-token" }),
|
||||
};
|
||||
|
||||
const membership: CallMembershipIdentityParts = {
|
||||
userId: aliceUserId,
|
||||
deviceId: aliceDeviceId,
|
||||
memberId: "123",
|
||||
};
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const slotId = "m.call#ROOM";
|
||||
|
||||
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
|
||||
|
||||
const urlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://livekit.example.org",
|
||||
};
|
||||
|
||||
const serviceUrlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://livekit-jwt.example.org",
|
||||
};
|
||||
|
||||
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
||||
...urlTransport,
|
||||
...serviceUrlTransport,
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("getSFUConfig", () => {
|
||||
const defaultParams = {
|
||||
client,
|
||||
membership,
|
||||
serverName: "example.org",
|
||||
role: "subscriber" as const,
|
||||
roomId,
|
||||
slotId,
|
||||
logger,
|
||||
};
|
||||
|
||||
it("uses CS API with url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: csApiJWTToken,
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID with url transport", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with livekit_service_url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CS API with url + livekit_service_url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: csApiJWTToken,
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to OpenID with url + livekit_service_url transport", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to OpenID when the widget host refuses the CS API", async () => {
|
||||
// In widget mode a host without MSC4533 rejects the request before it
|
||||
// reaches the homeserver, with a plain error rather than a Matrix error
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"The rtc_livekit_get_token action is not supported by the client.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the widget host's refusal with url transport", async () => {
|
||||
const error = new Error(
|
||||
"The rtc_livekit_get_token action is not supported by the client.",
|
||||
);
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
|
||||
await expect(
|
||||
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow(error);
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses CS API when publishing with url transport", async () => {
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID when publishing", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID when publishing with url + livekit_service_url transport", async () => {
|
||||
// Subscribers predating MSC4195 expect a legacy participant identity from
|
||||
// anyone advertising a state event, which only the JWT service assigns
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
role: "publisher",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: "publisher" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID on connection error", async () => {
|
||||
const client_ = {
|
||||
...client,
|
||||
_unstable_getLivekitToken: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ConnectionError("No connection")),
|
||||
};
|
||||
await Promise.all([
|
||||
expect(
|
||||
getSFUConfig({
|
||||
...defaultParams,
|
||||
client: client_,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).rejects.toThrow(expect.any(ConnectionError)),
|
||||
vi.runAllTimersAsync(), // Allow request to be retried
|
||||
]);
|
||||
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegateDelayedLeave", () => {
|
||||
const defaultParams = {
|
||||
client,
|
||||
membership,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId: "delay123",
|
||||
logger,
|
||||
};
|
||||
|
||||
it("uses CS API with url transport", async () => {
|
||||
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
|
||||
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID with url transport", async () => {
|
||||
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
|
||||
unsupportedError,
|
||||
);
|
||||
await expect(
|
||||
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow();
|
||||
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with livekit_service_url transport", async () => {
|
||||
await delegateDelayedLeave({
|
||||
...defaultParams,
|
||||
transport: serviceUrlTransport,
|
||||
});
|
||||
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses OpenID with url + livekit_service_url transport", async () => {
|
||||
// Must match the flow that got the token, so the identities line up
|
||||
await delegateDelayedLeave({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
});
|
||||
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "publisher",
|
||||
delayId: "delay123",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,253 +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 CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import * as openID from "./openID";
|
||||
import * as csApi from "./csApi";
|
||||
import { type SFUConfig } from "./types";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
|
||||
export type { ClientOpenIDParts } from "./openID";
|
||||
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
|
||||
export type * from "./types";
|
||||
|
||||
/**
|
||||
* Checks whether an error means "this homeserver does not implement the
|
||||
* (unstable) MSC4195 endpoints".
|
||||
*
|
||||
* MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support
|
||||
* answers with whatever it uses for unknown endpoints (Synapse: 404 with
|
||||
* `M_UNRECOGNIZED`), so we accept any 404 as "unsupported".
|
||||
* @param e The error to check.
|
||||
*/
|
||||
function isEndpointUnsupported(e: unknown): boolean {
|
||||
return (
|
||||
e instanceof MatrixError &&
|
||||
(e.httpStatus === 404 ||
|
||||
e.errcode === "M_NOT_FOUND" ||
|
||||
e.errcode === "M_UNRECOGNIZED")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an error means the MSC4195 CS API cannot be used from here:
|
||||
* the homeserver does not implement it, or the request never reached the
|
||||
* homeserver because in widget mode the host does not expose the endpoint
|
||||
* (MSC4533), which the widget API reports as a plain error rather than a
|
||||
* Matrix error. A connection error is neither, as the endpoint may well exist.
|
||||
* @param e The error to check.
|
||||
*/
|
||||
function isCsApiUnavailable(e: unknown): boolean {
|
||||
if (isEndpointUnsupported(e)) return true;
|
||||
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
|
||||
}
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport for which we wish to get an access token.
|
||||
*/
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The name of the homeserver to which the {@link transport} belongs.
|
||||
*/
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on the {@link transport}.
|
||||
*/
|
||||
role: "publisher" | "subscriber";
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given transport.
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
transport,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
logger,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
logger,
|
||||
});
|
||||
|
||||
const flow = chooseAuthFlow(transport, role);
|
||||
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
|
||||
|
||||
try {
|
||||
return await csApi.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
});
|
||||
} catch (e) {
|
||||
// Publishers should never fall back. If a homeserver advertises a `url`
|
||||
// but doesn't support MSC4195 itself, that's a misconfiguration which
|
||||
// the admin should know about so they can prepare for the future
|
||||
// deprecation of `livekit_service_url`.
|
||||
const mayFallBack =
|
||||
role === "subscriber" && "livekit_service_url" in transport;
|
||||
if (mayFallBack && isCsApiUnavailable(e)) {
|
||||
logger.warn(
|
||||
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
|
||||
);
|
||||
return await viaOpenID(transport.livekit_service_url);
|
||||
}
|
||||
if (isEndpointUnsupported(e)) {
|
||||
logger.error(
|
||||
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
|
||||
);
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport to which we are publishing and wish to delegate the delayed
|
||||
* leave event.
|
||||
*/
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
delayId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given transport, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the transport's SFU.
|
||||
*/
|
||||
export async function delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
transport,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
logger,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
// It's important that we use the same flow (CS API or OpenID) as whatever we
|
||||
// would've used to get a JWT token for this transport, since the participant
|
||||
// identity computed by the service needs to match.
|
||||
const flow = chooseAuthFlow(transport, "publisher");
|
||||
if (flow.kind === "csApi") {
|
||||
try {
|
||||
await csApi.delegateDelayedLeave({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isEndpointUnsupported(e))
|
||||
logger.error(
|
||||
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// This will technically cause the service to issue a new JWT token, but
|
||||
// it's safe to discard. We're only interested in triggering delegation.
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl: flow.serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role: "publisher",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
delayId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow with which the local member authenticates against a transport.
|
||||
*/
|
||||
export type AuthFlow =
|
||||
| { kind: "csApi"; url: string }
|
||||
| { kind: "openID"; serviceUrl: string };
|
||||
|
||||
/**
|
||||
* Chooses between the MSC4195 CS API and the legacy OpenID flow against the
|
||||
* transport's JWT service.
|
||||
*
|
||||
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
|
||||
* identity that clients predating MSC4195 expect from anyone advertising a
|
||||
* membership state event. So a publisher keeps using the JWT service wherever
|
||||
* the transport still offers one, even if it also has a `url`. Subscribers may
|
||||
* use the CS API regardless, as nobody matches their identity.
|
||||
* @param transport The transport to authenticate against.
|
||||
* @param role Whether we want to publish or only subscribe on the transport.
|
||||
*/
|
||||
export function chooseAuthFlow(
|
||||
transport: UnstableLivekitTransport,
|
||||
role: "publisher" | "subscriber",
|
||||
): AuthFlow {
|
||||
if ("url" in transport) {
|
||||
const useCsApi =
|
||||
!("livekit_service_url" in transport) || role === "subscriber";
|
||||
if (useCsApi) return { kind: "csApi", url: transport.url };
|
||||
}
|
||||
return { kind: "openID", serviceUrl: transport.livekit_service_url };
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
getSFUConfig,
|
||||
type GetSFUConfigParams,
|
||||
type ClientOpenIDParts,
|
||||
} from "./openID";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { FailToGetOpenIdToken } from "../../utils/errors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
const serviceUrl = "https://sfu.example.org";
|
||||
|
||||
const delayParams = {
|
||||
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||
delayId: "mock_delay_id",
|
||||
};
|
||||
|
||||
describe("getSFUConfig", () => {
|
||||
let matrixClient: MockedObject<ClientOpenIDParts>;
|
||||
let params: GetSFUConfigParams;
|
||||
beforeEach(() => {
|
||||
fetchMock.catch(404);
|
||||
matrixClient = { getOpenIdToken: vitest.fn() };
|
||||
params = {
|
||||
client: matrixClient,
|
||||
membership: ownMemberMock,
|
||||
serviceUrl: "https://sfu.example.org",
|
||||
roomId: "!example_room_id",
|
||||
slotId: "m.call#ROOM",
|
||||
role: "publisher",
|
||||
logger,
|
||||
};
|
||||
});
|
||||
afterEach(async () => {
|
||||
void (await fetchMock.flush());
|
||||
vitest.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
it("should handle fetching a token", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfig(params);
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if the SFU errors", async () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
await getSFUConfig(params);
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
expect.fail("Expected test to throw;");
|
||||
});
|
||||
|
||||
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||
let callCount = 0;
|
||||
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
callCount++;
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// First call: check if it has delay parts and return 400
|
||||
if (callCount === 1) {
|
||||
expect(body).toHaveProperty("delay_id", "mock_delay_id");
|
||||
return {
|
||||
status: 400,
|
||||
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
|
||||
};
|
||||
}
|
||||
|
||||
// Second call: check if delay parts were stripped and return success
|
||||
expect(body).not.toHaveProperty("delay_id");
|
||||
expect(body).not.toHaveProperty("delay_timeout");
|
||||
expect(body).not.toHaveProperty("delay_cs_api_url");
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Note: Assuming getSFUConfig eventually calls getLiveKitJWT
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// Verify, that the request contains the expected delay parameters
|
||||
if (
|
||||
body.delay_id === "mock_delay_id" &&
|
||||
body.delay_timeout === 3600000 &&
|
||||
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "Missing expected delay params" },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
// Prüfe das Ergebnis
|
||||
expect(config).toMatchObject({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use the default endpoint when subscribing", async () => {
|
||||
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
expect(body).toMatchObject({
|
||||
room_id: "!example_room_id",
|
||||
slot_id: "m.call#ROOM",
|
||||
member: {
|
||||
id: ownMemberMock.memberId,
|
||||
claimed_user_id: ownMemberMock.userId,
|
||||
claimed_device_id: ownMemberMock.deviceId,
|
||||
},
|
||||
});
|
||||
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
|
||||
});
|
||||
const config = await getSFUConfig({ ...params, role: "subscriber" });
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail when subscribing if the default endpoint is missing", async () => {
|
||||
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
|
||||
// must not fall back to the legacy endpoint, which would hand them a
|
||||
// legacy identity.
|
||||
await expect(
|
||||
getSFUConfig({ ...params, role: "subscriber" }),
|
||||
).rejects.toThrow(FailToGetOpenIdToken);
|
||||
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
|
||||
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||
});
|
||||
|
||||
it("should retry fetching the openid token", async () => {
|
||||
let count = 0;
|
||||
matrixClient.getOpenIdToken.mockImplementation(async () => {
|
||||
count++;
|
||||
if (count < 2) {
|
||||
throw Error("Test failure");
|
||||
}
|
||||
return Promise.resolve({
|
||||
token_type: "Bearer",
|
||||
access_token: "foobar",
|
||||
matrix_server_name: "example.org",
|
||||
expires_in: 30,
|
||||
});
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: serviceUrl, jwt: testJWTToken },
|
||||
};
|
||||
});
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
expect(config).toEqual({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
livekitIdentity: "@me:example.org:ABCDEF",
|
||||
livekitAlias: "!example_room_id",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
parseErrorResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { FailToGetOpenIdToken } from "../../utils/errors";
|
||||
import { doNetworkOperationWithRetry } from "../../utils/matrix";
|
||||
import { Config } from "../../config/Config";
|
||||
import { extractFullConfigFromToken, type SFUConfig } from "./types";
|
||||
|
||||
// The bits we need from MatrixClient
|
||||
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
client: ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The base URL of the LiveKit JWT service.
|
||||
*/
|
||||
serviceUrl: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on the {@link transport}.
|
||||
*/
|
||||
role: "publisher" | "subscriber";
|
||||
/**
|
||||
* The base URL of the Matrix homeserver, for delayed leave delegation.
|
||||
*/
|
||||
delayEndpointBaseUrl?: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated to the JWT service.
|
||||
*/
|
||||
delayId?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to the SFU behind a
|
||||
* given service URL, using the legacy OpenID flow.
|
||||
* @throws {@link FailToGetOpenIdToken}
|
||||
*/
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
delayEndpointBaseUrl,
|
||||
delayId,
|
||||
logger,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
let openIdToken: IOpenIDToken;
|
||||
try {
|
||||
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||
client.getOpenIdToken(),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new FailToGetOpenIdToken(
|
||||
error instanceof Error ? error : new Error("Unknown error"),
|
||||
);
|
||||
}
|
||||
logger.debug("Got openID token", openIdToken);
|
||||
let sfuConfig: { url: string; jwt: string } | undefined;
|
||||
|
||||
let endpoint: "default" | "legacy";
|
||||
if (role === "publisher") {
|
||||
// When publishing a legacy transport (one with a `livekit_service_url`),
|
||||
// subscribers will expect our participant identity to use the legacy
|
||||
// `@user_id:device_id` format. Only the legacy JWT service endpoint assigns
|
||||
// identities in this format, so we must use it.
|
||||
endpoint = "legacy";
|
||||
} else {
|
||||
// Use the default endpoint as it affords us slightly better metadata
|
||||
// protection (pseudonymous participant identities).
|
||||
endpoint = "default";
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
|
||||
);
|
||||
sfuConfig = await (
|
||||
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
|
||||
)(
|
||||
membership,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
openIdToken,
|
||||
delayEndpointBaseUrl,
|
||||
delayId,
|
||||
);
|
||||
logger.info(`Got JWT from call's active focus URL.`);
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (e) {
|
||||
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
|
||||
throw new FailToGetOpenIdToken(
|
||||
e instanceof Error ? e : new Error(`Unknown error ${e}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JWT token appropriate for connecting to the SFU behind a given service
|
||||
* URL, using the legacy `/sfu/get` endpoint, which assigns us a legacy
|
||||
* (`<user_id>:<device_id>`) participant identity.
|
||||
*/
|
||||
async function getLiveKitJWTLegacy(
|
||||
membership: CallMembershipIdentityParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
_slotId: string, // Unused - the legacy endpoint is from before slots existed.
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
interface IDelayParams {
|
||||
delay_id?: string;
|
||||
delay_timeout?: number;
|
||||
delay_cs_api_url?: string;
|
||||
}
|
||||
let bodyDalayParts: IDelayParams = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout:
|
||||
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
|
||||
return await fetch(livekitServiceURL + "/sfu/get", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
|
||||
// However, the livekit room alias is provided as part of the JWT payload.
|
||||
room: matrixRoomId,
|
||||
openid_token: openIDToken,
|
||||
device_id: membership.deviceId,
|
||||
...delayParts,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
let response = await makeRequest(bodyDalayParts);
|
||||
|
||||
// Old service compatibility check
|
||||
const oldServiceDoesNotSupportDelayParts =
|
||||
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
|
||||
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
|
||||
if (oldServiceDoesNotSupportDelayParts) {
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.errcode === "M_BAD_JSON") {
|
||||
response = await makeRequest({});
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the error, treat as real error
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
class NotSupportedError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NotSupported";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JWT token appropriate for connecting to the SFU behind a given service
|
||||
* URL, using the default `/get_token` endpoint, which assigns us a hashed
|
||||
* (pseudonymised) participant identity.
|
||||
*/
|
||||
async function getLiveKitJWT(
|
||||
membership: CallMembershipIdentityParts,
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
slotId: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
const { userId, deviceId, memberId } = membership;
|
||||
|
||||
const body = {
|
||||
room_id: matrixRoomId,
|
||||
slot_id: slotId,
|
||||
openid_token: openIDToken,
|
||||
member: {
|
||||
id: memberId,
|
||||
claimed_user_id: userId,
|
||||
claimed_device_id: deviceId,
|
||||
},
|
||||
};
|
||||
|
||||
let bodyDalayParts = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout:
|
||||
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
return await fetch(livekitServiceURL + "/get_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...body, ...bodyDalayParts }),
|
||||
});
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = "SFU Config fetch failed with status code " + res.status;
|
||||
if (res.status === 404) {
|
||||
throw new NotSupportedError(msg);
|
||||
} else {
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -1,90 +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 UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* Data identifying a transport and the homeserver to which it belongs, so
|
||||
* consumers know how to reach it.
|
||||
*/
|
||||
export interface TransportLocator {
|
||||
transport: UnstableLivekitTransport;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration and access tokens provided by the SFU on successful authentication.
|
||||
*/
|
||||
export interface SFUConfig {
|
||||
/**
|
||||
* The WebSocket URL of the LiveKit SFU. This is what we connect the LiveKit
|
||||
* room to. Note that this is NOT the JWT service URL
|
||||
* (`livekit_service_url`): the legacy JWT service returns this URL as part of
|
||||
* its response, while the CS-Api based flow requires us to know it upfront.
|
||||
*/
|
||||
url: string;
|
||||
jwt: string;
|
||||
livekitAlias: string;
|
||||
// NOTE: Currently unused.
|
||||
livekitIdentity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded details from the JWT.
|
||||
*/
|
||||
interface SFUJWTPayload {
|
||||
/**
|
||||
* Expiration time for the JWT.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
exp: number;
|
||||
/**
|
||||
* Name of the instance which authored the JWT
|
||||
*/
|
||||
iss: string;
|
||||
/**
|
||||
* Time at which the JWT can start to be used.
|
||||
* Note: This value is in seconds since Unix epoch.
|
||||
*/
|
||||
nbf: number;
|
||||
/**
|
||||
* Subject. The Livekit alias in this context.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* The set of permissions for the user.
|
||||
*/
|
||||
video: {
|
||||
canPublish: boolean;
|
||||
canSubscribe: boolean;
|
||||
room: string;
|
||||
roomJoin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complements the SFU websocket url and the JWT with the information encoded in
|
||||
* the JWT payload itself.
|
||||
* @param sfuConfig The SFU websocket url and the JWT to connect with.
|
||||
* @returns The full SFU config, including the LiveKit alias and identity.
|
||||
*/
|
||||
export function extractFullConfigFromToken(sfuConfig: {
|
||||
url: string;
|
||||
jwt: string;
|
||||
}): SFUConfig {
|
||||
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||
return {
|
||||
jwt: sfuConfig.jwt,
|
||||
url: sfuConfig.url,
|
||||
livekitAlias: payload.video.room,
|
||||
// NOTE: Currently unused.
|
||||
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
|
||||
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
|
||||
livekitIdentity: payload.sub,
|
||||
};
|
||||
}
|
||||
@@ -1,213 +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, it, vi } from "vitest";
|
||||
import { VideoPresets, type VideoPreset } from "livekit-client";
|
||||
|
||||
import { buildLiveKitOptions, getLiveKitOptions } from "./options";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("../config/Config", () => ({
|
||||
Config: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("buildLiveKitOptions", () => {
|
||||
it("returns sensible defaults with no config", () => {
|
||||
const opts = buildLiveKitOptions();
|
||||
expect(opts.adaptiveStream).toBe(true);
|
||||
expect(opts.dynacast).toBe(true);
|
||||
expect(opts.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h720.resolution,
|
||||
);
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("vp8");
|
||||
expect(opts.publishDefaults?.videoEncoding).toEqual({
|
||||
maxBitrate: 1_700_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 5_000_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
|
||||
VideoPresets.h180,
|
||||
VideoPresets.h360,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies video codec from config", () => {
|
||||
const opts = buildLiveKitOptions({ video_codec: "vp9" });
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("vp9");
|
||||
});
|
||||
|
||||
it("applies video resolution and encoding from config", () => {
|
||||
const baseVideoConfig = {
|
||||
max_resolution: 1080,
|
||||
max_bitrate: 3_000_000,
|
||||
max_framerate: 60,
|
||||
};
|
||||
const opts1080 = buildLiveKitOptions({
|
||||
video: baseVideoConfig,
|
||||
});
|
||||
const opts1440 = buildLiveKitOptions({
|
||||
video: { ...baseVideoConfig, max_resolution: 1440 },
|
||||
});
|
||||
const opts2160 = buildLiveKitOptions({
|
||||
video: { ...baseVideoConfig, max_resolution: 2160 },
|
||||
});
|
||||
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h1080.resolution,
|
||||
);
|
||||
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h1440.resolution,
|
||||
);
|
||||
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h2160.resolution,
|
||||
);
|
||||
expect(opts1080.publishDefaults?.videoEncoding).toEqual({
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies screen share encoding from config", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_bitrate: 8_000_000,
|
||||
max_framerate: 15,
|
||||
},
|
||||
});
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_resolution: 720,
|
||||
},
|
||||
});
|
||||
// Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 5_000_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps low resolutions to the closest preset, rounding up", () => {
|
||||
const expectations: [number, VideoPreset][] = [
|
||||
[180, VideoPresets.h180],
|
||||
[360, VideoPresets.h360],
|
||||
[480, VideoPresets.h540],
|
||||
[540, VideoPresets.h540],
|
||||
[720, VideoPresets.h720],
|
||||
];
|
||||
for (const [height, preset] of expectations) {
|
||||
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
|
||||
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
|
||||
}
|
||||
});
|
||||
|
||||
it("screen share layers fall back to max_framerate, then 30", () => {
|
||||
const fromMax = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_framerate: 15,
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||
).toMatchObject({ encoding: { maxFramerate: 15 } });
|
||||
|
||||
const fromDefault = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||
).toMatchObject({ encoding: { maxFramerate: 30 } });
|
||||
});
|
||||
|
||||
it("applies custom video simulcast layers", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
video: {
|
||||
simulcast_layers: [
|
||||
{ height: 180, bitrate: 100_000 },
|
||||
{ height: 360, bitrate: 300_000 },
|
||||
{ height: 540, bitrate: 600_000 },
|
||||
],
|
||||
max_framerate: 24,
|
||||
},
|
||||
});
|
||||
const layers = opts.publishDefaults?.videoSimulcastLayers;
|
||||
expect(layers).toHaveLength(3);
|
||||
expect(layers?.[0]).toMatchObject({
|
||||
width: 320,
|
||||
height: 180,
|
||||
encoding: { maxBitrate: 100_000, maxFramerate: 24 },
|
||||
});
|
||||
expect(layers?.[2]).toMatchObject({
|
||||
width: 960,
|
||||
height: 540,
|
||||
encoding: { maxBitrate: 600_000, maxFramerate: 24 },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom screen share simulcast layers", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
|
||||
},
|
||||
});
|
||||
const layers = opts.publishDefaults?.screenShareSimulcastLayers;
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers?.[0]).toMatchObject({
|
||||
width: 960,
|
||||
height: 540,
|
||||
encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include screenShareSimulcastLayers when not configured", () => {
|
||||
const opts = buildLiveKitOptions();
|
||||
expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backupCodec always uses stock VP8 720p encoding", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
video_codec: "av1",
|
||||
video: { max_bitrate: 10_000_000, max_framerate: 60 },
|
||||
});
|
||||
const backup = opts.publishDefaults?.backupCodec as {
|
||||
codec: string;
|
||||
encoding: { maxBitrate: number; maxFramerate: number };
|
||||
};
|
||||
expect(backup.codec).toBe("vp8");
|
||||
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLiveKitOptions", () => {
|
||||
it("reads from Config singleton", () => {
|
||||
vi.mocked(Config.get).mockReturnValue({
|
||||
media_quality: { video_codec: "h264" },
|
||||
} as ReturnType<typeof Config.get>);
|
||||
const opts = getLiveKitOptions();
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("h264");
|
||||
});
|
||||
|
||||
it("throws when Config is not initialized", () => {
|
||||
vi.mocked(Config.get).mockImplementation(() => {
|
||||
throw new Error("Config not initialized");
|
||||
});
|
||||
expect(() => getLiveKitOptions()).toThrow("Config not initialized");
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
AudioPresets,
|
||||
DefaultReconnectPolicy,
|
||||
type RoomOptions,
|
||||
type TrackPublishDefaults,
|
||||
type VideoPreset,
|
||||
VideoPresets,
|
||||
VideoPreset as VideoPresetClass,
|
||||
} from "livekit-client";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
|
||||
|
||||
/**
|
||||
* Find the closest matching VideoPreset for a given height.
|
||||
*/
|
||||
function videoPresetForHeight(height: number): VideoPreset {
|
||||
if (height <= 180) return VideoPresets.h180;
|
||||
if (height <= 360) return VideoPresets.h360;
|
||||
if (height <= 540) return VideoPresets.h540;
|
||||
if (height <= 720) return VideoPresets.h720;
|
||||
if (height <= 1080) return VideoPresets.h1080;
|
||||
if (height <= 1440) return VideoPresets.h1440;
|
||||
return VideoPresets.h2160;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LiveKit publish options from config, falling back to sensible defaults.
|
||||
*/
|
||||
function buildPublishOptions(
|
||||
mediaQuality: ConfigOptions["media_quality"],
|
||||
): TrackPublishDefaults {
|
||||
const defaults = DEFAULT_CONFIG.media_quality;
|
||||
const videoConf = mediaQuality?.video;
|
||||
const screenConf = mediaQuality?.screen_share;
|
||||
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
|
||||
|
||||
// Camera video encoding
|
||||
const videoEncoding = {
|
||||
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
|
||||
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||
};
|
||||
|
||||
// Camera simulcast layers
|
||||
let videoSimulcastLayers: VideoPreset[];
|
||||
if (videoConf?.simulcast_layers) {
|
||||
videoSimulcastLayers = videoConf.simulcast_layers.map(
|
||||
(layer) =>
|
||||
new VideoPresetClass(
|
||||
Math.round((layer.height * 16) / 9),
|
||||
layer.height,
|
||||
layer.bitrate,
|
||||
videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
|
||||
}
|
||||
|
||||
// Screen share encoding
|
||||
const screenShareEncoding = {
|
||||
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
|
||||
maxFramerate:
|
||||
screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
|
||||
};
|
||||
|
||||
// Screen share simulcast layers
|
||||
let screenShareSimulcastLayers: VideoPreset[] | undefined;
|
||||
if (screenConf?.simulcast_layers) {
|
||||
screenShareSimulcastLayers = screenConf.simulcast_layers.map(
|
||||
(layer) =>
|
||||
new VideoPresetClass(
|
||||
Math.round((layer.height * 16) / 9),
|
||||
layer.height,
|
||||
layer.bitrate,
|
||||
layer.framerate ?? screenConf?.max_framerate ?? 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: true,
|
||||
// disable red because the livekit server strips out red packets for clients
|
||||
// that don't support it (firefox) but of course that doesn't work with e2ee.
|
||||
red: false,
|
||||
forceStereo: false,
|
||||
simulcast: true,
|
||||
videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
|
||||
screenShareEncoding,
|
||||
...(screenShareSimulcastLayers && {
|
||||
screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
|
||||
}),
|
||||
stopMicTrackOnMute: false,
|
||||
videoCodec: codec,
|
||||
videoEncoding,
|
||||
backupCodec: {
|
||||
codec: "vp8",
|
||||
encoding: VideoPresets.h720.encoding,
|
||||
},
|
||||
} as TrackPublishDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LiveKit RoomOptions from config.
|
||||
* Call this after Config.init() has resolved.
|
||||
*/
|
||||
export function buildLiveKitOptions(
|
||||
mediaQuality?: ConfigOptions["media_quality"],
|
||||
): RoomOptions {
|
||||
const videoHeight =
|
||||
mediaQuality?.video?.max_resolution ??
|
||||
DEFAULT_CONFIG.media_quality.video.max_resolution;
|
||||
const basePreset = videoPresetForHeight(videoHeight);
|
||||
|
||||
return {
|
||||
// automatically manage subscribed video quality
|
||||
adaptiveStream: true,
|
||||
|
||||
// optimize publishing bandwidth and CPU for published tracks
|
||||
dynacast: true,
|
||||
|
||||
// capture settings
|
||||
videoCaptureDefaults: {
|
||||
resolution: basePreset.resolution,
|
||||
},
|
||||
|
||||
// publish settings
|
||||
publishDefaults: buildPublishOptions(mediaQuality),
|
||||
|
||||
// default LiveKit options that seem to be sane
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||
disconnectOnPageLeave: true,
|
||||
webAudioMix: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiveKit options, reading from the loaded Config singleton.
|
||||
* Requires Config.init() to have resolved first.
|
||||
*/
|
||||
export function getLiveKitOptions(): RoomOptions {
|
||||
return buildLiveKitOptions(Config.get().media_quality);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vitest.mock("livekit-client/e2ee-worker?worker");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
} from "../HostBridge";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
|
||||
@@ -15,22 +15,19 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { act, render, type RenderResult } from "@testing-library/react";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { ActiveCall, InCallView } from "./InCallView";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
type MockRTCSession,
|
||||
mockRTCSlot,
|
||||
mockRTCMember,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -41,15 +38,17 @@ import {
|
||||
type CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
} from "../state/CallViewModel/CallViewModel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import { alice, local, aliceRtcMember } from "../utils/test-fixtures";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer";
|
||||
import type * as MatrixRTCSdk from "@element-hq/matrixrtc-sdk";
|
||||
import { constant, type ObservableScope } from "@element-hq/matrixrtc-sdk";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import { initializeWidget } from "../widget";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
|
||||
@@ -68,21 +67,19 @@ vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
vi.mock("../tile/GridTile");
|
||||
vi.mock("../tile/SpotlightTile");
|
||||
vi.mock("@livekit/components-react");
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
vi.mock("../e2ee/sharedKeyManagement");
|
||||
vi.mock("../livekit/MatrixAudioRenderer");
|
||||
vi.mock("../tracks/MemberAudioRenderer");
|
||||
// ActiveCall builds the slot itself; the view is what these tests are about
|
||||
const createRTCSlotMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@element-hq/matrixrtc-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixRTCSdk>()),
|
||||
createRTCSlot: createRTCSlotMock,
|
||||
}));
|
||||
vi.mock("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
}));
|
||||
|
||||
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
const localParticipant = mockLocalParticipant({
|
||||
identity: "@local:example.org:AAAAAA",
|
||||
});
|
||||
const remoteParticipant = mockRemoteParticipant({
|
||||
identity: "@alice:example.org:AAAAAA",
|
||||
});
|
||||
|
||||
const matrixInfo = {
|
||||
userId: "",
|
||||
@@ -100,21 +97,26 @@ let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// MatrixAudioRenderer is tested separately.
|
||||
(
|
||||
LivekitRoomAudioRenderer as MockedFunction<typeof LivekitRoomAudioRenderer>
|
||||
).mockImplementation((_props) => {
|
||||
return <div>mocked: MatrixAudioRenderer</div>;
|
||||
});
|
||||
(
|
||||
useLocalParticipant as MockedFunction<typeof useLocalParticipant>
|
||||
).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isScreenShareEnabled: false,
|
||||
localParticipant: localRtcMember as unknown as LocalParticipant,
|
||||
}) as unknown as ReturnType<typeof useLocalParticipant>,
|
||||
createRTCSlotMock.mockImplementation((scope: ObservableScope) =>
|
||||
mockRTCSlot(scope, {
|
||||
localMember$: constant(
|
||||
mockRTCMember(true, {
|
||||
membership: localRtcMember,
|
||||
tracks$: constant([]),
|
||||
}),
|
||||
),
|
||||
remoteMembers$: constant([
|
||||
mockRTCMember(false, {
|
||||
membership: aliceRtcMember,
|
||||
tracks$: constant([]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
// MemberAudioRenderer is tested separately.
|
||||
(
|
||||
MemberAudioRenderer as MockedFunction<typeof MemberAudioRenderer>
|
||||
).mockImplementation(() => <div>mocked: MemberAudioRenderer</div>);
|
||||
useRoomEncryptionSystemMock =
|
||||
useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock;
|
||||
useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE });
|
||||
@@ -131,14 +133,6 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
} {
|
||||
const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
|
||||
const muteState = mockMuteStates();
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{
|
||||
localParticipant,
|
||||
},
|
||||
{
|
||||
remoteParticipants$: of([remoteParticipant]),
|
||||
},
|
||||
);
|
||||
const { vm, footerVm, developerSettingsVm, rtcSession } =
|
||||
getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
@@ -174,9 +168,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>{content}</RoomContext>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>{content}</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
@@ -244,18 +236,16 @@ describe("ActiveCall", () => {
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={mockLivekitRoom({ localParticipant })}>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</RoomContext>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
@@ -294,18 +284,16 @@ describe("ActiveCall", () => {
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={mockLivekitRoom({ localParticipant })}>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</RoomContext>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
|
||||
+164
-47
@@ -42,11 +42,16 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
type CallViewModel,
|
||||
callViewModelOptionsFromParams,
|
||||
type CaptureSettings,
|
||||
createCallViewModel$,
|
||||
initialPublishRequests,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
type EncryptionSystem,
|
||||
type MatrixRTCMode,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||
@@ -63,9 +68,35 @@ import {
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
|
||||
import {
|
||||
createRTCSlot,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlotOptions,
|
||||
constant,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
import {
|
||||
advancedCamera,
|
||||
advancedScreenShare,
|
||||
autoGainControlSetting,
|
||||
cameraBitrate,
|
||||
cameraCodec,
|
||||
cameraFramerate,
|
||||
cameraResolution,
|
||||
customTransport,
|
||||
echoCancellationSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
noiseSuppressionSetting,
|
||||
parseResolution,
|
||||
screenShareBitrate,
|
||||
screenShareCodec,
|
||||
screenShareFramerate,
|
||||
screenShareResolution,
|
||||
} from "../settings/settings";
|
||||
import { Config } from "../config/Config";
|
||||
import { createSentCallNotification$ } from "../state/CallViewModel/CallNotificationLifecycle.ts";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
@@ -76,10 +107,9 @@ import {
|
||||
} 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 { useTrackProcessorState$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { useTrackProcessorState$ } from "../tracks/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
@@ -100,7 +130,7 @@ declare module "react" {
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
|
||||
"vm" | "footerVm" | "developerSettingsVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
// TODO refactor those reasons into an enum
|
||||
@@ -131,29 +161,43 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
urlParams;
|
||||
|
||||
const capture = captureSettings();
|
||||
const slot = createRTCSlot(
|
||||
scope,
|
||||
props.client,
|
||||
props.matrixRoom,
|
||||
rtcSlotOptions(props.e2eeSystem),
|
||||
);
|
||||
const rtcParticipation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
|
||||
});
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
rtcParticipation,
|
||||
props.matrixRoom,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
videoProcessor$: scope.behavior(
|
||||
trackProcessorState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
// We merely sample the current mode here, so the user would need to
|
||||
// manually rejoin to switch to a different one.
|
||||
matrixRTCMode: matrixRTCModeSetting.value$.value,
|
||||
sentCallNotification$: createSentCallNotification$(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
),
|
||||
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
trackProcessorState$,
|
||||
);
|
||||
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||
vm.join();
|
||||
setVm(vm);
|
||||
|
||||
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
||||
@@ -250,16 +294,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
// TODO-MULTI-SFU This is unused now??
|
||||
// const connectionState = useBehavior(vm.livekitConnectionState$);
|
||||
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
// This needs to be done differential. with the vm connection state we start with Disconnected.
|
||||
// TODO-MULTI-SFU decide how to handle this properly
|
||||
// @BillCarsonFr
|
||||
// if (connectionState === ConnectionState.Disconnected)
|
||||
// throw new ConnectionLostError();
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure();
|
||||
@@ -296,7 +330,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const ringingVm = useBehavior(vm.ringingVm$);
|
||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||
const remoteMembers = useBehavior(vm.remoteMembers$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const screenShareError = useBehavior(vm.screenShareError$);
|
||||
@@ -646,7 +680,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const footer = footerVm !== null && (
|
||||
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
|
||||
);
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
const connectedTransports = useBehavior(vm.connectedTransports$);
|
||||
const localMember = useBehavior(vm.localMember$);
|
||||
const localTransport = useBehavior(
|
||||
localMember?.transport$ ?? constant(undefined),
|
||||
);
|
||||
|
||||
return (
|
||||
// The pointer handler here exists to control the visibility of the footer,
|
||||
@@ -665,15 +703,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
{header}
|
||||
{audioParticipants.map(({ livekitRoom, transport, participants }) => (
|
||||
<LivekitRoomAudioRenderer
|
||||
key={JSON.stringify(transport)}
|
||||
transport={transport}
|
||||
livekitRoom={livekitRoom}
|
||||
validIdentities={participants}
|
||||
muted={muteAllAudio}
|
||||
/>
|
||||
))}
|
||||
<MemberAudioRenderer members={remoteMembers} muted={muteAllAudio} />
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
@@ -694,22 +724,109 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
livekitRooms={allConnections.map(
|
||||
({ livekitRoom: room, livekitAlias, transport, role }) => ({
|
||||
room,
|
||||
livekitAlias,
|
||||
role,
|
||||
url:
|
||||
// It should be relatively clear to the user which kind of URL
|
||||
// this is, based on the scheme (ws:// vs. http://)
|
||||
"url" in transport
|
||||
? transport.url
|
||||
: transport.livekit_service_url,
|
||||
}),
|
||||
)}
|
||||
transports={connectedTransports.map((transport) => ({
|
||||
transport,
|
||||
local: transport === localTransport,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* How the tracks are captured and encoded, from Element Call's settings and
|
||||
* configuration. Shared with the legacy SDK bundle so that a widget built from
|
||||
* it behaves like the app.
|
||||
*/
|
||||
export function captureSettings(): CaptureSettings {
|
||||
const config = Config.get();
|
||||
const cameraSettings = advancedCamera.getValue()
|
||||
? {
|
||||
resolution: {
|
||||
...parseResolution(cameraResolution.getValue()),
|
||||
frameRate: cameraFramerate.getValue(),
|
||||
},
|
||||
maxBitrate: cameraBitrate.getValue(),
|
||||
maxFramerate: cameraFramerate.getValue(),
|
||||
codec: cameraCodec.getValue(),
|
||||
}
|
||||
: undefined;
|
||||
const screenConfig = config.media_quality?.screen_share;
|
||||
const screenShareSettings = advancedScreenShare.getValue()
|
||||
? {
|
||||
resolution: {
|
||||
...parseResolution(screenShareResolution.getValue()),
|
||||
frameRate: screenShareFramerate.getValue(),
|
||||
},
|
||||
maxBitrate: screenShareBitrate.getValue(),
|
||||
maxFramerate: screenShareFramerate.getValue(),
|
||||
codec: screenShareCodec.getValue(),
|
||||
}
|
||||
: screenConfig?.max_resolution
|
||||
? {
|
||||
resolution: {
|
||||
width: Math.round((screenConfig.max_resolution * 16) / 9),
|
||||
height: screenConfig.max_resolution,
|
||||
frameRate: screenConfig.max_framerate ?? 30,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
audio: {
|
||||
echoCancellation: echoCancellationSetting.getValue(),
|
||||
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||
autoGainControl: autoGainControlSetting.getValue(),
|
||||
},
|
||||
camera: cameraSettings,
|
||||
screenShare: screenShareSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the client needs from Element Call's configuration, settings and URL:
|
||||
* the SDK reads none of them itself. What to publish depends on the mute
|
||||
* switches and the devices, so the caller adds that. Shared with the legacy
|
||||
* SDK bundle so that a widget built from it behaves like the app.
|
||||
*/
|
||||
export function rtcSlotOptions(
|
||||
encryptionSystem: EncryptionSystem,
|
||||
): RTCSlotOptions {
|
||||
return {
|
||||
encryptionSystem,
|
||||
// matrix_rtc_mode in config.json overrides the user's Developer Settings
|
||||
// choice. We merely sample the current mode here, so the user would need
|
||||
// to manually rejoin to switch to a different one.
|
||||
matrixRTCMode:
|
||||
(Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
|
||||
matrixRTCModeSetting.value$.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function rtcParticipationOptions(
|
||||
urlParams: ReturnType<typeof useUrlParams>,
|
||||
): Omit<RTCParticipationOptions, "publish"> {
|
||||
const config = Config.get();
|
||||
const session = config.matrix_rtc_session;
|
||||
return {
|
||||
sendNotificationType: urlParams.sendNotificationType,
|
||||
applicationData: urlParams.callIntent
|
||||
? { "m.call.intent": urlParams.callIntent }
|
||||
: undefined,
|
||||
timings: {
|
||||
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
||||
networkErrorRetryMs: session.network_error_retry_ms,
|
||||
membershipEventExpiryMs: session.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit: session.key_rotation_participant_limit,
|
||||
delayedLeave: session.delayed_leave,
|
||||
delegatedDelayedLeave: session.delegated_delayed_leave,
|
||||
},
|
||||
mediaQuality: config.media_quality,
|
||||
transport: customTransport.value$.value ?? undefined,
|
||||
fallbackTransport: config.livekit && {
|
||||
type: "livekit",
|
||||
livekit_service_url: config.livekit.livekit_service_url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
|
||||
import { KnockLobbyView } from "./KnockLobbyView";
|
||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
import { type ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
}));
|
||||
|
||||
vi.mock("../livekit/TrackProcessorContext", () => ({
|
||||
vi.mock("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "../utils/test";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
import { type ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import lobbyStyles from "./LobbyView.module.css";
|
||||
import headerStyles from "../Header.module.css";
|
||||
@@ -41,7 +41,7 @@ vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
}));
|
||||
|
||||
vi.mock("../livekit/TrackProcessorContext", () => ({
|
||||
vi.mock("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { convertToLivekitProcessor } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -42,7 +43,7 @@ import { useInitial } from "../useInitial";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "../livekit/TrackProcessorContext";
|
||||
} from "../tracks/TrackProcessorContext";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
|
||||
@@ -150,7 +151,8 @@ export const LobbyView: FC<Props> = ({
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
video: videoEnabled && {
|
||||
deviceId: videoInputId,
|
||||
processor: initialProcessor,
|
||||
processor:
|
||||
initialProcessor && convertToLivekitProcessor(initialProcessor),
|
||||
},
|
||||
}),
|
||||
[initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
|
||||
|
||||
@@ -48,7 +48,6 @@ function TestComponent({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
vitest.mock("livekit-client/e2ee-worker?worker");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, afterEach, vi } from "vitest";
|
||||
import { expect, test, afterEach } from "vitest";
|
||||
import { act } from "react";
|
||||
|
||||
import { showReactions } from "../settings/settings";
|
||||
@@ -22,8 +22,6 @@ import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
afterEach(() => {
|
||||
showReactions.setValue(showReactions.defaultValue);
|
||||
});
|
||||
|
||||
@@ -84,6 +84,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div>
|
||||
mocked: MemberAudioRenderer
|
||||
</div>
|
||||
<div
|
||||
class="_scrollingGrid_4e7ff8 _grid_b0d1cd"
|
||||
>
|
||||
|
||||
@@ -11,10 +11,10 @@ import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
type TransportInfo,
|
||||
} from "./DeveloperSettingsTab";
|
||||
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
|
||||
import { createStaticViewModel } from "../state/ViewModel";
|
||||
@@ -23,9 +23,13 @@ import {
|
||||
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
} from "./settings";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
import {
|
||||
authenticateWithTransport,
|
||||
constant,
|
||||
MatrixRTCMode,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import type * as MatrixRTCSdk from "@element-hq/matrixrtc-sdk";
|
||||
import { exampleTransport, mockConfig } from "../utils/test";
|
||||
import { getSFUConfig } from "../livekit/auth";
|
||||
|
||||
// Mock url params hook to avoid environment-dependent snapshot churn.
|
||||
vi.mock("../UrlParams", () => ({
|
||||
@@ -36,32 +40,30 @@ vi.mock("../UrlParams", () => ({
|
||||
}));
|
||||
|
||||
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
|
||||
vi.mock("../livekit/auth", () => ({
|
||||
getSFUConfig: vi.fn().mockResolvedValue({
|
||||
vi.mock("@element-hq/matrixrtc-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixRTCSdk>()),
|
||||
authenticateWithTransport: vi.fn().mockResolvedValue({
|
||||
url: "mock-url",
|
||||
jwt: "mock-jwt",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Provide a minimal mock of a Livekit Room structure used by the component.
|
||||
function createMockLivekitRoom(
|
||||
wsUrl: string,
|
||||
serverInfo: object,
|
||||
metadata: string,
|
||||
): { isLocal: boolean; url: string; room: LivekitRoom; livekitAlias: string } {
|
||||
const mockRoom = {
|
||||
serverInfo,
|
||||
metadata,
|
||||
engine: { client: { ws: { url: wsUrl } } },
|
||||
localParticipant: { identity: "localParticipantIdentity" },
|
||||
remoteParticipants: new Map(),
|
||||
} as unknown as LivekitRoom;
|
||||
|
||||
/** A transport the panel shows, as the client would hand it out. */
|
||||
function mockTransport(
|
||||
url: string,
|
||||
local: boolean,
|
||||
resolved?: { url: string; roomAlias: string; identity: string },
|
||||
): TransportInfo {
|
||||
return {
|
||||
isLocal: true,
|
||||
url: wsUrl,
|
||||
room: mockRoom,
|
||||
livekitAlias: "TestAlias",
|
||||
local,
|
||||
transport: {
|
||||
type: "livekit",
|
||||
id: url,
|
||||
raw: { type: "livekit", livekit_service_url: url },
|
||||
resolved$: constant(
|
||||
resolved && { type: "livekit", token: "secret", ...resolved },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,36 +98,20 @@ describe("DeveloperSettingsTab", () => {
|
||||
it("renders and matches snapshot", async () => {
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
const livekitRooms: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
isLocal?: boolean;
|
||||
livekitAlias: string;
|
||||
}[] = [
|
||||
createMockLivekitRoom(
|
||||
"wss://local-sfu.example.org",
|
||||
{ region: "local", version: "1.2.3" },
|
||||
"local-metadata",
|
||||
),
|
||||
{
|
||||
isLocal: false,
|
||||
livekitAlias: "TestAlias2",
|
||||
url: "wss://remote-sfu.example.org",
|
||||
room: {
|
||||
localParticipant: { identity: "localParticipantIdentity" },
|
||||
remoteParticipants: new Map(),
|
||||
serverInfo: { region: "remote", version: "4.5.6" },
|
||||
metadata: "remote-metadata",
|
||||
engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } },
|
||||
} as unknown as LivekitRoom,
|
||||
},
|
||||
const transports = [
|
||||
mockTransport("https://local-sfu.example.org/jwt", true, {
|
||||
url: "wss://local-sfu.example.org",
|
||||
roomAlias: "TestAlias",
|
||||
identity: "localParticipantIdentity",
|
||||
}),
|
||||
mockTransport("https://remote-sfu.example.org/jwt", false),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId={"#room:example.org"}
|
||||
livekitRooms={livekitRooms}
|
||||
transports={transports}
|
||||
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>,
|
||||
@@ -169,7 +155,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(authenticateWithTransport).not.toHaveBeenCalled();
|
||||
|
||||
expect(customTransportSetting.getValue()).toBe(null);
|
||||
});
|
||||
@@ -192,7 +178,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(authenticateWithTransport).not.toHaveBeenCalled();
|
||||
|
||||
expect(customTransportSetting.getValue()).toBe(null);
|
||||
});
|
||||
@@ -218,7 +204,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
name: "Reset overwrite",
|
||||
});
|
||||
await user.click(cancelButton);
|
||||
expect(getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(authenticateWithTransport).not.toHaveBeenCalled();
|
||||
|
||||
expect(customTransportSetting.getValue()).toBe(null);
|
||||
});
|
||||
@@ -245,10 +231,12 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfig).toHaveBeenCalledWith(
|
||||
expect(authenticateWithTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
transport: { type: "livekit", url: "wss://example.livekit.valid" },
|
||||
serverName: "hs",
|
||||
roomId: "#testRoom",
|
||||
role: "publisher",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -279,7 +267,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
(getSFUConfig as Mock).mockImplementation(() => {
|
||||
(authenticateWithTransport as Mock).mockImplementation(() => {
|
||||
throw new Error("Failed to get SFU config");
|
||||
});
|
||||
await user.click(saveButton);
|
||||
@@ -290,9 +278,6 @@ describe("DeveloperSettingsTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Add this test inside the describe("DeveloperSettingsTab", () => { block,
|
||||
// after the custom transport tests:
|
||||
|
||||
describe("enable extended livekit logs", () => {
|
||||
afterEach(() => {
|
||||
enableExtendedLivekitLogsSetting.setValue(false);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
EditInPlace,
|
||||
ErrorMessage,
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
RadioControl,
|
||||
Separator,
|
||||
} from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -67,14 +66,23 @@ import styles from "./DeveloperSettingsTab.module.css";
|
||||
import settingsStyles from "./SettingsModal.module.css";
|
||||
import { Slider } from "../Slider";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import {
|
||||
authenticateWithTransport,
|
||||
type TransportMetadata,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { getSFUConfig } from "../livekit/auth/index.ts";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* The state of MatrixRTC's media key rotation.
|
||||
*/
|
||||
/** One connected transport, as the panel shows it. */
|
||||
export interface TransportInfo {
|
||||
transport: TransportMetadata;
|
||||
/** Whether this is the transport the local member publishes on. */
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export interface KeyRotationInfo {
|
||||
/** Whether the call is large enough that MatrixRTC has stopped rotating the media key. */
|
||||
suppressed: boolean;
|
||||
@@ -106,19 +114,15 @@ const KeyRotationStatus: FC<{ info: KeyRotationInfo }> = ({ info }) => (
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
isLocal?: boolean;
|
||||
livekitAlias?: string;
|
||||
}[];
|
||||
/** The transports the client is connected to, while in a call. */
|
||||
transports?: TransportInfo[];
|
||||
env: ImportMetaEnv;
|
||||
vm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
}
|
||||
|
||||
export const DeveloperSettingsTab: FC<Props> = ({
|
||||
client,
|
||||
livekitRooms,
|
||||
transports,
|
||||
roomId,
|
||||
env,
|
||||
vm,
|
||||
@@ -168,34 +172,23 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
enableExtendedLivekitLogsSetting,
|
||||
);
|
||||
|
||||
const [customTransportUpdateError, setCustomLivekitUrlUpdateError] = useState<
|
||||
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [customTransport, setCustomTransport] = useSetting(
|
||||
customTransportSetting,
|
||||
);
|
||||
const [customTransportTextBuffer, setCustomLivekitUrlTextBuffer] = useState(
|
||||
const [customTransportTextBuffer, setCustomTransportTextBuffer] = useState(
|
||||
() => transportText(customTransport),
|
||||
);
|
||||
useEffect(() => {
|
||||
setCustomLivekitUrlTextBuffer(transportText(customTransport));
|
||||
setCustomTransportTextBuffer(transportText(customTransport));
|
||||
}, [customTransport]);
|
||||
|
||||
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
|
||||
|
||||
const urlParams = useUrlParams();
|
||||
|
||||
const localSfuUrl = useMemo((): URL | null => {
|
||||
const localRoom = livekitRooms?.find((r) => r.isLocal)?.room;
|
||||
if (localRoom?.engine.client.ws?.url) {
|
||||
// strip the URL params
|
||||
const url = new URL(localRoom.engine.client.ws.url);
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}, [livekitRooms]);
|
||||
|
||||
const MediaQualitySettings: React.FC<{
|
||||
id: string;
|
||||
header: string;
|
||||
@@ -505,11 +498,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
cancelButtonLabel={t("developer_mode.custom_transport.reset")}
|
||||
onSave={useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
if (
|
||||
roomId === undefined ||
|
||||
customTransportTextBuffer === "" ||
|
||||
customTransportTextBuffer === null
|
||||
) {
|
||||
if (roomId === undefined || customTransportTextBuffer === "") {
|
||||
setCustomTransport(null);
|
||||
return;
|
||||
}
|
||||
@@ -517,38 +506,39 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
try {
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
const serverName = client.getDomain();
|
||||
|
||||
if (userId === null || deviceId === null) {
|
||||
throw new Error("Invalid user or device ID");
|
||||
if (userId === null || deviceId === null || serverName === null) {
|
||||
throw new Error("Invalid user, device ID or server name");
|
||||
}
|
||||
const transport = JSON.parse(customTransportTextBuffer);
|
||||
// Validate the transport by attempting to get a token for it
|
||||
await getSFUConfig({
|
||||
await authenticateWithTransport({
|
||||
client,
|
||||
membership: { userId, deviceId, memberId: "" },
|
||||
transport,
|
||||
serverName: client.getDomain()!,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId: "org.example.fake-slot#room",
|
||||
role: "publisher",
|
||||
logger,
|
||||
});
|
||||
setCustomLivekitUrlUpdateError(null);
|
||||
setCustomTransportUpdateError(null);
|
||||
setCustomTransport(transport);
|
||||
} catch {
|
||||
setCustomLivekitUrlUpdateError(
|
||||
setCustomTransportUpdateError(
|
||||
"invalid transport (did not update)",
|
||||
);
|
||||
}
|
||||
},
|
||||
[customTransportTextBuffer, setCustomTransport, client, roomId],
|
||||
)}
|
||||
value={customTransportTextBuffer ?? ""}
|
||||
value={customTransportTextBuffer}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCustomLivekitUrlTextBuffer(event.target.value);
|
||||
setCustomTransportTextBuffer(event.target.value);
|
||||
},
|
||||
[setCustomLivekitUrlTextBuffer],
|
||||
[setCustomTransportTextBuffer],
|
||||
)}
|
||||
onCancel={useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -601,39 +591,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
</HelpMessage>
|
||||
</InlineField>
|
||||
</Form>
|
||||
{livekitRooms?.map((livekitRoom) => (
|
||||
<div className={styles.livekit_room_box}>
|
||||
<h4>
|
||||
{t("developer_mode.livekit_sfu", {
|
||||
url: livekitRoom.url || "unknown",
|
||||
})}
|
||||
</h4>
|
||||
<p>LivekitAlias: {livekitRoom.livekitAlias}</p>
|
||||
<p>connectionState (wont hot reload): {livekitRoom.room.state}</p>
|
||||
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}(
|
||||
{livekitRoom.isLocal ? "local" : "remote"})
|
||||
</p>
|
||||
<pre className={styles.pre}>
|
||||
{livekitRoom.room.serverInfo
|
||||
? JSON.stringify(livekitRoom.room.serverInfo, null, 2)
|
||||
: "undefined"}
|
||||
{livekitRoom.room.metadata}
|
||||
</pre>
|
||||
<p>Local Participant</p>
|
||||
<pre className={styles.pre}>
|
||||
{livekitRoom.room.localParticipant.identity}
|
||||
</pre>
|
||||
<p>Remote Participants</p>
|
||||
<ul>
|
||||
{Array.from(livekitRoom.room.remoteParticipants.keys()).map(
|
||||
(id) => (
|
||||
<li key={id}>{id}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{transports?.map(({ transport, local }) => (
|
||||
<TransportBox key={transport.id} transport={transport} local={local} />
|
||||
))}
|
||||
<Separator />
|
||||
<MediaQualitySettings
|
||||
@@ -698,9 +657,29 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The text shown in the custom transport field for a given setting value.
|
||||
*/
|
||||
/** The text shown in the custom transport field for a given setting value. */
|
||||
function transportText(transport: UnstableLivekitTransport | null): string {
|
||||
return transport === null ? "" : JSON.stringify(transport);
|
||||
}
|
||||
|
||||
const TransportBox: FC<TransportInfo> = ({ transport, local }) => {
|
||||
const { t } = useTranslation();
|
||||
const resolved = useBehavior(transport.resolved$);
|
||||
return (
|
||||
<div className={styles.livekit_room_box}>
|
||||
<h4>{t("developer_mode.livekit_sfu", { url: transport.id })}</h4>
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}({local ? "local" : "remote"})
|
||||
</p>
|
||||
<pre className={styles.pre}>
|
||||
{resolved === undefined
|
||||
? "not connected"
|
||||
: JSON.stringify(
|
||||
{ ...resolved, token: resolved.token && "<redacted>" },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type FC, type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
@@ -28,10 +27,11 @@ import {
|
||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||
import { useTrackProcessor } from "../tracks/TrackProcessorContext";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
type TransportInfo,
|
||||
} from "./DeveloperSettingsTab";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { useSubmitRageshake } from "./submit-rageshake";
|
||||
@@ -56,11 +56,8 @@ interface Props {
|
||||
onTabChange: (tab: SettingsTab) => void;
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
role: "publisher" | "subscriber";
|
||||
}[];
|
||||
/** The transports the client is connected to, while in a call. */
|
||||
transports?: TransportInfo[];
|
||||
/** Only available while in a call. Used by the developer tab. */
|
||||
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
|
||||
}
|
||||
@@ -74,7 +71,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
onTabChange,
|
||||
client,
|
||||
roomId,
|
||||
livekitRooms,
|
||||
transports,
|
||||
developerSettingsVm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -227,7 +224,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
<DeveloperSettingsTab
|
||||
env={import.meta.env}
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
transports={transports}
|
||||
roomId={roomId}
|
||||
vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
|
||||
@@ -361,19 +361,8 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_livekit_room_box_2ddec4"
|
||||
>
|
||||
<h4>
|
||||
LiveKit SFU: wss://local-sfu.example.org
|
||||
LiveKit SFU: https://local-sfu.example.org/jwt
|
||||
</h4>
|
||||
<p>
|
||||
LivekitAlias:
|
||||
TestAlias
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
ws-url:
|
||||
wss://local-sfu.example.org/
|
||||
</p>
|
||||
<p>
|
||||
LiveKit Server Info
|
||||
(
|
||||
@@ -384,37 +373,20 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_pre_2ddec4"
|
||||
>
|
||||
{
|
||||
"region": "local",
|
||||
"version": "1.2.3"
|
||||
"type": "livekit",
|
||||
"token": "<redacted>",
|
||||
"url": "wss://local-sfu.example.org",
|
||||
"roomAlias": "TestAlias",
|
||||
"identity": "localParticipantIdentity"
|
||||
}
|
||||
local-metadata
|
||||
</pre>
|
||||
<p>
|
||||
Local Participant
|
||||
</p>
|
||||
<pre
|
||||
class="_pre_2ddec4"
|
||||
>
|
||||
localParticipantIdentity
|
||||
</pre>
|
||||
<p>
|
||||
Remote Participants
|
||||
</p>
|
||||
<ul />
|
||||
</div>
|
||||
<div
|
||||
class="_livekit_room_box_2ddec4"
|
||||
>
|
||||
<h4>
|
||||
LiveKit SFU: wss://remote-sfu.example.org
|
||||
LiveKit SFU: https://remote-sfu.example.org/jwt
|
||||
</h4>
|
||||
<p>
|
||||
LivekitAlias:
|
||||
TestAlias2
|
||||
</p>
|
||||
<p>
|
||||
connectionState (wont hot reload):
|
||||
</p>
|
||||
<p>
|
||||
LiveKit Server Info
|
||||
(
|
||||
@@ -424,24 +396,8 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
<pre
|
||||
class="_pre_2ddec4"
|
||||
>
|
||||
{
|
||||
"region": "remote",
|
||||
"version": "4.5.6"
|
||||
}
|
||||
remote-metadata
|
||||
not connected
|
||||
</pre>
|
||||
<p>
|
||||
Local Participant
|
||||
</p>
|
||||
<pre
|
||||
class="_pre_2ddec4"
|
||||
>
|
||||
localParticipantIdentity
|
||||
</pre>
|
||||
<p>
|
||||
Remote Participants
|
||||
</p>
|
||||
<ul />
|
||||
</div>
|
||||
<div
|
||||
class="_separator_13qwf_8"
|
||||
|
||||
+2
-23
@@ -1,29 +1,8 @@
|
||||
/*
|
||||
Copyright 2025 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.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
/**
|
||||
* A stateful, read-only reactive value. As an Observable, it is "hot" and
|
||||
* always replays the current value upon subscription.
|
||||
*
|
||||
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
|
||||
* provide a way to imperatively set new values. For more info on the
|
||||
* distinction between Behaviors and Observables, see
|
||||
* https://monoid.dk/post/behaviors-and-streams-why-both/.
|
||||
*/
|
||||
export type Behavior<T> = Omit<
|
||||
BehaviorSubject<T>,
|
||||
"next" | "observers" | "error"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates a Behavior which never changes in value.
|
||||
*/
|
||||
export function constant<T>(value: T): Behavior<T> {
|
||||
return new BehaviorSubject(value);
|
||||
}
|
||||
export { type Behavior, constant } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
createCallNotificationLifecycle$,
|
||||
type RingAttempt,
|
||||
} from "./CallNotificationLifecycle";
|
||||
import { Epoch, trackEpoch } from "../ObservableScope";
|
||||
import { constant } from "../Behavior";
|
||||
|
||||
function mockRingEvent(
|
||||
@@ -44,8 +43,8 @@ function mockRingEvent(
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
memberships$: constant(new Epoch([])),
|
||||
matrixRoomMembers$: constant(new Map([[alice.userId, alice]])),
|
||||
memberUserIds$: constant<string[]>([]),
|
||||
roomMemberUserIds$: constant([alice.userId]),
|
||||
receivedDecline$: NEVER,
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
@@ -122,7 +121,7 @@ test("ring attempt times out after nobody joins", () => {
|
||||
scope,
|
||||
...defaultProps,
|
||||
// No one ever joins (only local user)
|
||||
memberships$: constant(new Epoch([])),
|
||||
memberUserIds$: constant<string[]>([]),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
@@ -140,8 +139,8 @@ test("ring attempt is accepted once recipient joins", () => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
|
||||
memberUserIds$: scope.behavior(
|
||||
behavior("a-b", { a: [], b: [aliceRtcMember.userId] }),
|
||||
),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
@@ -160,7 +159,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberships$: constant(new Epoch([aliceRtcMember])),
|
||||
memberUserIds$: constant([aliceRtcMember.userId]),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
@@ -35,9 +34,7 @@ import {
|
||||
} from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
|
||||
import { type Behavior, type ObservableScope } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
|
||||
@@ -81,8 +78,10 @@ export function createReceivedDecline$(
|
||||
|
||||
export interface Props {
|
||||
scope: ObservableScope;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
matrixRoomMembers$: Behavior<RoomMemberMap>;
|
||||
/** The user ids of the session's members, ours included. */
|
||||
memberUserIds$: Behavior<string[]>;
|
||||
/** The user ids of the room's members, ours included. */
|
||||
roomMemberUserIds$: Behavior<string[]>;
|
||||
sentCallNotification$: Observable<CallNotificationWrapper | null>;
|
||||
receivedDecline$: Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
@@ -93,8 +92,8 @@ export interface Props {
|
||||
|
||||
export function createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$,
|
||||
matrixRoomMembers$,
|
||||
memberUserIds$,
|
||||
roomMemberUserIds$,
|
||||
sentCallNotification$,
|
||||
receivedDecline$,
|
||||
options,
|
||||
@@ -127,7 +126,7 @@ export function createCallNotificationLifecycle$({
|
||||
switchMap((notificationEvent) => {
|
||||
// We assume that there is only one other user in the room when ringing
|
||||
// TODO: Respect io.element.functional_members
|
||||
const recipient = [...matrixRoomMembers$.value.keys()].find(
|
||||
const recipient = roomMemberUserIds$.value.find(
|
||||
(userId) => userId !== localUser.userId,
|
||||
);
|
||||
if (recipient === undefined) {
|
||||
@@ -140,8 +139,8 @@ export function createCallNotificationLifecycle$({
|
||||
map(() => "timeout" as const),
|
||||
);
|
||||
// Call is accepted when the recipient joins
|
||||
const accept$ = memberships$.pipe(
|
||||
filter((ms) => ms.value.some((m) => m.userId === recipient)),
|
||||
const accept$ = memberUserIds$.pipe(
|
||||
filter((userIds) => userIds.includes(recipient)),
|
||||
map(() => "accept" as const),
|
||||
);
|
||||
// Call is declined when we receive a decline event
|
||||
@@ -171,12 +170,12 @@ export function createCallNotificationLifecycle$({
|
||||
scope.share,
|
||||
);
|
||||
|
||||
const allOthersLeft$ = memberships$.pipe(
|
||||
const allOthersLeft$ = memberUserIds$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([{ value: prev }, { value: current }]) =>
|
||||
current.every((m) => m.userId === localUser.userId) &&
|
||||
prev.some((m) => m.userId !== localUser.userId),
|
||||
([prev, current]) =>
|
||||
current.every((userId) => userId === localUser.userId) &&
|
||||
prev.some((userId) => userId !== localUser.userId),
|
||||
),
|
||||
map(() => {}),
|
||||
);
|
||||
|
||||
@@ -15,34 +15,23 @@ import {
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { SyncState } from "matrix-js-sdk";
|
||||
import {
|
||||
ConnectionState,
|
||||
type LocalTrackPublication,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
import {
|
||||
Status,
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
MatrixRTCSessionEvent,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { type Layout } from "../layout-types.ts";
|
||||
import {
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
withTestScheduler,
|
||||
mockRtcMembership,
|
||||
testScope,
|
||||
exampleTransport,
|
||||
} from "../../utils/test.ts";
|
||||
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
||||
import {
|
||||
@@ -59,11 +48,15 @@ import {
|
||||
localRtcMember,
|
||||
localRtcMemberDevice2,
|
||||
} from "../../utils/test-fixtures.ts";
|
||||
import { MediaDevices } from "../MediaDevices.ts";
|
||||
import {
|
||||
MediaDevices,
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "../MediaDevices.ts";
|
||||
import { getValue } from "../../utils/observable.ts";
|
||||
import { type Behavior, constant } from "../Behavior.ts";
|
||||
import {
|
||||
localParticipant,
|
||||
type Participant,
|
||||
withCallViewModel as withCallViewModelInMode,
|
||||
} from "./CallViewModelTestUtils.ts";
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
|
||||
@@ -80,11 +73,6 @@ vi.mock("rxjs", async (importOriginal) => ({
|
||||
interval: (): Observable<number> => NEVER,
|
||||
}));
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
vi.mock("../e2ee/matrixKeyProvider");
|
||||
|
||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../../Platform", () => ({
|
||||
get platform(): string {
|
||||
@@ -93,15 +81,6 @@ vi.mock("../../Platform", () => ({
|
||||
isFirefox: (): boolean => false,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../state/CallViewModel/localMember/localTransport",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
makeTransport: async (): Promise<UnstableLivekitTransport> =>
|
||||
Promise.resolve(exampleTransport),
|
||||
}),
|
||||
);
|
||||
|
||||
const yesNo = {
|
||||
y: true,
|
||||
n: false,
|
||||
@@ -262,6 +241,56 @@ const modes = [[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]];
|
||||
describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
const withCallViewModel = withCallViewModelInMode(mode);
|
||||
|
||||
/** Media devices whose audio output the test selects. */
|
||||
const withAudioOutput = (): {
|
||||
mediaDevices: MediaDevices;
|
||||
selected$: BehaviorSubject<SelectedAudioOutputDevice | undefined>;
|
||||
} => {
|
||||
const selected$ = new BehaviorSubject<
|
||||
SelectedAudioOutputDevice | undefined
|
||||
>(undefined);
|
||||
const mediaDevices = mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map()),
|
||||
selected$,
|
||||
select: vi.fn(),
|
||||
},
|
||||
});
|
||||
return { mediaDevices, selected$ };
|
||||
};
|
||||
|
||||
test("the selected audio output is handed to the client as it changes", () => {
|
||||
const { mediaDevices, selected$ } = withAudioOutput();
|
||||
withCallViewModel(
|
||||
{ mediaDevices },
|
||||
(_vm, _session, { rtcParticipation: participation }) => {
|
||||
// Nothing selected yet, so the browser's choice stands
|
||||
expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
|
||||
selected$.next({ id: "speaker", virtualEarpiece: false });
|
||||
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledWith(
|
||||
"speaker",
|
||||
);
|
||||
selected$.next({ id: "headphones", virtualEarpiece: false });
|
||||
expect(participation.setAudioOutputDeviceId).toHaveBeenLastCalledWith(
|
||||
"headphones",
|
||||
);
|
||||
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a host that controls the audio devices keeps the output from the client", () => {
|
||||
const { mediaDevices, selected$ } = withAudioOutput();
|
||||
withCallViewModel(
|
||||
{ mediaDevices },
|
||||
(_vm, _session, { rtcParticipation: participation }) => {
|
||||
selected$.next({ id: "earpiece", virtualEarpiece: true });
|
||||
expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
|
||||
},
|
||||
{ controlledAudioDevices: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
@@ -278,10 +307,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: [],
|
||||
}),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
livekitConnectionState$: behavior(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ConnectionState.Connecting,
|
||||
}),
|
||||
connected$: behavior(connectionInputMarbles, { c: true, s: false }),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
@@ -1388,9 +1414,9 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
function participantJoinLeave$(
|
||||
behavior: (
|
||||
marbles: string,
|
||||
values: Record<string, RemoteParticipant[]>,
|
||||
) => Behavior<RemoteParticipant[]>,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
values: Record<string, Participant[]>,
|
||||
) => Behavior<Participant[]>,
|
||||
): Behavior<Participant[]> {
|
||||
return behavior("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [aliceParticipant], // Alice joins
|
||||
@@ -1592,10 +1618,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
|
||||
it.skip("audio output changes when toggling earpiece mode", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
of([]),
|
||||
);
|
||||
|
||||
const devices = new MediaDevices(testScope(), {
|
||||
controlledAudioDevices: true,
|
||||
});
|
||||
@@ -1627,78 +1649,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("media tracks are paused while reconnecting to MatrixRTC", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
const trackRunning$ = new BehaviorSubject(true);
|
||||
const originalPublications = localParticipant.trackPublications;
|
||||
localParticipant.trackPublications = new Map([
|
||||
[
|
||||
"video",
|
||||
{
|
||||
track: new (class {
|
||||
public get isUpstreamPaused(): boolean {
|
||||
return !trackRunning$.value;
|
||||
}
|
||||
public async pauseUpstream(): Promise<void> {
|
||||
trackRunning$.next(false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
public async resumeUpstream(): Promise<void> {
|
||||
trackRunning$.next(true);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})(),
|
||||
} as unknown as LocalTrackPublication,
|
||||
],
|
||||
]);
|
||||
onTestFinished(() => {
|
||||
localParticipant.trackPublications = originalPublications;
|
||||
});
|
||||
|
||||
// There are three indicators that the client might be disconnected from
|
||||
// MatrixRTC: whether the sync loop is connected, whether the membership is
|
||||
// present in local room state, and whether the membership manager thinks
|
||||
// we've hit the timeout for the delayed leave event. Let's test all
|
||||
// combinations of these conditions.
|
||||
const syncingMarbles = " nyny----n--y";
|
||||
const membershipStatusMarbles = " y---ny-n-yn-y";
|
||||
const probablyLeftMarbles = " n-----y-ny---n";
|
||||
const expectedReconnectingMarbles = "n-ynyny------n";
|
||||
const expectedTrackRunningMarbles = "nynynyn------y";
|
||||
|
||||
withCallViewModel(
|
||||
{ initialSyncState: SyncState.Reconnecting },
|
||||
(vm, rtcSession, _subjects, setSyncState) => {
|
||||
schedule(syncingMarbles, {
|
||||
y: () => setSyncState(SyncState.Syncing),
|
||||
n: () => setSyncState(SyncState.Reconnecting),
|
||||
});
|
||||
schedule(membershipStatusMarbles, {
|
||||
y: () => {
|
||||
rtcSession.membershipStatus = Status.Connected;
|
||||
},
|
||||
});
|
||||
schedule(probablyLeftMarbles, {
|
||||
y: () => {
|
||||
rtcSession.probablyLeft = true;
|
||||
},
|
||||
n: () => {
|
||||
rtcSession.probablyLeft = false;
|
||||
},
|
||||
});
|
||||
expectObservable(vm.reconnecting$).toBe(
|
||||
expectedReconnectingMarbles,
|
||||
yesNo,
|
||||
);
|
||||
expectObservable(trackRunning$).toBe(
|
||||
expectedTrackRunningMarbles,
|
||||
yesNo,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("callViewModelOptionsFromParams", () => {
|
||||
@@ -1709,33 +1659,8 @@ describe("callViewModelOptionsFromParams", () => {
|
||||
const widgetUrl = (extra: string): string =>
|
||||
`#?widgetId=id&parentUrl=${encodeURIComponent("http://parent")}&${extra}`;
|
||||
|
||||
it("carries an explicitly requested notification type", () => {
|
||||
const params = computeUrlParams("", widgetUrl("sendNotificationType=ring"));
|
||||
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
|
||||
"ring",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the notification type an intent implies", () => {
|
||||
const params = computeUrlParams("", widgetUrl("intent=start_call_dm"));
|
||||
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
|
||||
"ring",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries hideScreensharing", () => {
|
||||
const params = computeUrlParams("", widgetUrl("hideScreensharing=true"));
|
||||
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
|
||||
});
|
||||
|
||||
it("carries controlledAudioDevices and the call intent", () => {
|
||||
const params = computeUrlParams(
|
||||
"",
|
||||
widgetUrl("controlledAudioDevices=true&intent=start_call_voice"),
|
||||
);
|
||||
expect(callViewModelOptionsFromParams(params)).toMatchObject({
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,39 +7,41 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionState,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
type Behavior,
|
||||
constant,
|
||||
E2eeType,
|
||||
type LocalRTCMember,
|
||||
type ParticipationState,
|
||||
type RTCParticipation,
|
||||
type MatrixRTCMode,
|
||||
type ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { BehaviorSubject, combineLatest, map, of } from "rxjs";
|
||||
import { onTestFinished, vi } from "vitest";
|
||||
import { BehaviorSubject, map, of, switchMap } from "rxjs";
|
||||
import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk";
|
||||
import EventEmitter from "events";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
type CallViewModelOptions,
|
||||
} from "./CallViewModel";
|
||||
import { createSentCallNotification$ } from "./CallNotificationLifecycle";
|
||||
import {
|
||||
exampleTransportLocator,
|
||||
mockConfig,
|
||||
MockConnection,
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRTCParticipation,
|
||||
mockAudioTrack,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
type MockTracks,
|
||||
mockMuteStates,
|
||||
MockRTCSession,
|
||||
mockRTCMember,
|
||||
testScope,
|
||||
} from "../../utils/test";
|
||||
import {
|
||||
@@ -52,10 +54,7 @@ import {
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../../utils/test-fixtures";
|
||||
import { type Behavior, constant } from "../Behavior";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { type MatrixRTCMode } from "../../config/ConfigOptions";
|
||||
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
@@ -65,11 +64,18 @@ const carol = local;
|
||||
|
||||
const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" });
|
||||
|
||||
/** Whoever publishes media: a stand-in for a LiveKit participant, by identity. */
|
||||
export interface Participant {
|
||||
identity: string;
|
||||
}
|
||||
|
||||
export interface CallViewModelInputs {
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||
/** The members whose media has arrived. The local member's always has. */
|
||||
remoteParticipants$: Behavior<Participant[]>;
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
||||
roomMembers: RoomMember[];
|
||||
livekitConnectionState$: Behavior<ConnectionState>;
|
||||
/** Whether the local transport is connected. */
|
||||
connected$: Behavior<boolean>;
|
||||
speaking: Map<Participant, Behavior<boolean>>;
|
||||
videoEnabled: Map<Participant, Behavior<boolean>>;
|
||||
sharingScreen: Map<Participant, Behavior<boolean>>;
|
||||
@@ -78,7 +84,7 @@ export interface CallViewModelInputs {
|
||||
windowSize$: Behavior<{ width: number; height: number }>;
|
||||
}
|
||||
|
||||
export const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
export const localParticipant: Participant = { identity: "" };
|
||||
|
||||
export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
return (
|
||||
@@ -94,9 +100,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
dave,
|
||||
daveRTL,
|
||||
],
|
||||
livekitConnectionState$: connectionState$ = constant(
|
||||
ConnectionState.Connected,
|
||||
),
|
||||
connected$ = constant(true),
|
||||
speaking = new Map(),
|
||||
videoEnabled = new Map(),
|
||||
sharingScreen = new Map(),
|
||||
@@ -109,6 +113,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
rtcSession: MockRTCSession,
|
||||
subjects: {
|
||||
raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||
/** The participation the view model was built on, with spies for its calls. */
|
||||
rtcParticipation: RTCParticipation;
|
||||
},
|
||||
setSyncState: (value: SyncState) => void,
|
||||
) => void,
|
||||
@@ -122,8 +128,6 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
};
|
||||
const room = mockMatrixRoom({
|
||||
client: new (class extends EventEmitter {
|
||||
public readonly baseUrl = "https://matrix.example.com";
|
||||
|
||||
public getUserId(): string | undefined {
|
||||
return localRtcMember.userId;
|
||||
}
|
||||
@@ -146,114 +150,141 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||
rtcMembers$,
|
||||
);
|
||||
const participantsSpy = vi
|
||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||
.mockReturnValue(remoteParticipants$);
|
||||
const mediaSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||
.mockImplementation((p) => {
|
||||
return (videoEnabled.get(p) ?? constant(false)).pipe(
|
||||
map((videoEnabled) => ({
|
||||
participant: p,
|
||||
isMicrophoneEnabled: false,
|
||||
isCameraEnabled: videoEnabled,
|
||||
isScreenShareEnabled: false,
|
||||
cameraTrack: {
|
||||
isMuted: !videoEnabled,
|
||||
} as unknown as TrackPublication,
|
||||
})),
|
||||
);
|
||||
});
|
||||
const eventsSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||
.mockImplementation((p, ...eventTypes) => {
|
||||
return combineLatest([
|
||||
(eventTypes.includes(ParticipantEvent.IsSpeakingChanged) &&
|
||||
speaking.get(p)) ||
|
||||
constant(false),
|
||||
(eventTypes.includes(ParticipantEvent.TrackPublished) &&
|
||||
sharingScreen.get(p)) ||
|
||||
constant(false),
|
||||
]).pipe(
|
||||
map(
|
||||
([isSpeaking, isScreenShareEnabled]) =>
|
||||
({ ...p, isSpeaking, isScreenShareEnabled }) as Participant,
|
||||
),
|
||||
);
|
||||
});
|
||||
const scope = testScope();
|
||||
|
||||
const roomEventSelectorSpy = vi
|
||||
.spyOn(ComponentsCore, "roomEventSelector")
|
||||
.mockImplementation((_room, _eventType) => of());
|
||||
const tracksOf = (
|
||||
scope: ObservableScope,
|
||||
participant: Participant,
|
||||
): Behavior<MockTracks> => {
|
||||
// A microphone and a camera are always published; the screen share
|
||||
// comes and goes
|
||||
const microphone = mockAudioTrack({
|
||||
isActive$: speaking.get(participant) ?? constant(false),
|
||||
});
|
||||
const camera = mockVideoTrack({
|
||||
source: "camera",
|
||||
muted$: scope.behavior(
|
||||
(videoEnabled.get(participant) ?? constant(false)).pipe(
|
||||
map((enabled) => !enabled),
|
||||
),
|
||||
),
|
||||
});
|
||||
const screenShare = mockVideoTrack({ source: "screenShare" });
|
||||
return scope.behavior(
|
||||
(sharingScreen.get(participant) ?? constant(false)).pipe(
|
||||
map((sharing) =>
|
||||
sharing ? [microphone, camera, screenShare] : [microphone, camera],
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const participantOf = (
|
||||
scope: ObservableScope,
|
||||
membership: CallMembership,
|
||||
): Behavior<Participant | undefined> =>
|
||||
scope.behavior(
|
||||
remoteParticipants$.pipe(
|
||||
map((participants) =>
|
||||
participants.find(
|
||||
(p) => p.identity === membership.rtcBackendIdentity,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const memberOf = (
|
||||
scope: ObservableScope,
|
||||
membership: CallMembership,
|
||||
): RemoteRTCMember =>
|
||||
mockRTCMember(false, {
|
||||
membership,
|
||||
roomMember: roomMembers.find((m) => m.userId === membership.userId),
|
||||
transportUrl: "http://my-default-service-url.com",
|
||||
tracks$: scope.behavior(
|
||||
participantOf(scope, membership).pipe(
|
||||
switchMap((participant) =>
|
||||
participant === undefined
|
||||
? of(null)
|
||||
: tracksOf(scope, participant),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
||||
rtcMembers$.pipe(
|
||||
map((memberships) =>
|
||||
(memberships as CallMembership[])
|
||||
.filter(
|
||||
(m) =>
|
||||
m.userId !== localRtcMember.userId ||
|
||||
m.deviceId !== localRtcMember.deviceId,
|
||||
)
|
||||
.map((membership) => memberOf(scope, membership)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
||||
rtcMembers$.pipe(
|
||||
map((memberships) =>
|
||||
(memberships as CallMembership[]).some(
|
||||
(m) =>
|
||||
m.userId === localRtcMember.userId &&
|
||||
m.deviceId === localRtcMember.deviceId,
|
||||
)
|
||||
? mockRTCMember(true, {
|
||||
membership: localRtcMember,
|
||||
roomMember: carol,
|
||||
transportUrl: "http://my-default-service-url.com",
|
||||
tracks$: tracksOf(scope, localParticipant),
|
||||
})
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const rtcParticipation = mockRTCParticipation(scope, {
|
||||
localMember$,
|
||||
remoteMembers$,
|
||||
state$: scope.behavior(
|
||||
connected$.pipe(
|
||||
map((connected): ParticipationState =>
|
||||
connected
|
||||
? { kind: "connected" }
|
||||
: { kind: "reconnecting", reason: "media" },
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
const muteStates = mockMuteStates();
|
||||
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>(
|
||||
{},
|
||||
);
|
||||
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
||||
|
||||
const livekitRoomFactory = (): LivekitRoom =>
|
||||
mockLivekitRoom({
|
||||
localParticipant,
|
||||
disconnect: async () => Promise.resolve(),
|
||||
setE2EEEnabled: async () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
scope,
|
||||
rtcParticipation,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
livekitRoomFactory,
|
||||
connectionState$,
|
||||
sentCallNotification$: createSentCallNotification$(
|
||||
scope,
|
||||
rtcSession.asMockedSession(),
|
||||
),
|
||||
windowSize$,
|
||||
localTransport: exampleTransportLocator,
|
||||
connectionFactory: {
|
||||
createConnection(
|
||||
scope,
|
||||
role,
|
||||
transport,
|
||||
serverName,
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
) {
|
||||
return new MockConnection(
|
||||
{
|
||||
scope,
|
||||
role,
|
||||
transport,
|
||||
serverName,
|
||||
ownMembershipIdentity,
|
||||
client: room.client,
|
||||
roomId: room.roomId,
|
||||
slotId: "m.call#ROOM",
|
||||
livekitRoomFactory,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
},
|
||||
},
|
||||
matrixRTCMode: mode,
|
||||
...options,
|
||||
},
|
||||
raisedHands$,
|
||||
reactions$,
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
processor: undefined,
|
||||
supported: undefined,
|
||||
}),
|
||||
);
|
||||
void mode;
|
||||
|
||||
onTestFinished(() => {
|
||||
participantsSpy.mockRestore();
|
||||
mediaSpy.mockRestore();
|
||||
eventsSpy.mockRestore();
|
||||
roomEventSelectorSpy.mockRestore();
|
||||
});
|
||||
|
||||
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
||||
continuation(
|
||||
vm,
|
||||
rtcSession,
|
||||
{ raisedHands$, rtcParticipation },
|
||||
setSyncState,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import { ClientEvent, SyncState } from "matrix-js-sdk";
|
||||
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||
import { testScope, withTestScheduler } from "../../../utils/test";
|
||||
|
||||
/**
|
||||
* Minimal stub of a Matrix client sufficient for our tests:
|
||||
```
|
||||
createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
)
|
||||
```
|
||||
*/
|
||||
class MockMatrixClient extends EventEmitter {
|
||||
private syncState: SyncState;
|
||||
public constructor(initial: SyncState) {
|
||||
super();
|
||||
this.syncState = initial;
|
||||
}
|
||||
public setSyncState(state: SyncState): void {
|
||||
this.syncState = state;
|
||||
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
|
||||
this.emit(ClientEvent.Sync, [state]);
|
||||
}
|
||||
public getSyncState(): SyncState {
|
||||
return this.syncState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stub of MatrixRTCSession (membership manager):
|
||||
```
|
||||
createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
)
|
||||
```
|
||||
*/
|
||||
class MockMatrixRTCSession extends EventEmitter {
|
||||
public membershipStatus: Status;
|
||||
public probablyLeft: boolean;
|
||||
|
||||
public constructor(props: {
|
||||
membershipStatus: Status;
|
||||
probablyLeft: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.membershipStatus = props.membershipStatus;
|
||||
this.probablyLeft = props.probablyLeft;
|
||||
}
|
||||
|
||||
public setMembershipStatus(status: Status): void {
|
||||
this.membershipStatus = status;
|
||||
this.emit(MembershipManagerEvent.StatusChanged);
|
||||
}
|
||||
|
||||
public setProbablyLeft(flag: boolean): void {
|
||||
this.probablyLeft = flag;
|
||||
this.emit(MembershipManagerEvent.ProbablyLeft);
|
||||
}
|
||||
}
|
||||
|
||||
describe("createHomeserverConnected$", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
client = new MockMatrixClient(SyncState.Error); // start disconnected
|
||||
session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Disconnected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||
// easy enough to read them so I think they can stay.
|
||||
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||
it("reports syncing reason when sync state is not Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Make sync loop OK
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// Indicate probable leave before connection
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// 1. Sync loop connected
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected
|
||||
// 2. Membership connected
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
|
||||
});
|
||||
|
||||
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Reach connected state
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Sync loop error => should report syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setMembershipStatus(Status.Disconnected);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
});
|
||||
|
||||
it("composite sequence reflects each individual failure reason", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
|
||||
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
|
||||
// Fix sync only → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
|
||||
// Fix membership → all conditions satisfied
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Introduce probablyLeft → certainlyConnected
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Restore notProbablyLeft → connected again
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Drop sync → syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - combined$ reason values", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
// Start with sync failing and membership disconnected
|
||||
client = new MockMatrixClient(SyncState.Error);
|
||||
session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Disconnected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("is [true, null] when all three conditions are satisfied", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(combined$.value).toEqual([true, null]);
|
||||
});
|
||||
|
||||
it("reports syncing when sync loop is not Syncing", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// client starts with SyncState.Error, membership also disconnected
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// session still Status.Disconnected
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
session.setProbablyLeft(true);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Both sync (Error) and membership (Disconnected) are failing
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("updates reason as conditions change", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Initially: syncing fails
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
|
||||
// Fix sync → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
|
||||
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Clear probablyLeft → all conditions satisfied
|
||||
session.setProbablyLeft(false);
|
||||
expect(combined$.value).toEqual([true, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||
const GRACE_PERIOD = 5;
|
||||
|
||||
function marbleTest(
|
||||
syncStateMarbles: string,
|
||||
expectedConnectedMarbles: string,
|
||||
): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const syncState$ = behavior(syncStateMarbles, {
|
||||
s: SyncState.Syncing,
|
||||
e: SyncState.Error,
|
||||
});
|
||||
const client = new MockMatrixClient(syncState$.value);
|
||||
schedule(syncStateMarbles, {
|
||||
s: () => client.setSyncState(SyncState.Syncing),
|
||||
e: () => client.setSyncState(SyncState.Error),
|
||||
});
|
||||
const session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Connected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
const hsConnected = createHomeserverConnected$(
|
||||
testScope(),
|
||||
client,
|
||||
session,
|
||||
GRACE_PERIOD,
|
||||
);
|
||||
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
||||
y: [true, null],
|
||||
n: [false, "sync"],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||
// - Initial state: Everything is connected
|
||||
// - Sync error occurs -> should remain connected due to grace period
|
||||
// - After grace period, not connected
|
||||
marbleTest("se", "y-----n");
|
||||
// If the sync error takes longer to occur, it should take equally long for
|
||||
// the connection state to change
|
||||
marbleTest("s--e", "y-------n");
|
||||
});
|
||||
|
||||
it("recovers immediately if sync returns during grace period", () => {
|
||||
// - Initial state: Connected
|
||||
// - Sync error occurs
|
||||
// - Sync recovers BEFORE the grace period expires
|
||||
// - Connection state remains constant
|
||||
marbleTest("se--s", "y");
|
||||
});
|
||||
|
||||
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||
// - Initial error: connection eventually flips to false
|
||||
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||
marbleTest("e-----s", "y----ny");
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
import {
|
||||
fromEvent,
|
||||
startWith,
|
||||
map,
|
||||
tap,
|
||||
type Observable,
|
||||
distinctUntilChanged,
|
||||
switchMap,
|
||||
of,
|
||||
delay,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../../../config/Config";
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
|
||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
export interface HomeserverConnected {
|
||||
/**
|
||||
* Emits `[true, null]` when the homeserver connection is healthy, or
|
||||
* `[false, reason]` when one of the three sub-conditions fails.
|
||||
*/
|
||||
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
|
||||
rtsSession$: Behavior<Status>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||
* for the purposes of a MatrixRTC session.
|
||||
*
|
||||
* `combined$` emits `null` when all conditions are satisfied, or the first failing
|
||||
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
||||
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
||||
* 2. membershipStatus !== Status.Connected → "membership"
|
||||
* 3. probablyLeft === true → "probablyLeft"
|
||||
*
|
||||
* @param scope - The observable scope for lifecycle management.
|
||||
* @param client - The Matrix client to monitor sync state.
|
||||
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||
* If not provided, uses the config value (default 10000ms).
|
||||
*/
|
||||
export function createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
gracePeriodMs?: number,
|
||||
): HomeserverConnected {
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||
|
||||
const syncing$ = (
|
||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||
).pipe(
|
||||
startWith([client.getSyncState()]),
|
||||
map(([state]) => state === SyncState.Syncing),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isSyncing) => {
|
||||
if (isSyncing || graceMs <= 0) {
|
||||
return of(isSyncing);
|
||||
}
|
||||
return of(false).pipe(delay(graceMs), startWith(true));
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const rtsSession$ = scope.behavior<Status>(
|
||||
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||
),
|
||||
matrixRTCSession.membershipStatus ?? Status.Unknown,
|
||||
);
|
||||
|
||||
const membershipConnected$ = rtsSession$.pipe(
|
||||
map((status) => status === Status.Connected),
|
||||
);
|
||||
|
||||
// This is basically notProbablyLeft$
|
||||
//
|
||||
// probablyLeft is computed by a local timer that mimics the server delayed event.
|
||||
// If we locally predict our server event timed out. We consider ourselves as probablyLeft
|
||||
// even though we might not yet have received the delayed event leave.
|
||||
//
|
||||
// If that is not the case we certainly still have a valid membership on the matrix network
|
||||
// independet if the sync currently works.
|
||||
const certainlyConnected$ = fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => matrixRTCSession.probablyLeft !== true),
|
||||
);
|
||||
|
||||
const combined$ = scope.behavior(
|
||||
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||
map(
|
||||
([syncing, membership, certainly]): [
|
||||
boolean,
|
||||
HomeserverDisconnectReason | null,
|
||||
] => {
|
||||
if (!syncing) return [false, "sync"];
|
||||
if (!membership) return [false, "membership"];
|
||||
if (!certainly) return [false, "probablyLeft"];
|
||||
return [true, null];
|
||||
},
|
||||
),
|
||||
tap(([connected, reason]) => {
|
||||
logger.info(
|
||||
`Homeserver connected update: ${connected ? "connected" : reason}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { combined$, rtsSession$ };
|
||||
}
|
||||
@@ -1,920 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
Status as RTCMemberStatus,
|
||||
type UnstableLivekitTransport,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type LocalParticipant,
|
||||
type LocalTrack,
|
||||
} from "livekit-client";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
|
||||
import {
|
||||
MatrixRTCMode,
|
||||
type ResolvedDelayedLeaveTimings,
|
||||
} from "../../../config/ConfigOptions";
|
||||
import { type HomeserverDisconnectReason } from "./HomeserverConnected";
|
||||
import {
|
||||
flushPromises,
|
||||
mockConfig,
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMuteStates,
|
||||
withTestScheduler,
|
||||
ownMemberMock,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import {
|
||||
TransportState,
|
||||
createLocalMembership$,
|
||||
enterRTCSession,
|
||||
PublishState,
|
||||
TrackState,
|
||||
watchScreenShareToggle,
|
||||
} from "./LocalMember";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { constant } from "../../Behavior";
|
||||
import { ConnectionMap } from "../remoteMembers/ConnectionMap";
|
||||
import {
|
||||
type ConnectionState,
|
||||
type Connection,
|
||||
} from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
import { nullHostBridge } from "../../../HostBridge";
|
||||
import * as livekitAuth from "../../../livekit/auth";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Compatibility;
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
observeParticipantEvents: vi
|
||||
.fn()
|
||||
.mockReturnValue(of({ isScreenShareEnabled: false })),
|
||||
}));
|
||||
|
||||
describe("watchScreenShareToggle", () => {
|
||||
it("reports nothing when the toggle completes", async () => {
|
||||
const onError = vi.fn();
|
||||
watchScreenShareToggle(Promise.resolve(), true, logger, onError);
|
||||
await flushPromises();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports failures other than the user cancelling", async () => {
|
||||
const onError = vi.fn();
|
||||
const e = new Error("NotReadableError");
|
||||
watchScreenShareToggle(Promise.reject(e), true, logger, onError);
|
||||
await flushPromises();
|
||||
expect(onError).toHaveBeenCalledWith(e);
|
||||
});
|
||||
|
||||
it("does not report the user cancelling the picker", async () => {
|
||||
const onError = vi.fn();
|
||||
const cancelled = new DOMException("Permission denied", "NotAllowedError");
|
||||
watchScreenShareToggle(Promise.reject(cancelled), true, logger, onError);
|
||||
await flushPromises();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const timings: ResolvedDelayedLeaveTimings = {
|
||||
delay_ms: 10000,
|
||||
restart_ms: 4000,
|
||||
restart_timeout_ms: 1000,
|
||||
};
|
||||
|
||||
const delegatedTimings: ResolvedDelayedLeaveTimings = {
|
||||
delay_ms: timings.delay_ms * 10,
|
||||
restart_ms: timings.restart_ms! * 10,
|
||||
restart_timeout_ms: timings.restart_timeout_ms! * 10,
|
||||
};
|
||||
|
||||
const mockedClient = {
|
||||
baseUrl: "https://matrix.example.org",
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
getDeviceId: vi.fn().mockReturnValue("AAAA"),
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "ACCCESS_TOKEN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "localhost",
|
||||
expires_in: 10000,
|
||||
}),
|
||||
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
describe("enterRTCSession", () => {
|
||||
const transport: UnstableLivekitTransport = {
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
};
|
||||
|
||||
const options = {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MATRIX_RTC_MODE,
|
||||
delayedLeaveTimings: timings,
|
||||
};
|
||||
|
||||
const mockedSession = vi.mocked({
|
||||
room: {
|
||||
roomId: "roomId",
|
||||
client: mockedClient,
|
||||
},
|
||||
memberships: [],
|
||||
join: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
beforeEach(() =>
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
it("It joins the correct Session", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
{
|
||||
deviceId: "DEVICE",
|
||||
memberId: "@alice:example.org:DEVICE",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
[transport],
|
||||
expect.objectContaining({ manageMediaKeys: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes keyRotationParticipantLimit from config to join", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
matrix_rtc_session: {
|
||||
network_error_retry_ms: 0,
|
||||
key_rotation_participant_limit: 50,
|
||||
delayed_leave: timings,
|
||||
delegated_delayed_leave: timings,
|
||||
},
|
||||
});
|
||||
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
keyRotationParticipantLimit: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the specified delayed leave timings", () => {
|
||||
enterRTCSession(mockedSession, ownMemberMock, transport, options);
|
||||
|
||||
expect(mockedSession.join).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
delayedLeaveEventRestartMs: timings.restart_ms,
|
||||
delayedLeaveEventDelayMs: timings.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs: timings.restart_timeout_ms,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalMembership", () => {
|
||||
const defaultCreateLocalMemberValues = {
|
||||
options: constant({
|
||||
encryptMedia: false,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
}),
|
||||
matrixRTCSession: {
|
||||
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
|
||||
leave: vi.fn(),
|
||||
} as unknown as MatrixRTCSession,
|
||||
muteStates: mockMuteStates(),
|
||||
trackProcessorState$: constant({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
logger: logger,
|
||||
createPublisherFactory: vi.fn().mockReturnValue({
|
||||
destroy: vi.fn(),
|
||||
createAndSetupTracks: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
joinMatrixRTC: async (): Promise<void> => {},
|
||||
homeserverConnected: {
|
||||
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
|
||||
true,
|
||||
null,
|
||||
]),
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
roomId: "!test-room-id:example.org",
|
||||
hideScreensharing: false,
|
||||
hostBridge: nullHostBridge,
|
||||
baseUrl: "https://matrix.example.org",
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
client: mockedClient,
|
||||
delayId$: constant(null),
|
||||
matrixRTCMode: MATRIX_RTC_MODE,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
matrix_rtc_session: {
|
||||
network_error_retry_ms: 1000,
|
||||
delayed_leave: timings,
|
||||
delegated_delayed_leave: delegatedTimings,
|
||||
},
|
||||
});
|
||||
fetchMock.catch(404);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
void (await fetchMock.flush());
|
||||
fetchMock.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
// we do not need any connection data since we want to fail before reaching that.
|
||||
connectionMap$: constant(new Epoch(new ConnectionMap())),
|
||||
localTransport$: hot(
|
||||
"1ms #",
|
||||
{},
|
||||
new MatrixRTCTransportMissingError("domain.com"),
|
||||
),
|
||||
});
|
||||
|
||||
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
||||
n: TransportState.Waiting,
|
||||
e: expect.toSatisfy((e) => e instanceof MatrixRTCTransportMissingError),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("logs if callIntent cannot be updated", async () => {
|
||||
async function reject(): Promise<void> {
|
||||
return Promise.reject(new Error("Not connected yet"));
|
||||
}
|
||||
const localMembership = createLocalMembership$({
|
||||
scope: testScope(),
|
||||
...defaultCreateLocalMemberValues,
|
||||
matrixRTCSession: {
|
||||
slotId: "m.call#ROOM",
|
||||
updateCallIntent: vi.fn().mockImplementation(reject),
|
||||
leave: vi.fn(),
|
||||
},
|
||||
connectionMap$: constant(new Epoch(new ConnectionMap())),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
const expextedLog =
|
||||
"'not connected yet' while updating the call intent (this is expected on startup)";
|
||||
const internalLogger = vi.spyOn(localMembership.internalLoggerRef, "debug");
|
||||
|
||||
await flushPromises();
|
||||
defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true);
|
||||
expect(internalLogger).toHaveBeenCalledWith(expextedLog);
|
||||
});
|
||||
|
||||
const mockTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "https://sfu.example.org",
|
||||
livekit_service_url: "https://jwt.example.org",
|
||||
};
|
||||
|
||||
const mockTransportLocator: livekitAuth.TransportLocator = {
|
||||
transport: mockTransport,
|
||||
serverName: "example.org",
|
||||
};
|
||||
|
||||
const connectionTransportAConnected = {
|
||||
livekitRoom: mockLivekitRoom({
|
||||
localParticipant: {
|
||||
isScreenShareEnabled: false,
|
||||
trackPublications: [],
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
}),
|
||||
transport: mockTransport,
|
||||
serverName: "example.org",
|
||||
} as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
state$: constant({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connecting,
|
||||
}),
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const delegationSpy = vi
|
||||
.spyOn(livekitAuth, "delegateDelayedLeave")
|
||||
.mockResolvedValue();
|
||||
afterEach(() => delegationSpy.mockClear());
|
||||
|
||||
const urlOnlyTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "https://sfu.example.org",
|
||||
};
|
||||
|
||||
// With a `url` only we publish through the CS API, so the homeserver
|
||||
// delegates. With a `livekit_service_url` we publish through the JWT
|
||||
// service, so that delegates.
|
||||
it.each([
|
||||
["no", mockTransport, null, timings],
|
||||
[
|
||||
"homeserver",
|
||||
urlOnlyTransport,
|
||||
"https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave",
|
||||
delegatedTimings,
|
||||
],
|
||||
[
|
||||
"transport",
|
||||
mockTransport,
|
||||
"https://jwt.example.org/delegate_delayed_leave",
|
||||
delegatedTimings,
|
||||
],
|
||||
])(
|
||||
"joins session with %s delegation support",
|
||||
async (_serviceName, transport, delegationUrl, delayedLeaveTimings) => {
|
||||
const scope = testScope();
|
||||
const joinMatrixRTC = vi.fn();
|
||||
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||
|
||||
if (delegationUrl !== null)
|
||||
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(
|
||||
{ ...connectionTransportAConnected, transport } as Connection,
|
||||
[],
|
||||
);
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
joinMatrixRTC,
|
||||
localTransport$: constant({ transport, serverName: "example.org" }),
|
||||
delayId$,
|
||||
});
|
||||
|
||||
localMembership.requestJoinAndPublish();
|
||||
void (await fetchMock.flush());
|
||||
await flushPromises();
|
||||
// Joins with timings appropriate for the level of delegation support
|
||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||
transport,
|
||||
delayedLeaveTimings,
|
||||
);
|
||||
|
||||
expect(delegationSpy).not.toHaveBeenCalled();
|
||||
delayId$.next("leave1");
|
||||
await flushPromises();
|
||||
if (delegationUrl === null) {
|
||||
expect(delegationSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
// Delegation is supported in this test case, so go on to check that
|
||||
// LocalMember actually performs delegation
|
||||
const expectDelegation = (delayId: string) =>
|
||||
expect(delegationSpy).toHaveBeenLastCalledWith({
|
||||
client: mockedClient,
|
||||
membership: ownMemberMock,
|
||||
transport,
|
||||
roomId: "!test-room-id:example.org",
|
||||
delayId,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
|
||||
expectDelegation("leave1");
|
||||
delayId$.next("leave2"); // Can change delegated leaves
|
||||
await flushPromises();
|
||||
expectDelegation("leave2");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("only starts tracks if requested", async () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const publishers: Publisher[] = [];
|
||||
|
||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||
const publishing$ = constant<boolean>(false);
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
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(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
tracks$.next([{}, {}] as LocalTrack[]);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
tracks$,
|
||||
publishing$,
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(connectionTransportAConnected, []);
|
||||
// connectionMap.add(connectionTransportB, []);
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
await flushPromises();
|
||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||
// expect(localMembership.tracks$.value.length).toBe(0);
|
||||
expect(publishers[0].createAndSetupTracks).not.toHaveBeenCalled();
|
||||
localMembership.startTracks();
|
||||
await flushPromises();
|
||||
expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
|
||||
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
publisherFactory.mockClear();
|
||||
});
|
||||
// TODO add an integration test combining publisher and localMembership
|
||||
//
|
||||
it("tracks livekit state correctly", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const connectionMap = new ConnectionMap();
|
||||
const localTransport$ = new Subject<livekitAuth.TransportLocator>();
|
||||
|
||||
const connectionMap$ = new BehaviorSubject(new Epoch(connectionMap));
|
||||
const publishers: Publisher[] = [];
|
||||
|
||||
const publishing$ = new BehaviorSubject<boolean>(false);
|
||||
const createTrackResolver = Promise.withResolvers<void>();
|
||||
const publishResolver = Promise.withResolvers<void>();
|
||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||
() => {
|
||||
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(),
|
||||
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||
await createTrackResolver.promise;
|
||||
}),
|
||||
startPublishing: vi.fn().mockImplementation(async () => {
|
||||
await publishResolver.promise;
|
||||
publishing$.next(true);
|
||||
}),
|
||||
publishing$,
|
||||
};
|
||||
publishers.push(p as unknown as Publisher);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
|
||||
const publisherFactory =
|
||||
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionMap$: connectionMap$,
|
||||
localTransport$,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual(
|
||||
TransportState.Waiting,
|
||||
);
|
||||
localTransport$.next(mockTransportLocator);
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
media: {
|
||||
connection: { state: "initializing" },
|
||||
tracks: TrackState.WaitingForUser,
|
||||
},
|
||||
});
|
||||
|
||||
const connectionMap2 = new ConnectionMap();
|
||||
connectionMap2.add(
|
||||
// clone because we will mutate this later.
|
||||
{ ...connectionTransportAConnecting } as unknown as Connection,
|
||||
[],
|
||||
);
|
||||
|
||||
connectionMap$.next(new Epoch(connectionMap2));
|
||||
await flushPromises();
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
media: {
|
||||
connection: {
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connecting,
|
||||
},
|
||||
tracks: TrackState.WaitingForUser,
|
||||
},
|
||||
});
|
||||
|
||||
(
|
||||
connectionMap2.getConnectionForTransport(mockTransportLocator)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
matrix: RTCMemberStatus.Connected,
|
||||
media: {
|
||||
connection: {
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
},
|
||||
tracks: TrackState.WaitingForUser,
|
||||
},
|
||||
});
|
||||
|
||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||
// expect(localMembership.tracks$.value.length).toBe(0);
|
||||
|
||||
// -------
|
||||
localMembership.startTracks();
|
||||
// -------
|
||||
|
||||
await flushPromises();
|
||||
// expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
// matrix: RTCMemberStatus.Connected,
|
||||
// media: {
|
||||
// tracks: TrackState.Creating,
|
||||
// connection: ConnectionState.LivekitConnected,
|
||||
// },
|
||||
// });
|
||||
createTrackResolver.resolve();
|
||||
await flushPromises();
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.WaitingForUser);
|
||||
|
||||
// -------
|
||||
localMembership.requestJoinAndPublish();
|
||||
// -------
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
|
||||
publishResolver.resolve();
|
||||
await flushPromises();
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
|
||||
expect(publishers[0].destroy).not.toHaveBeenCalled();
|
||||
|
||||
expect(localMembership.localMemberState$.isStopped).toBe(false);
|
||||
scope.end();
|
||||
await flushPromises();
|
||||
// stays in connected state because it is stopped before the update to tracks update the state.
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(localMembership.localMemberState$.value as any).media,
|
||||
).toStrictEqual(PublishState.Publishing);
|
||||
// stop all tracks after ending scopes
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
});
|
||||
// TODO add tests for matrix local matrix participation.
|
||||
|
||||
describe("reconnecting analytics", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("does not fire CallReconnecting for the initial non-connected state at startup", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
// Simulate startup where membership isn't established yet
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([false, "membership"]);
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Membership is established — call is now connected
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).not.toHaveBeenCalled();
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([true, null]);
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
hsReason$.next([false, "sync"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"sync",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("reports livekit reason when livekit disconnects then reconnects", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const connectionState$ = new BehaviorSubject<ConnectionState>({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
const mutableConnection = {
|
||||
...connectionTransportAConnected,
|
||||
state$: connectionState$,
|
||||
} as unknown as Connection;
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(mutableConnection, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
|
||||
true,
|
||||
null,
|
||||
]),
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
connectionState$.next({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Disconnected,
|
||||
});
|
||||
connectionState$.next({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"livekit",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("fires one event per completed reconnection cycle", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([true, null]);
|
||||
|
||||
const connectionMap = new ConnectionMap();
|
||||
connectionMap.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
hsReason$.next([false, "membership"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
hsReason$.next([false, "probablyLeft"]);
|
||||
hsReason$.next([false, "sync"]);
|
||||
hsReason$.next([false, "membership"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledTimes(2);
|
||||
expect(trackSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"membership",
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(trackSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"probablyLeft",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleScreenSharing", () => {
|
||||
let originalMediaDevices: MediaDevices | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
// Screen sharing is only offered when getDisplayMedia is available.
|
||||
originalMediaDevices = navigator.mediaDevices;
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getDisplayMedia: vi.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: originalMediaDevices,
|
||||
});
|
||||
});
|
||||
|
||||
const createMembershipWithConnection = (
|
||||
connection: Connection | null,
|
||||
): {
|
||||
scope: ObservableScope;
|
||||
localMembership: ReturnType<typeof createLocalMembership$>;
|
||||
} => {
|
||||
const scope = new ObservableScope();
|
||||
const connectionMap = new ConnectionMap();
|
||||
if (connection) connectionMap.add(connection, []);
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
connectionMap$: constant(new Epoch(connectionMap)),
|
||||
localTransport$: constant(mockTransportLocator),
|
||||
});
|
||||
return { scope, localMembership };
|
||||
};
|
||||
|
||||
it("surfaces a failure and clears it on dismiss", async () => {
|
||||
const error = new Error("NotReadableError");
|
||||
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
|
||||
const connection = {
|
||||
state$: constant({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
}),
|
||||
transport: mockTransport,
|
||||
serverName: "example.org",
|
||||
livekitRoom: mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({
|
||||
isScreenShareEnabled: false,
|
||||
setScreenShareEnabled,
|
||||
}),
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
const { scope, localMembership } =
|
||||
createMembershipWithConnection(connection);
|
||||
await flushPromises();
|
||||
|
||||
expect(localMembership.toggleScreenSharing).not.toBeNull();
|
||||
expect(localMembership.screenShareError$.value).toBeNull();
|
||||
|
||||
localMembership.toggleScreenSharing!();
|
||||
await flushPromises();
|
||||
|
||||
expect(setScreenShareEnabled).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
expect(localMembership.screenShareError$.value).toBe(error);
|
||||
|
||||
localMembership.dismissScreenShareError();
|
||||
expect(localMembership.screenShareError$.value).toBeNull();
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("does nothing when there is no local participant", async () => {
|
||||
// No connection means participant$ never resolves to a participant.
|
||||
const { scope, localMembership } = createMembershipWithConnection(null);
|
||||
await flushPromises();
|
||||
|
||||
expect(localMembership.toggleScreenSharing).not.toBeNull();
|
||||
localMembership.toggleScreenSharing!();
|
||||
await flushPromises();
|
||||
|
||||
expect(localMembership.screenShareError$.value).toBeNull();
|
||||
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockConfig } from "../../../utils/test";
|
||||
import { getLocalTransport } from "./LocalTransport";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||
import { customTransport } from "../../../settings/settings";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
describe("LocalTransport", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
|
||||
it("returns preferred transport", async () => {
|
||||
// Use config so transport discovery succeeds
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
|
||||
expect(
|
||||
await getLocalTransport({
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "example.org",
|
||||
}),
|
||||
).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
serverName: "example.org",
|
||||
});
|
||||
});
|
||||
|
||||
describe("transport configuration mechanisms", () => {
|
||||
let client: MockedObject<
|
||||
Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">
|
||||
>;
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
customTransport.setValue(customTransport.defaultValue);
|
||||
client = {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
it("supports getting transport via application config", async () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
|
||||
expect(await getLocalTransport(client)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
serverName: "example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports getting transport via user settings", async () => {
|
||||
customTransport.setValue({
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
});
|
||||
|
||||
expect(await getLocalTransport(client)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
serverName: "example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports getting transport via backend", async () => {
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
]);
|
||||
|
||||
expect(await getLocalTransport(client)).toStrictEqual({
|
||||
transport: {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
serverName: "example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if no options are available", async () => {
|
||||
await expect(
|
||||
getLocalTransport({
|
||||
getDomain: () => "example.org",
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
}),
|
||||
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
|
||||
import { customTransport as customTransportSetting } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth/types.ts";
|
||||
|
||||
/**
|
||||
* Determines the transport to advertise in our MatrixRTC membership and publish
|
||||
* media on.
|
||||
*/
|
||||
export async function getLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
): Promise<TransportLocator> {
|
||||
const serverName = client.getDomain();
|
||||
if (serverName === null) throw new Error("No server name");
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: Config.get(),
|
||||
logger: rootLogger.getChild("[LocalTransport]"),
|
||||
});
|
||||
|
||||
// Respect the user's custom transport, if set
|
||||
const transport =
|
||||
customTransportSetting.value$.value ??
|
||||
(await discovery.discoverPreferredTransport());
|
||||
|
||||
if (transport === null) throw new MatrixRTCTransportMissingError(serverName);
|
||||
|
||||
return { transport, serverName };
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
import { constant } from "../../Behavior";
|
||||
import {
|
||||
flushPromises,
|
||||
mockLivekitRoom,
|
||||
mockMediaDevices,
|
||||
} from "../../../utils/test";
|
||||
import { Publisher } from "./Publisher";
|
||||
import { type Connection } from "../remoteMembers/Connection";
|
||||
import { type MuteStates } from "../../MuteStates";
|
||||
|
||||
let scope: ObservableScope;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
});
|
||||
|
||||
afterEach(() => scope.end());
|
||||
|
||||
function createMockLocalTrack(source: Track.Source): LocalTrack {
|
||||
const track = {
|
||||
source,
|
||||
isMuted: false,
|
||||
isUpstreamPaused: false,
|
||||
} as Partial<LocalTrack> as LocalTrack;
|
||||
|
||||
vi.mocked(track).mute = vi.fn().mockImplementation(() => {
|
||||
track.isMuted = true;
|
||||
});
|
||||
vi.mocked(track).unmute = vi.fn().mockImplementation(() => {
|
||||
track.isMuted = false;
|
||||
});
|
||||
vi.mocked(track).pauseUpstream = vi.fn().mockImplementation(() => {
|
||||
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
|
||||
track.isUpstreamPaused = true;
|
||||
});
|
||||
vi.mocked(track).resumeUpstream = vi.fn().mockImplementation(() => {
|
||||
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
|
||||
track.isUpstreamPaused = false;
|
||||
});
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
||||
enabled$: BehaviorSubject<boolean>;
|
||||
syncing$: BehaviorSubject<boolean>;
|
||||
setHandler: (h: (enabled: boolean) => void) => void;
|
||||
unsetHandler: () => void;
|
||||
} {
|
||||
let currentHandler = (enabled: boolean): void => {};
|
||||
|
||||
const ms = {
|
||||
enabled$,
|
||||
syncing$: new BehaviorSubject(false),
|
||||
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
|
||||
currentHandler = h;
|
||||
}),
|
||||
unsetHandler: vi.fn().mockImplementation(() => {
|
||||
currentHandler = (enabled: boolean): void => {};
|
||||
}),
|
||||
};
|
||||
// forward enabled$ emissions to the current handler
|
||||
enabled$.subscribe((enabled) => {
|
||||
logger.info(`MockMuteState: enabled changed to ${enabled}`);
|
||||
currentHandler(enabled);
|
||||
});
|
||||
|
||||
return ms;
|
||||
}
|
||||
|
||||
let connection: Connection;
|
||||
let muteStates: MuteStates;
|
||||
let localParticipant: LocalParticipant;
|
||||
let audioEnabled$: BehaviorSubject<boolean>;
|
||||
let videoEnabled$: BehaviorSubject<boolean>;
|
||||
let trackPublications: LocalTrackPublication[];
|
||||
// use it to control when track creation resolves, default to resolved
|
||||
let createTrackLock: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
trackPublications = [];
|
||||
audioEnabled$ = new BehaviorSubject(false);
|
||||
videoEnabled$ = new BehaviorSubject(false);
|
||||
createTrackLock = Promise.resolve();
|
||||
|
||||
muteStates = {
|
||||
audio: createMockMuteState(audioEnabled$),
|
||||
video: createMockMuteState(videoEnabled$),
|
||||
} as unknown as MuteStates;
|
||||
|
||||
const mockSendDataPacket = vi.fn();
|
||||
const mockEngine = {
|
||||
client: {
|
||||
sendUpdateLocalMetadata: vi.fn(),
|
||||
},
|
||||
on: vi.fn().mockReturnThis(),
|
||||
sendDataPacket: mockSendDataPacket,
|
||||
};
|
||||
|
||||
localParticipant = new LocalParticipant(
|
||||
"local-sid",
|
||||
"local-identity",
|
||||
// @ts-expect-error - for that test we want a real LocalParticipant to have the pending publications logic
|
||||
mockEngine,
|
||||
{
|
||||
adaptiveStream: true,
|
||||
dynacase: false,
|
||||
audioCaptureDefaults: {},
|
||||
videoCaptureDefaults: {},
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: "always",
|
||||
disconnectOnPageLeave: true,
|
||||
},
|
||||
new Map(),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
|
||||
vi.mocked(localParticipant).createTracks = vi
|
||||
.fn()
|
||||
.mockImplementation(async (opts) => {
|
||||
const tracks: LocalTrack[] = [];
|
||||
if (opts.audio) {
|
||||
tracks.push(createMockLocalTrack(Track.Source.Microphone));
|
||||
}
|
||||
if (opts.video) {
|
||||
tracks.push(createMockLocalTrack(Track.Source.Camera));
|
||||
}
|
||||
await createTrackLock;
|
||||
return tracks;
|
||||
});
|
||||
|
||||
vi.mocked(localParticipant).publishTrack = vi
|
||||
.fn()
|
||||
.mockImplementation(async (track: LocalTrack) => {
|
||||
const pub = {
|
||||
track,
|
||||
source: track.source,
|
||||
mute: track.mute,
|
||||
unmute: track.unmute,
|
||||
} as Partial<LocalTrackPublication> as LocalTrackPublication;
|
||||
trackPublications.push(pub);
|
||||
localParticipant.emit(ParticipantEvent.LocalTrackPublished, pub);
|
||||
return Promise.resolve(pub);
|
||||
});
|
||||
|
||||
vi.mocked(localParticipant).getTrackPublication = vi
|
||||
.fn()
|
||||
.mockImplementation((source: Track.Source) => {
|
||||
return trackPublications.find((pub) => pub.track?.source === source);
|
||||
});
|
||||
|
||||
connection = {
|
||||
state$: constant({
|
||||
state: "ConnectedToLkRoom",
|
||||
livekitConnectionState$: constant(LivekitConnectionState.Connected),
|
||||
}),
|
||||
livekitRoom: mockLivekitRoom({
|
||||
localParticipant: localParticipant,
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
});
|
||||
|
||||
describe("Publisher", () => {
|
||||
let publisher: Publisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new Publisher(
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("Should not create tracks if started muted to avoid unneeded permission requests", async () => {
|
||||
const createTracksSpy = vi.spyOn(
|
||||
connection.livekitRoom.localParticipant,
|
||||
"createTracks",
|
||||
);
|
||||
|
||||
audioEnabled$.next(false);
|
||||
videoEnabled$.next(false);
|
||||
await publisher.createAndSetupTracks();
|
||||
|
||||
expect(createTracksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should unsetHandler and stop tracks on destroy", async () => {
|
||||
// setup all spies
|
||||
const unsetVideoSpy = vi.spyOn(
|
||||
(
|
||||
publisher as unknown as {
|
||||
muteStates: { video: { unsetHandler: () => void } };
|
||||
}
|
||||
).muteStates.video,
|
||||
"unsetHandler",
|
||||
);
|
||||
const unsetAudioSpy = vi.spyOn(
|
||||
(
|
||||
publisher as unknown as {
|
||||
muteStates: { audio: { unsetHandler: () => void } };
|
||||
}
|
||||
).muteStates.audio,
|
||||
"unsetHandler",
|
||||
);
|
||||
const scopeEndSpy = vi.spyOn(
|
||||
(publisher as unknown as { scope: { end: () => void } }).scope,
|
||||
"end",
|
||||
);
|
||||
const stopTracksSpy = vi.spyOn(publisher, "stopTracks");
|
||||
// destroy publisher
|
||||
await publisher.destroy();
|
||||
|
||||
expect(stopTracksSpy).toHaveBeenCalledOnce();
|
||||
expect(unsetVideoSpy).toHaveBeenCalledOnce();
|
||||
expect(unsetAudioSpy).toHaveBeenCalledOnce();
|
||||
expect(scopeEndSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should minimize permission request by querying create at once", async () => {
|
||||
const enableCameraAndMicrophoneSpy = vi.spyOn(
|
||||
localParticipant,
|
||||
"enableCameraAndMicrophone",
|
||||
);
|
||||
const createTracksSpy = vi.spyOn(localParticipant, "createTracks");
|
||||
|
||||
audioEnabled$.next(true);
|
||||
videoEnabled$.next(true);
|
||||
await publisher.createAndSetupTracks();
|
||||
await flushPromises();
|
||||
|
||||
expect(enableCameraAndMicrophoneSpy).toHaveBeenCalled();
|
||||
|
||||
// It should create both at once
|
||||
expect(createTracksSpy).toHaveBeenCalledWith({
|
||||
audio: true,
|
||||
video: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("Ensure no data is streamed until publish has been called", async () => {
|
||||
audioEnabled$.next(true);
|
||||
await publisher.createAndSetupTracks();
|
||||
|
||||
// The track should be created and paused
|
||||
expect(localParticipant.createTracks).toHaveBeenCalledWith({
|
||||
audio: true,
|
||||
video: undefined,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(localParticipant.publishTrack).toHaveBeenCalled();
|
||||
|
||||
await flushPromises();
|
||||
const track = localParticipant.getTrackPublication(
|
||||
Track.Source.Microphone,
|
||||
)?.track;
|
||||
expect(track).toBeDefined();
|
||||
expect(track!.pauseUpstream).toHaveBeenCalled();
|
||||
expect(track!.isUpstreamPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("Ensure resume upstream when published is called", async () => {
|
||||
videoEnabled$.next(true);
|
||||
await publisher.createAndSetupTracks();
|
||||
// await flushPromises();
|
||||
await publisher.startPublishing();
|
||||
|
||||
const track = localParticipant.getTrackPublication(
|
||||
Track.Source.Camera,
|
||||
)?.track;
|
||||
expect(track).toBeDefined();
|
||||
// expect(track.pauseUpstream).toHaveBeenCalled();
|
||||
expect(track!.isUpstreamPaused).toBe(false);
|
||||
});
|
||||
|
||||
describe("Mute states", () => {
|
||||
let publisher: Publisher;
|
||||
beforeEach(() => {
|
||||
publisher = new Publisher(
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ mutes: { audioEnabled: true, videoEnabled: false } },
|
||||
{ mutes: { audioEnabled: true, videoEnabled: false } },
|
||||
])("only create the tracks that are unmuted $mutes", async ({ mutes }) => {
|
||||
// Ensure all muted
|
||||
audioEnabled$.next(mutes.audioEnabled);
|
||||
videoEnabled$.next(mutes.videoEnabled);
|
||||
|
||||
vi.mocked(connection.livekitRoom.localParticipant).createTracks = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await publisher.createAndSetupTracks();
|
||||
|
||||
expect(
|
||||
connection.livekitRoom.localParticipant.createTracks,
|
||||
).toHaveBeenCalledOnce();
|
||||
|
||||
expect(
|
||||
connection.livekitRoom.localParticipant.createTracks,
|
||||
).toHaveBeenCalledWith({
|
||||
audio: mutes.audioEnabled ? true : undefined,
|
||||
video: mutes.videoEnabled ? true : undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does mute unmute audio", async () => {});
|
||||
});
|
||||
|
||||
describe("Bug fix", () => {
|
||||
// There is a race condition when creating and publishing tracks while the mute state changes.
|
||||
// This race condition could cause tracks to be published even though they are muted at the
|
||||
// beginning of a call coming from lobby.
|
||||
// This is caused by our stack using manually the low level API to create and publish tracks,
|
||||
// but also using the higher level setMicrophoneEnabled and setCameraEnabled functions that also create
|
||||
// and publish tracks, and managing pending publications.
|
||||
// Race is as follow, on creation of the Publisher we create the tracks then publish them.
|
||||
// If in the middle of that process the mute state changes:
|
||||
// - the `setMicrophoneEnabled` will be no-op because it is not aware of our created track and can't see any pending publication
|
||||
// - If start publication is requested it will publish the track even though there was a mute request.
|
||||
it("wrongly publish tracks while muted", async () => {
|
||||
// setLogLevel(`debug`);
|
||||
const publisher = new Publisher(
|
||||
connection,
|
||||
mockMediaDevices({}),
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
audioEnabled$.next(true);
|
||||
|
||||
const resolvers = Promise.withResolvers<void>();
|
||||
createTrackLock = resolvers.promise;
|
||||
|
||||
// Initially the audio is unmuted, so creating tracks should publish the audio track
|
||||
const createTracks = publisher.createAndSetupTracks();
|
||||
void publisher.startPublishing();
|
||||
void createTracks.then(() => {
|
||||
void publisher.startPublishing();
|
||||
});
|
||||
// now mute the audio before allowing track creation to complete
|
||||
audioEnabled$.next(false);
|
||||
resolvers.resolve(undefined);
|
||||
await createTracks;
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const track = localParticipant.getTrackPublication(
|
||||
Track.Source.Microphone,
|
||||
)?.track;
|
||||
expect(track).toBeDefined();
|
||||
|
||||
try {
|
||||
expect(localParticipant.publishTrack).not.toHaveBeenCalled();
|
||||
} catch {
|
||||
expect(track!.mute).toHaveBeenCalled();
|
||||
expect(track!.isMuted).toBe(true);
|
||||
}
|
||||
await publisher.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,445 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import type { Behavior } from "../../Behavior.ts";
|
||||
import type { MediaDevices, SelectedDevice } from "../../MediaDevices.ts";
|
||||
import type { MuteStates } from "../../MuteStates.ts";
|
||||
import {
|
||||
type ProcessorState,
|
||||
trackProcessorSync,
|
||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||
|
||||
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
|
||||
/**
|
||||
* A wrapper for a Connection object.
|
||||
* This wrapper will manage the connection used to publish to the LiveKit room.
|
||||
* The Publisher is also responsible for creating the media tracks.
|
||||
*/
|
||||
export class Publisher {
|
||||
/**
|
||||
* By default, livekit will start publishing tracks as soon as they are created.
|
||||
* In the matrix RTC world, we want to control when tracks are published based
|
||||
* on whether the user is part of the RTC session or not.
|
||||
*/
|
||||
public shouldPublish = false;
|
||||
|
||||
private readonly scope = new ObservableScope();
|
||||
|
||||
/**
|
||||
* Creates a new Publisher.
|
||||
* @param connection - The connection to use for publishing.
|
||||
* @param devices - The media devices to use for audio and video input.
|
||||
* @param muteStates - The mute states for audio and video.
|
||||
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
|
||||
* @param logger - The logger to use for logging :D.
|
||||
* @param controlledAudioDevices - Whether the app hosting Element Call
|
||||
* controls the audio output devices, rather than the browser.
|
||||
*/
|
||||
public constructor(
|
||||
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
|
||||
devices: MediaDevices,
|
||||
private readonly muteStates: MuteStates,
|
||||
trackerProcessorState$: Behavior<ProcessorState>,
|
||||
private logger: Logger,
|
||||
controlledAudioDevices: boolean,
|
||||
) {
|
||||
const room = connection.livekitRoom;
|
||||
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
this.logger.error("Failed to set E2EE enabled on room", e);
|
||||
});
|
||||
|
||||
// Setup track processor syncing (blur)
|
||||
this.observeTrackProcessors(this.scope, room, trackerProcessorState$);
|
||||
// Observe media device changes and update LiveKit active devices accordingly
|
||||
this.observeMediaDevices(this.scope, devices, controlledAudioDevices);
|
||||
|
||||
this.workaroundRestartAudioInputTrackChrome(devices, this.scope);
|
||||
|
||||
this.connection.livekitRoom.localParticipant.on(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
this.onLocalTrackPublished.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.scope.end();
|
||||
this.logger.info("Scope ended -> unset handler");
|
||||
this.muteStates.audio.unsetHandler();
|
||||
this.muteStates.video.unsetHandler();
|
||||
|
||||
this.logger.info(`Start to stop tracks`);
|
||||
try {
|
||||
await this.stopTracks();
|
||||
this.logger.info(`Done to stop tracks`);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to stop tracks: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// LiveKit will publish the tracks as soon as they are created
|
||||
// but we want to control when tracks are published.
|
||||
// We cannot just mute the tracks, even if this will effectively stop the publishing,
|
||||
// it would also prevent the user from seeing their own video/audio preview.
|
||||
// So for that we use pauseUpStream(): Stops sending media to the server by replacing
|
||||
// the sender track with null, but keeps the local MediaStreamTrack active.
|
||||
// The user can still see/hear themselves locally, but remote participants see nothing.
|
||||
private onLocalTrackPublished(
|
||||
localTrackPublication: LocalTrackPublication,
|
||||
): void {
|
||||
this.logger.info("Local track published", localTrackPublication);
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
if (!this.shouldPublish) {
|
||||
this.logger.debug("Not publishing, pausing upstream");
|
||||
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
|
||||
this.logger.error(`Failed to pause upstreams`, e);
|
||||
});
|
||||
}
|
||||
if (localTrackPublication.source === Track.Source.Microphone) {
|
||||
const muteState = this.muteStates.audio;
|
||||
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||
// state while the handler is mid-flight, so the handler itself will apply
|
||||
// the correct mute state once it completes.
|
||||
if (!muteState.syncing$.value) {
|
||||
const enabled = muteState.enabled$.value;
|
||||
if (!enabled) {
|
||||
this.logger.info(
|
||||
"Local audio track just published but muted meanwhile, setting enabled to false",
|
||||
);
|
||||
lkRoom.localParticipant.setMicrophoneEnabled(false).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable microphone track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (localTrackPublication.source === Track.Source.Camera) {
|
||||
const muteState = this.muteStates.video;
|
||||
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||
// state while the handler is mid-flight, so the handler itself will apply
|
||||
// the correct mute state once it completes.
|
||||
if (!muteState.syncing$.value) {
|
||||
const enabled = muteState.enabled$.value;
|
||||
if (!enabled) {
|
||||
this.logger.info(
|
||||
"Local video track just published but muted meanwhile, setting enabled to false",
|
||||
);
|
||||
lkRoom.localParticipant.setCameraEnabled(false).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable camera track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create and setup local audio and video tracks based on the current mute states.
|
||||
* It creates the tracks only if audio and/or video is enabled, to avoid unnecessary
|
||||
* permission prompts.
|
||||
*
|
||||
* It also observes mute state changes to update LiveKit microphone/camera states accordingly.
|
||||
* If a track is not created initially because disabled, it will be created when unmuting.
|
||||
*
|
||||
* This call is not blocking anymore, instead callers can listen to the
|
||||
* `RoomEvent.MediaDevicesError` event in the LiveKit room to be notified of any errors.
|
||||
*
|
||||
*/
|
||||
public async createAndSetupTracks(): Promise<void> {
|
||||
this.logger.debug("createAndSetupTracks called");
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
// Observe mute state changes and update LiveKit microphone/camera states accordingly
|
||||
this.observeMuteStates();
|
||||
|
||||
// Check if audio and/or video is enabled. We only create tracks if enabled,
|
||||
// because it could prompt for permission, and we don't want to do that unnecessarily.
|
||||
const audio = this.muteStates.audio.enabled$.value;
|
||||
const video = this.muteStates.video.enabled$.value;
|
||||
|
||||
// We don't await the creation, because livekit could block until the tracks
|
||||
// are fully published, and not only that they are created.
|
||||
// We don't have control on that, localParticipant creates and publishes the tracks
|
||||
// asap.
|
||||
// We are using the `ParticipantEvent.LocalTrackPublished` to be notified
|
||||
// when tracks are actually published, and at that point
|
||||
// we can pause upstream if needed (depending on if startPublishing has been called).
|
||||
if (audio && video) {
|
||||
// Enable both at once in order to have a single permission prompt!
|
||||
void lkRoom.localParticipant.enableCameraAndMicrophone();
|
||||
} else if (audio) {
|
||||
void lkRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
} else if (video) {
|
||||
void lkRoom.localParticipant.setCameraEnabled(true);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private async pauseUpstreams(
|
||||
lkRoom: LivekitRoom,
|
||||
sources: Track.Source[],
|
||||
): Promise<void> {
|
||||
for (const source of sources) {
|
||||
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
|
||||
if (track) {
|
||||
await track.pauseUpstream();
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`No track found for source ${source} to pause upstream`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resumeUpstreams(
|
||||
lkRoom: LivekitRoom,
|
||||
sources: Track.Source[],
|
||||
): Promise<void> {
|
||||
for (const source of sources) {
|
||||
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
|
||||
if (track) {
|
||||
await track.resumeUpstream();
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`No track found for source ${source} to resume upstream`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Request to publish local tracks to the LiveKit room.
|
||||
* This will wait for the connection to be ready before publishing.
|
||||
* Livekit also have some local retry logic for publishing tracks.
|
||||
* Can be called multiple times, localparticipant manages the state of published tracks (or pending publications).
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
public async startPublishing(): Promise<void> {
|
||||
if (this.shouldPublish) {
|
||||
this.logger.debug(`Already publishing, ignoring startPublishing call`);
|
||||
return;
|
||||
}
|
||||
this.shouldPublish = true;
|
||||
this.logger.debug("startPublishing called");
|
||||
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
|
||||
// Resume upstream for both audio and video tracks
|
||||
// We need to call it explicitly because call setTrackEnabled does not always
|
||||
// resume upstream. It will only if you switch the track from disabled to enabled,
|
||||
// but if the track is already enabled but upstream is paused, it won't resume it.
|
||||
// TODO what about screen share?
|
||||
try {
|
||||
await this.resumeUpstreams(lkRoom, [
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
]);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to resume upstreams`, e);
|
||||
}
|
||||
}
|
||||
|
||||
public async stopPublishing(): Promise<void> {
|
||||
this.logger.debug("stopPublishing called");
|
||||
this.shouldPublish = false;
|
||||
// Pause upstream will stop sending media to the server, while keeping
|
||||
// the local MediaStreamTrack active, so the user can still see themselves.
|
||||
await this.pauseUpstreams(this.connection.livekitRoom, [
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
Track.Source.ScreenShare,
|
||||
]);
|
||||
}
|
||||
|
||||
public async stopTracks(): Promise<void> {
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
for (const source of [
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
Track.Source.ScreenShare,
|
||||
]) {
|
||||
const localPub = lkRoom.localParticipant.getTrackPublication(source);
|
||||
if (localPub?.track) {
|
||||
// stops and unpublishes the track
|
||||
await lkRoom.localParticipant.unpublishTrack(localPub!.track, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Private methods
|
||||
|
||||
// Restart the audio input track whenever we detect that the active media
|
||||
// device has changed to refer to a different hardware device. We do this
|
||||
// for the sake of Chrome, which provides a "default" device that is meant
|
||||
// to match the system's default audio input, whatever that may be.
|
||||
// This is special-cased for only audio inputs because we need to dig around
|
||||
// in the LocalParticipant object for the track object and there's not a nice
|
||||
// way to do that generically. There is usually no OS-level default video capture
|
||||
// device anyway, and audio outputs work differently.
|
||||
private workaroundRestartAudioInputTrackChrome(
|
||||
devices: MediaDevices,
|
||||
scope: ObservableScope,
|
||||
): void {
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
devices.audioInput.selected$
|
||||
.pipe(
|
||||
switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() => {
|
||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
||||
const activeMicTrack = Array.from(
|
||||
lkRoom.localParticipant.audioTrackPublications.values(),
|
||||
).find((d) => d.source === Track.Source.Microphone)?.track;
|
||||
|
||||
if (
|
||||
activeMicTrack &&
|
||||
// only restart if the stream is still running: LiveKit will detect
|
||||
// when a track stops & restart appropriately, so this is not our job.
|
||||
// Plus, we need to avoid restarting again if the track is already in
|
||||
// the process of being restarted.
|
||||
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
||||
) {
|
||||
this.logger?.info(
|
||||
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
|
||||
);
|
||||
// Restart the track, which will cause Livekit to do another
|
||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||
// the deviceId hasn't changed (was & still is default).
|
||||
lkRoom.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.audioTrack?.restartTrack()
|
||||
.catch((e) => {
|
||||
this.logger.error(`Failed to restart audio device track`, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Observe changes in the selected media devices and update the LiveKit room accordingly.
|
||||
private observeMediaDevices(
|
||||
scope: ObservableScope,
|
||||
devices: MediaDevices,
|
||||
controlledAudioDevices: boolean,
|
||||
): void {
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription =>
|
||||
selected$.pipe(scope.bind()).subscribe((device) => {
|
||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
||||
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
||||
this.logger.info(
|
||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||
lkRoom.getActiveDevice(kind),
|
||||
" !== ",
|
||||
device?.id,
|
||||
);
|
||||
if (
|
||||
device !== undefined &&
|
||||
lkRoom.getActiveDevice(kind) !== device.id
|
||||
) {
|
||||
lkRoom
|
||||
.switchActiveDevice(kind, device.id)
|
||||
.catch((e: Error) =>
|
||||
this.logger.error(
|
||||
`Failed to sync ${kind} device with LiveKit`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
syncDevice("audioinput", devices.audioInput.selected$);
|
||||
if (!controlledAudioDevices)
|
||||
syncDevice("audiooutput", devices.audioOutput.selected$);
|
||||
syncDevice("videoinput", devices.videoInput.selected$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe changes in the mute states and update the LiveKit room accordingly.
|
||||
* @private
|
||||
*/
|
||||
private observeMuteStates(): void {
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
this.muteStates.audio.setHandler(async (enable) => {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`handler: Setting LiveKit microphone enabled: ${enable}`,
|
||||
);
|
||||
await lkRoom.localParticipant.setMicrophoneEnabled(enable);
|
||||
// Unmute will restart the track if it was paused upstream,
|
||||
// but until explicitly requested, we want to keep it paused.
|
||||
if (!this.shouldPublish && enable) {
|
||||
await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]);
|
||||
}
|
||||
return enable;
|
||||
} catch (e) {
|
||||
this.logger.error("Failed to update LiveKit audio input mute state", e);
|
||||
return lkRoom.localParticipant.isMicrophoneEnabled;
|
||||
}
|
||||
});
|
||||
this.muteStates.video.setHandler(async (enable) => {
|
||||
try {
|
||||
this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`);
|
||||
await lkRoom.localParticipant.setCameraEnabled(enable);
|
||||
// Unmute will restart the track if it was paused upstream,
|
||||
// but until explicitly requested, we want to keep it paused.
|
||||
if (!this.shouldPublish && enable) {
|
||||
await this.pauseUpstreams(lkRoom, [Track.Source.Camera]);
|
||||
}
|
||||
return enable;
|
||||
} catch (e) {
|
||||
this.logger.error("Failed to update LiveKit video input mute state", e);
|
||||
return lkRoom.localParticipant.isCameraEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private observeTrackProcessors(
|
||||
scope: ObservableScope,
|
||||
room: LivekitRoom,
|
||||
trackerProcessorState$: Behavior<ProcessorState>,
|
||||
): void {
|
||||
const track$ = scope.behavior(
|
||||
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
trackProcessorSync(scope, track$, trackerProcessorState$);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type UnstableLivekitTransport,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
|
||||
import {
|
||||
RtcTransportAutoDiscovery,
|
||||
type RtcTransportAutoDiscoveryProps,
|
||||
} from "./RtcTransportAutoDiscovery.ts";
|
||||
|
||||
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
||||
|
||||
const backendTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://backend.example.org",
|
||||
};
|
||||
|
||||
const configTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://config.example.org",
|
||||
};
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
return {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
function makeResolvedConfig(livekitServiceUrl?: string): ResolvedConfigOptions {
|
||||
return {
|
||||
livekit: livekitServiceUrl
|
||||
? {
|
||||
livekit_service_url: livekitServiceUrl,
|
||||
}
|
||||
: undefined,
|
||||
} as ResolvedConfigOptions;
|
||||
}
|
||||
|
||||
describe("RtcTransportAutoDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
{ transports: [backendTransport] },
|
||||
// will pick the first livekit transport in the list, even if there are other non-livekit transports
|
||||
{ transports: [{ type: "not_livekit" }, backendTransport] },
|
||||
];
|
||||
it.each(VALID_TEST_CASES)(
|
||||
"prefers backend transport other app config $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
new MatrixError(
|
||||
{
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
error: "Too many requests",
|
||||
retry_after_ms: 100,
|
||||
},
|
||||
429,
|
||||
),
|
||||
)
|
||||
.mockResolvedValue([backendTransport]);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig("https://config.example.org"),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
|
||||
backendTransport,
|
||||
);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
{ transports: [] },
|
||||
{ transports: [{ type: "not_livekit" }] },
|
||||
];
|
||||
it.each(INVALID_TEST_CASES)(
|
||||
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
expect(discoveredTransport).not.toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).toStrictEqual(configTransport);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns null when backend and config are all unavailable", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(undefined),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
isUnstableLivekitTransport,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
|
||||
import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
|
||||
export interface RtcTransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
resolvedConfig: ResolvedConfigOptions;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class RtcTransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly resolvedConfig: ResolvedConfigOptions;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
resolvedConfig,
|
||||
logger,
|
||||
}: RtcTransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.resolvedConfig = resolvedConfig;
|
||||
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
||||
}
|
||||
|
||||
public async discoverPreferredTransport(): Promise<UnstableLivekitTransport | null> {
|
||||
// 1) backend transports
|
||||
const backendTransport = await this.tryBackendTransports();
|
||||
if (backendTransport) {
|
||||
this.logger.info(
|
||||
`Found backend transport: ${JSON.stringify(backendTransport)}`,
|
||||
);
|
||||
return backendTransport;
|
||||
}
|
||||
|
||||
// 2) app config URL
|
||||
const configTransport = this.tryConfigTransport();
|
||||
if (configTransport) {
|
||||
this.logger.info(
|
||||
`Found app config transport: ${JSON.stringify(configTransport)}`,
|
||||
);
|
||||
return configTransport;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first rtc_foci from the backend.
|
||||
* 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<UnstableLivekitTransport | null> {
|
||||
const client = this.client;
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
try {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
const first = transportList.find(isUnstableLivekitTransport);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`No livekit transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryConfigTransport(): UnstableLivekitTransport | null {
|
||||
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
||||
if (url) {
|
||||
return {
|
||||
type: "livekit",
|
||||
livekit_service_url: url,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,749 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionState as LivekitConnectionState,
|
||||
} from "livekit-client";
|
||||
import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type LivekitGetTokenResponse,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
Connection,
|
||||
type ConnectionState,
|
||||
type ConnectionOpts,
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import { FailToGetOpenIdToken } from "../../../utils/errors.ts";
|
||||
import { testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||
import { mockRemoteParticipant, ownMemberMock } from "../../../utils/test.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
} from "../../../livekit/auth";
|
||||
|
||||
let testScope: ObservableScope;
|
||||
|
||||
let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
|
||||
|
||||
let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
||||
|
||||
let localParticipantEventEmiter: EventEmitter;
|
||||
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
const SLOT_ID = "m.call#ROOM";
|
||||
|
||||
const transport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
||||
};
|
||||
|
||||
function setupTest(): void {
|
||||
testScope = new ObservableScope();
|
||||
client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "example.org",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||
});
|
||||
|
||||
localParticipantEventEmiter = new EventEmitter();
|
||||
|
||||
fakeLocalParticipant = vi.mocked<LocalParticipant>({
|
||||
identity: "@me:example.org",
|
||||
isMicrophoneEnabled: vi.fn().mockReturnValue(true),
|
||||
getTrackPublication: vi.fn().mockReturnValue(undefined),
|
||||
on: localParticipantEventEmiter.on.bind(localParticipantEventEmiter),
|
||||
off: localParticipantEventEmiter.off.bind(localParticipantEventEmiter),
|
||||
addListener: localParticipantEventEmiter.addListener.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
removeListener: localParticipantEventEmiter.removeListener.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
removeAllListeners: localParticipantEventEmiter.removeAllListeners.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
} as unknown as LocalParticipant);
|
||||
|
||||
const fakeRoomEventEmitter = new EventEmitter();
|
||||
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
remoteParticipants: new Map(),
|
||||
localParticipant: fakeLocalParticipant,
|
||||
state: LivekitConnectionState.Disconnected,
|
||||
on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
|
||||
off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
|
||||
addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
|
||||
removeListener:
|
||||
fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
|
||||
removeAllListeners:
|
||||
fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
|
||||
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
|
||||
emit: (eventName: string | symbol, ...args: unknown[]) => {
|
||||
fakeRoomEventEmitter.emit(eventName, ...args);
|
||||
},
|
||||
} as unknown as LivekitRoom);
|
||||
}
|
||||
|
||||
function setupRemoteConnection(): Connection {
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
|
||||
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||
const changeEv = RoomEvent.ConnectionStateChanged;
|
||||
|
||||
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
|
||||
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||
fakeLivekitRoom.state = LivekitConnectionState.Connected;
|
||||
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return new Connection(opts, logger);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
describe("Start connection states", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.post(
|
||||
`https://matrix-rtc.example.org/livekit/jwt/get_token`,
|
||||
() => {
|
||||
return {
|
||||
// Return a non-retryable error, if not, the retry logic will
|
||||
// wait and fail the test with a timeout.
|
||||
status: 404,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("start in initialized state", () => {
|
||||
setupTest();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
expect(connection.state$.getValue()).toEqual({ state: "initialized" });
|
||||
});
|
||||
|
||||
it("fail to get SFU token then error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
|
||||
|
||||
client._unstable_getLivekitToken.mockImplementation(
|
||||
async (): Promise<LivekitGetTokenResponse> => {
|
||||
return await deferred.promise;
|
||||
},
|
||||
);
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
if (capturedState?.state === "error") {
|
||||
expect(capturedState.error.message).toEqual("Something went wrong");
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fail to get JWT token and error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferredSFU = Promise.withResolvers<void>();
|
||||
client._unstable_getLivekitToken.mockImplementation(async () => {
|
||||
await deferredSFU.promise;
|
||||
throw new MatrixError(
|
||||
{
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferredSFU.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
|
||||
if (
|
||||
capturedState?.state === "error" &&
|
||||
capturedState.error.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.error.cause.message).toContain(
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fail to connect to livekit error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferredSFU = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockImplementation(async () => {
|
||||
await deferredSFU.promise;
|
||||
throw new Error("Failed to connect to livekit");
|
||||
});
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferredSFU.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
|
||||
if (
|
||||
capturedState?.state === "error" &&
|
||||
capturedState.error.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.error.cause.message).toContain(
|
||||
"Failed to connect to livekit",
|
||||
);
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("connection states happy path", async () => {
|
||||
vi.useFakeTimers();
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
await connection.start();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const initialState = capturedStates.shift();
|
||||
expect(initialState).toEqual({ state: "initialized" });
|
||||
const fetchingState = capturedStates.shift();
|
||||
expect(fetchingState).toEqual({ state: "authenticating" });
|
||||
const disconnectedState = capturedStates.shift();
|
||||
expect(disconnectedState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Disconnected,
|
||||
});
|
||||
const connectingState = capturedStates.shift();
|
||||
expect(connectingState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connecting,
|
||||
});
|
||||
const connectedState = capturedStates.shift();
|
||||
expect(connectedState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
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 caller,
|
||||
// so a rejection would be unhandled).
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
|
||||
expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
|
||||
});
|
||||
|
||||
it("shutting down the scope should stop the connection", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
await connection.start();
|
||||
|
||||
const stopSpy = vi.spyOn(connection, "stop");
|
||||
testScope.end();
|
||||
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
expect(fakeLivekitRoom.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote participants", () => {
|
||||
it("emits the list of remote participants", () => {
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
// The remoteParticipants$ observable is derived from the current members of the
|
||||
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||
// on this connection.
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||
// Mock Dan to have no published tracks. We want him to still show show up
|
||||
// in the participants list.
|
||||
mockRemoteParticipant({
|
||||
identity: "@dan:example.org:DEV333",
|
||||
getTrackPublication: () => undefined,
|
||||
getTrackPublications: () => [],
|
||||
}),
|
||||
];
|
||||
|
||||
// Let's simulate 3 members on the livekitRoom
|
||||
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||
() => new Map(participants.map((p) => [p.identity, p])),
|
||||
);
|
||||
|
||||
participants.forEach((p) =>
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||
);
|
||||
|
||||
// At this point there should be ~~no~~ publishers
|
||||
// We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
|
||||
// The filtering we do is just based on the matrixRTC member events.
|
||||
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||
|
||||
participants = [
|
||||
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||
mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
|
||||
];
|
||||
participants.forEach((p) =>
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||
);
|
||||
|
||||
// At this point there should be no publishers
|
||||
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||
});
|
||||
|
||||
it("should be scoped to parent scope", (): void => {
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
let observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
];
|
||||
|
||||
// Let's simulate 3 members on the livekitRoom
|
||||
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||
() => new Map(participants.map((p) => [p.identity, p])),
|
||||
);
|
||||
|
||||
for (const participant of participants) {
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||
}
|
||||
|
||||
// We should have bob as a participant now
|
||||
const ps = observedParticipants.pop();
|
||||
expect(ps?.length).toEqual(1);
|
||||
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||
|
||||
// end the parent scope
|
||||
testScope.end();
|
||||
observedParticipants = [];
|
||||
|
||||
// SHOULD NOT emit any more participants as the scope is ended
|
||||
participants = participants.filter(
|
||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||
);
|
||||
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
);
|
||||
|
||||
expect(observedParticipants.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote track logging", () => {
|
||||
it("logs remote participant and track events on the connection logger", () => {
|
||||
setupTest();
|
||||
const info = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const testLogger = {
|
||||
getChild: (): unknown => testLogger,
|
||||
info,
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
new Connection(
|
||||
{
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
},
|
||||
testLogger,
|
||||
);
|
||||
info.mockClear(); // drop the constructor log line
|
||||
|
||||
const bob = mockRemoteParticipant({
|
||||
identity: "@bob:example.org:DEV111",
|
||||
sid: "PA_bob",
|
||||
});
|
||||
const pub = {
|
||||
kind: "audio",
|
||||
source: "microphone",
|
||||
trackSid: "TR_mic",
|
||||
isMuted: false,
|
||||
isEncrypted: true,
|
||||
} as unknown as RemoteTrackPublication;
|
||||
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.TrackSubscribed,
|
||||
{} as RemoteTrack,
|
||||
pub,
|
||||
bob,
|
||||
);
|
||||
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.TrackStreamStateChanged,
|
||||
pub,
|
||||
Track.StreamState.Paused,
|
||||
bob,
|
||||
);
|
||||
expect(messages()).toEqual([
|
||||
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
|
||||
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
|
||||
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
]);
|
||||
|
||||
// Encryption status changes are logged; cryptor errors are warnings
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
false,
|
||||
bob,
|
||||
);
|
||||
expect(messages().at(-1)).toBe(
|
||||
"Encryption status of @bob:example.org:DEV111: encrypted=false",
|
||||
);
|
||||
const cryptorError = new Error("missing key at index 3");
|
||||
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Encryption error for @bob:example.org:DEV111:",
|
||||
cryptorError,
|
||||
);
|
||||
|
||||
// Local mute events are already logged by the Publisher
|
||||
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
|
||||
...fakeLocalParticipant,
|
||||
isLocal: true,
|
||||
} as unknown as LocalParticipant);
|
||||
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
|
||||
|
||||
// Listeners are removed when the scope ends
|
||||
testScope.end();
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
|
||||
expect(
|
||||
messages().filter((m) => m.startsWith("Participant disconnected")),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
//
|
||||
// NOT USED ANYMORE ?
|
||||
//
|
||||
// This setup look like sth for the Publisher. Not a connection.
|
||||
|
||||
// describe("PublishConnection", () => {
|
||||
// // let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
|
||||
// let roomFactoryMock: Mock<() => LivekitRoom>;
|
||||
// let muteStates: MockedObject<MuteStates>;
|
||||
|
||||
// function setUpPublishConnection(): void {
|
||||
// setupTest();
|
||||
|
||||
// roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
|
||||
|
||||
// muteStates = mockMuteStates();
|
||||
|
||||
// // fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
|
||||
// // name: "BackgroundBlur",
|
||||
// // restart: vi.fn().mockResolvedValue(undefined),
|
||||
// // setOptions: vi.fn().mockResolvedValue(undefined),
|
||||
// // getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
|
||||
// // isRunning: vi.fn().mockReturnValue(false)
|
||||
// // });
|
||||
// }
|
||||
|
||||
// describe("Livekit room creation", () => {
|
||||
// function createSetup(): void {
|
||||
// setUpPublishConnection();
|
||||
|
||||
// const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
|
||||
// supported: true,
|
||||
// processor: undefined,
|
||||
// });
|
||||
|
||||
// const opts: ConnectionOpts = {
|
||||
// client: client,
|
||||
// transport: livekitFocus,
|
||||
// scope: testScope,
|
||||
// livekitRoomFactory: roomFactoryMock,
|
||||
// };
|
||||
|
||||
// const audioInput = {
|
||||
// available$: of(new Map([["mic1", { id: "mic1" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "mic1" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// const videoInput = {
|
||||
// available$: of(new Map([["cam1", { id: "cam1" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "cam1" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// const audioOutput = {
|
||||
// available$: of(new Map([["speaker", { id: "speaker" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "speaker" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// // TODO understand what is wrong with our mocking that requires ts-expect-error
|
||||
// const fakeDevices = mockMediaDevices({
|
||||
// // @ts-expect-error Mocking only
|
||||
// audioInput,
|
||||
// // @ts-expect-error Mocking only
|
||||
// videoInput,
|
||||
// // @ts-expect-error Mocking only
|
||||
// audioOutput,
|
||||
// });
|
||||
|
||||
// new Connection(
|
||||
// opts,
|
||||
// fakeDevices,
|
||||
// muteStates,
|
||||
// undefined,
|
||||
// fakeTrackProcessorSubject$,
|
||||
// );
|
||||
// }
|
||||
|
||||
// it("should create room with proper initial audio and video settings", () => {
|
||||
// createSetup();
|
||||
|
||||
// expect(roomFactoryMock).toHaveBeenCalled();
|
||||
|
||||
// const lastCallArgs =
|
||||
// roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
|
||||
|
||||
// const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
|
||||
// expect(roomOptions).toBeDefined();
|
||||
|
||||
// expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
|
||||
// expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
|
||||
// expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
|
||||
// });
|
||||
|
||||
// it("respect controlledAudioDevices", () => {
|
||||
// // TODO: Refactor the code to make it testable.
|
||||
// // The UrlParams module is a singleton has a cache and is very hard to test.
|
||||
// // This breaks other tests as well if not handled properly.
|
||||
// // vi.mock(import("./../UrlParams"), () => {
|
||||
// // return {
|
||||
// // getUrlParams: vi.fn().mockReturnValue({
|
||||
// // controlledAudioDevices: true
|
||||
// // })
|
||||
// // };
|
||||
// // });
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@@ -1,417 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
connectedParticipantsObserver,
|
||||
connectionStateObserver,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type ConnectionState as LivekitConnectionState,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
type SubscriptionError,
|
||||
type Track,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { 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 ObservableScope } from "../../ObservableScope.ts";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
PeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
getSFUConfig,
|
||||
type ClientOpenIDParts,
|
||||
type SFUConfig,
|
||||
} from "../../../livekit/auth";
|
||||
|
||||
export interface ConnectionOpts {
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** Whether we want to publish or only subscribe on this connection. */
|
||||
role: "publisher" | "subscriber";
|
||||
/** The media transport to connect to. */
|
||||
transport: UnstableLivekitTransport;
|
||||
/** The name of the homeserver to which the {@link transport} belongs. */
|
||||
serverName: string;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
/** The ID of the Matrix room in which the session takes place. */
|
||||
roomId: string;
|
||||
/** The ID of the MatrixRTC slot in which the session takes place. */
|
||||
slotId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
scope: ObservableScope;
|
||||
|
||||
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
||||
livekitRoomFactory: () => LivekitRoom;
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
// The start state of a connection. It has been created but nothing has loaded yet.
|
||||
| { state: "initialized" }
|
||||
// `start` has been called on the connection. It acquires the JWT token to connect to the LK room.
|
||||
| { state: "authenticating" }
|
||||
| { state: "authenticated"; livekitState: LivekitConnectionState }
|
||||
| { state: "error"; error: ElementCallError }
|
||||
| { state: "stopped" };
|
||||
|
||||
/**
|
||||
* A connection to a Matrix RTC LiveKit backend.
|
||||
*
|
||||
* Expose observables for participants and connection state.
|
||||
*/
|
||||
export class Connection {
|
||||
// Private Behavior
|
||||
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
||||
state: "initialized",
|
||||
});
|
||||
|
||||
/**
|
||||
* The current state of the connection to the media transport.
|
||||
*/
|
||||
public readonly state$: Behavior<ConnectionState> = this._state$;
|
||||
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on this connection.
|
||||
*/
|
||||
public readonly role: "publisher" | "subscriber";
|
||||
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: UnstableLivekitTransport;
|
||||
|
||||
/**
|
||||
* The name of the homeserver to which the {@link transport} belongs.
|
||||
*/
|
||||
public readonly serverName: string;
|
||||
|
||||
public readonly livekitRoom: LivekitRoom;
|
||||
|
||||
private scope: ObservableScope;
|
||||
|
||||
/**
|
||||
* The remote LiveKit participants that are visible on this connection.
|
||||
*
|
||||
* Note that this may include participants that are connected only to
|
||||
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||
* It is therefore more low-level than what should be presented to the user.
|
||||
*/
|
||||
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||
|
||||
/**
|
||||
* The alias of the LiveKit room.
|
||||
*/
|
||||
public get livekitAlias(): string | undefined {
|
||||
return this._livekitAlias;
|
||||
}
|
||||
private _livekitAlias?: string;
|
||||
|
||||
/**
|
||||
* Whether the connection has been stopped.
|
||||
* @see Connection.stop
|
||||
* */
|
||||
protected stopped = false;
|
||||
|
||||
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
||||
private readonly client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
private readonly roomId: string;
|
||||
private readonly slotId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.roomId = opts.roomId;
|
||||
this.slotId = opts.slotId;
|
||||
this.logger = logger.getChild(
|
||||
`[Connection ${JSON.stringify(opts.transport)}]`,
|
||||
);
|
||||
this.logger.debug(`constructor called`);
|
||||
const { role, transport, serverName, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.role = role;
|
||||
this.transport = transport;
|
||||
this.serverName = serverName;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
this.logRemoteTrackEvents();
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the lifecycle of remote participants and their tracks as seen by
|
||||
* livekit-client, so that rageshakes show what a tile should have been
|
||||
* rendering (published, subscribed, muted, paused) for each remote member.
|
||||
*/
|
||||
private logRemoteTrackEvents(): void {
|
||||
const room = this.livekitRoom;
|
||||
const log = this.logger.getChild("[RemoteTracks]");
|
||||
// The encryption flag matters: if the publisher encrypts but this client
|
||||
// believes the track is unencrypted, the cryptor is bypassed and raw
|
||||
// ciphertext reaches the decoder (audible as loud noise bursts).
|
||||
const track = (pub: TrackPublication, p: Participant): string =>
|
||||
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
|
||||
|
||||
const onParticipantConnected = (p: RemoteParticipant): void =>
|
||||
log.info(`Participant connected: ${p.identity} (${p.sid})`);
|
||||
const onParticipantDisconnected = (p: RemoteParticipant): void =>
|
||||
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
|
||||
const onTrackPublished = (
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||
const onTrackUnpublished = (
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Unpublished: ${track(pub, p)}`);
|
||||
const onTrackSubscribed = (
|
||||
_t: Track,
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||
const onTrackUnsubscribed = (
|
||||
_t: Track,
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
|
||||
const onTrackSubscriptionFailed = (
|
||||
trackSid: string,
|
||||
p: RemoteParticipant,
|
||||
reason?: SubscriptionError,
|
||||
): void =>
|
||||
log.warn(
|
||||
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
|
||||
);
|
||||
// Mute events also fire for the local participant, which the Publisher
|
||||
// already logs.
|
||||
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
|
||||
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
|
||||
};
|
||||
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
|
||||
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
|
||||
};
|
||||
const onTrackStreamStateChanged = (
|
||||
pub: RemoteTrackPublication,
|
||||
state: Track.StreamState,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
|
||||
const onEncryptionStatusChanged = (
|
||||
encrypted: boolean,
|
||||
p?: Participant,
|
||||
): void =>
|
||||
log.info(
|
||||
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
|
||||
);
|
||||
// livekit-client throttles these per cryptor; they indicate frames being
|
||||
// dropped (missing/invalid key).
|
||||
const onEncryptionError = (error: Error, p?: Participant): void =>
|
||||
log.warn(
|
||||
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
.on(RoomEvent.TrackPublished, onTrackPublished)
|
||||
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.on(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.on(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||
this.scope.onEnd(() => {
|
||||
room
|
||||
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
.off(RoomEvent.TrackPublished, onTrackPublished)
|
||||
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.off(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.off(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the connection.
|
||||
*
|
||||
* This will:
|
||||
* 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.)
|
||||
* 2. Use this token to request the SFU config to the MatrixRtc authentication service.
|
||||
* 3. Connect to the configured LiveKit room.
|
||||
*
|
||||
* The errors are also represented as a state in the `state$` observable.
|
||||
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
|
||||
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
||||
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
||||
*/
|
||||
// TODO consider an autostart pattern...
|
||||
public async start(): Promise<void> {
|
||||
this.logger.debug("Starting Connection");
|
||||
this.stopped = false;
|
||||
try {
|
||||
this._state$.next({ state: "authenticating" });
|
||||
const { url, jwt, livekitAlias } = await this.getSFUConfig();
|
||||
this.logger.debug(
|
||||
"Starting Connection - jwt: ",
|
||||
jwt,
|
||||
"wss: ",
|
||||
url,
|
||||
"livekitAlias: ",
|
||||
livekitAlias,
|
||||
);
|
||||
this._livekitAlias = livekitAlias;
|
||||
// If we were stopped while fetching the config, don't proceed to connect
|
||||
if (this.stopped) return;
|
||||
|
||||
// Setup observer once we are done with getSFUConfig
|
||||
connectionStateObserver(this.livekitRoom)
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((livekitState) =>
|
||||
this._state$.next({ state: "authenticated", livekitState }),
|
||||
);
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (e instanceof ConnectionError) {
|
||||
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||
throw new InsufficientCapacityError();
|
||||
}
|
||||
if (e.status === 404) {
|
||||
// error msg is "Failed to create call"
|
||||
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
|
||||
// The room does not exist. There are two different modes of operation for the SFU:
|
||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
|
||||
if (e.reason === ConnectionErrorReason.Timeout) {
|
||||
// Unabled to establish peer connection within the timeout
|
||||
throw new PeerConnectionTimeoutError();
|
||||
}
|
||||
|
||||
throw new LivekitConnectionError(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// 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 necessarily awaited by the caller, 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({
|
||||
state: "error",
|
||||
error:
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: error instanceof Error
|
||||
? new UnknownCallError(error)
|
||||
: new UnknownCallError(new Error(`${error}`)),
|
||||
});
|
||||
// Its okay to ignore the throw. The error is part of the state.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async getSFUConfig(): Promise<SFUConfig> {
|
||||
return await getSFUConfig({
|
||||
client: this.client,
|
||||
membership: this.ownMembershipIdentity,
|
||||
role: this.role,
|
||||
transport: this.transport,
|
||||
serverName: this.serverName,
|
||||
roomId: this.roomId,
|
||||
slotId: this.slotId,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the connection.
|
||||
*
|
||||
* This will disconnect from the LiveKit room.
|
||||
* If the connection is already stopped, this is a no-op.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.debug("stop: disconnecing from lk room");
|
||||
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({ state: "stopped" });
|
||||
this.logger.debug("stop: DONE disconnecing from lk room");
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 {
|
||||
Room as LivekitRoom,
|
||||
type RoomOptions,
|
||||
type BaseKeyProvider,
|
||||
type E2EEManagerOptions,
|
||||
type BaseE2EEManager,
|
||||
} from "livekit-client";
|
||||
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 UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Connection } from "./Connection.ts";
|
||||
import type { MediaDevices } from "../../MediaDevices.ts";
|
||||
import type { Behavior } from "../../Behavior.ts";
|
||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { getLiveKitOptions } from "../../../livekit/options.ts";
|
||||
import {
|
||||
advancedCamera,
|
||||
cameraResolution,
|
||||
cameraFramerate,
|
||||
cameraBitrate,
|
||||
cameraCodec,
|
||||
parseResolution,
|
||||
echoCancellationSetting,
|
||||
noiseSuppressionSetting,
|
||||
autoGainControlSetting,
|
||||
} from "../../../settings/settings.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
} from "../../../livekit/auth";
|
||||
|
||||
// TODO evaluate if this should be done like the Publisher Factory
|
||||
export interface ConnectionFactory {
|
||||
createConnection(
|
||||
scope: ObservableScope,
|
||||
role: "publisher" | "subscriber",
|
||||
transport: UnstableLivekitTransport,
|
||||
serverName: string,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
): Connection;
|
||||
}
|
||||
|
||||
export class ECConnectionFactory implements ConnectionFactory {
|
||||
private readonly livekitRoomFactory: () => LivekitRoom;
|
||||
|
||||
/**
|
||||
* Creates a ConnectionFactory for LiveKit connections.
|
||||
*
|
||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||
* @param roomId - The ID of the Matrix room in which the session takes place.
|
||||
* @param slotId - The ID of the MatrixRTC slot in which the session takes place.
|
||||
* @param devices - Used for video/audio out/in capture options.
|
||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
|
||||
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
|
||||
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
||||
*/
|
||||
public constructor(
|
||||
private client: ClientGetTokenParts & ClientOpenIDParts,
|
||||
private readonly roomId: string,
|
||||
private readonly slotId: string,
|
||||
private devices: MediaDevices,
|
||||
private processorState$: Behavior<ProcessorState>,
|
||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||
private controlledAudioDevices: boolean,
|
||||
livekitRoomFactory?: () => LivekitRoom,
|
||||
) {
|
||||
const defaultFactory = (): LivekitRoom => {
|
||||
const roomOptions = generateRoomOption({
|
||||
devices: this.devices,
|
||||
processorState: this.processorState$.value,
|
||||
e2eeLivekitOptions: livekitKeyProvider && {
|
||||
keyProvider: livekitKeyProvider,
|
||||
// It's important that every room use a separate E2EE worker.
|
||||
// They get confused if given streams from multiple rooms.
|
||||
worker: new E2EEWorker(),
|
||||
},
|
||||
controlledAudioDevices: this.controlledAudioDevices,
|
||||
});
|
||||
logger.info("[ECConnectionFactory] livekit room options: ", roomOptions);
|
||||
return new LivekitRoom(roomOptions);
|
||||
};
|
||||
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param scope The observable scope (used for clean-up)
|
||||
* @param transport The transport to use for this connection.
|
||||
* @param serverName The name of the homeserver to which the {@link transport} belongs.
|
||||
* @param ownMembershipIdentity required to connect (using the jwt service) with the SFU.
|
||||
* @param logger The logger instance to use for this connection.
|
||||
* @param sfuConfig optional config in case we already have a token for this connection.
|
||||
* @returns
|
||||
*/
|
||||
public createConnection(
|
||||
scope: ObservableScope,
|
||||
role: "publisher" | "subscriber",
|
||||
transport: UnstableLivekitTransport,
|
||||
serverName: string,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
): Connection {
|
||||
return new Connection(
|
||||
{
|
||||
roomId: this.roomId,
|
||||
slotId: this.slotId,
|
||||
role,
|
||||
transport,
|
||||
serverName,
|
||||
client: this.client,
|
||||
scope: scope,
|
||||
livekitRoomFactory: this.livekitRoomFactory,
|
||||
ownMembershipIdentity,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
||||
* Reads audio processing and camera quality settings directly from Settings.
|
||||
*/
|
||||
function generateRoomOption({
|
||||
devices,
|
||||
processorState,
|
||||
e2eeLivekitOptions,
|
||||
controlledAudioDevices,
|
||||
}: {
|
||||
devices: MediaDevices;
|
||||
processorState: ProcessorState;
|
||||
e2eeLivekitOptions:
|
||||
| E2EEManagerOptions
|
||||
| { e2eeManager: BaseE2EEManager }
|
||||
| undefined;
|
||||
controlledAudioDevices: boolean;
|
||||
}): RoomOptions {
|
||||
const liveKitOptions = getLiveKitOptions();
|
||||
|
||||
// Apply advanced camera settings if enabled
|
||||
let videoCaptureDefaults = {
|
||||
...liveKitOptions.videoCaptureDefaults,
|
||||
deviceId: devices.videoInput.selected$.value?.id,
|
||||
processor: processorState.processor,
|
||||
};
|
||||
let publishDefaults = liveKitOptions.publishDefaults;
|
||||
|
||||
if (advancedCamera.getValue()) {
|
||||
const { width, height } = parseResolution(cameraResolution.getValue());
|
||||
const fps = cameraFramerate.getValue();
|
||||
const bps = cameraBitrate.getValue();
|
||||
const codec = cameraCodec.getValue();
|
||||
|
||||
videoCaptureDefaults = {
|
||||
...videoCaptureDefaults,
|
||||
resolution: { width, height, frameRate: fps },
|
||||
};
|
||||
publishDefaults = {
|
||||
...publishDefaults,
|
||||
videoEncoding: { maxBitrate: bps, maxFramerate: fps },
|
||||
videoCodec: codec,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...liveKitOptions,
|
||||
videoCaptureDefaults,
|
||||
publishDefaults,
|
||||
audioCaptureDefaults: {
|
||||
...liveKitOptions.audioCaptureDefaults,
|
||||
deviceId: devices.audioInput.selected$.value?.id,
|
||||
echoCancellation: echoCancellationSetting.getValue(),
|
||||
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||
autoGainControl: autoGainControlSetting.getValue(),
|
||||
},
|
||||
audioOutput: {
|
||||
// When using controlled audio devices, we don't want to set the
|
||||
// deviceId here, because it will be set by the native app.
|
||||
// (also the id does not need to match a browser device id)
|
||||
deviceId: controlledAudioDevices
|
||||
? undefined
|
||||
: devices.audioOutput.selected$.value?.id,
|
||||
},
|
||||
e2ee: e2eeLivekitOptions,
|
||||
// TODO test and consider this:
|
||||
// webAudioMix: true,
|
||||
};
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
|
||||
import {
|
||||
createConnectionMap$,
|
||||
ConnectionMap,
|
||||
keyFor,
|
||||
} from "./ConnectionMap.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth";
|
||||
|
||||
// Some test constants
|
||||
|
||||
const TRANSPORT_1: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
},
|
||||
serverName: "example.org",
|
||||
};
|
||||
|
||||
const TRANSPORT_2: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
},
|
||||
serverName: "sample.com",
|
||||
};
|
||||
|
||||
function findConnection(
|
||||
transport: TransportLocator,
|
||||
connections: Connection[],
|
||||
): Connection | undefined {
|
||||
const key = keyFor(transport);
|
||||
return connections.find((c) => keyFor(c) === key);
|
||||
}
|
||||
|
||||
let fakeConnectionFactory: ConnectionFactory;
|
||||
let testScope: ObservableScope;
|
||||
|
||||
// Can be useful to track all created connections in tests, even the disposed ones
|
||||
let allCreatedConnections: Connection[];
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
allCreatedConnections = [];
|
||||
fakeConnectionFactory = {} as unknown as ConnectionFactory;
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
scope: ObservableScope,
|
||||
_role: unknown,
|
||||
transport: UnstableLivekitTransport,
|
||||
serverName: string,
|
||||
) => {
|
||||
const mockConnection = {
|
||||
transport,
|
||||
serverName,
|
||||
remoteParticipants$: new BehaviorSubject([]),
|
||||
} as unknown as Connection;
|
||||
vi.mocked(mockConnection).start = vi.fn();
|
||||
vi.mocked(mockConnection).stop = vi.fn();
|
||||
// Tie the connection's lifecycle to the scope to test scope lifecycle management
|
||||
scope.onEnd(() => {
|
||||
void mockConnection.stop();
|
||||
});
|
||||
allCreatedConnections.push(mockConnection);
|
||||
return mockConnection;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
describe("connections$ stream", () => {
|
||||
test("Should create and start new connections for each transports", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_1,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||
}),
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("a", {
|
||||
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
const connections = e.value;
|
||||
expect(connections.length).toBe(2);
|
||||
|
||||
expect(
|
||||
vi.mocked(fakeConnectionFactory).createConnection,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
|
||||
const conn1 = findConnection(TRANSPORT_1, connections);
|
||||
expect(conn1).toBeDefined();
|
||||
expect(conn1!.start).toHaveBeenCalled();
|
||||
|
||||
const conn2 = findConnection(TRANSPORT_2, connections);
|
||||
expect(conn2).toBeDefined();
|
||||
expect(conn2!.start).toHaveBeenCalled();
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Shares one connection between homeservers advertising the same JWT service", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const sameServiceElsewhere: TransportLocator = {
|
||||
transport: TRANSPORT_1.transport,
|
||||
serverName: "elsewhere.org",
|
||||
};
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_1,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([sameServiceElsewhere], 0),
|
||||
}),
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("a", {
|
||||
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
expect(e.value.length).toBe(1);
|
||||
// The JWT service is reached directly, so the server name plays no
|
||||
// part and the local locator, which came first, is the one used
|
||||
expect(
|
||||
vi.mocked(fakeConnectionFactory).createConnection,
|
||||
).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.anything(),
|
||||
"publisher",
|
||||
TRANSPORT_1.transport,
|
||||
"example.org",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Keeps one connection per homeserver for transports with a url", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const urlTransport: TransportLocator = {
|
||||
transport: { type: "livekit", url: "wss://sfu.example.org" },
|
||||
serverName: "example.org",
|
||||
};
|
||||
const sameUrlElsewhere: TransportLocator = {
|
||||
transport: urlTransport.transport,
|
||||
serverName: "elsewhere.org",
|
||||
};
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: urlTransport,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([sameUrlElsewhere], 0),
|
||||
}),
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("a", {
|
||||
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
// Our homeserver asks the named server for the token, so each
|
||||
// server name needs its own connection
|
||||
expect(e.value.length).toBe(2);
|
||||
expect(findConnection(urlTransport, e.value)).toBeDefined();
|
||||
expect(findConnection(sameUrlElsewhere, e.value)).toBeDefined();
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Should start connection only once", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_1,
|
||||
remoteTransports$: behavior("abcdef", {
|
||||
a: new Epoch([TRANSPORT_1], 0),
|
||||
b: new Epoch([TRANSPORT_1], 1),
|
||||
c: new Epoch([TRANSPORT_1], 2),
|
||||
d: new Epoch([TRANSPORT_1], 3),
|
||||
e: new Epoch([TRANSPORT_1], 4),
|
||||
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
|
||||
}),
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("xxxxxa", {
|
||||
x: expect.anything(),
|
||||
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
const connections = e.value;
|
||||
|
||||
expect(connections.length).toBe(2);
|
||||
expect(
|
||||
vi.mocked(fakeConnectionFactory).createConnection,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
|
||||
const conn2 = findConnection(TRANSPORT_2, connections);
|
||||
expect(conn2).toBeDefined();
|
||||
|
||||
const conn1 = findConnection(TRANSPORT_1, connections);
|
||||
expect(conn1).toBeDefined();
|
||||
expect(conn1!.start).toHaveBeenCalledOnce();
|
||||
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Should cleanup connections when not needed anymore", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_1,
|
||||
remoteTransports$: behavior("abc", {
|
||||
a: new Epoch([TRANSPORT_1], 0),
|
||||
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
|
||||
c: new Epoch([TRANSPORT_1], 2),
|
||||
}),
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("xab", {
|
||||
x: expect.anything(),
|
||||
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
const connections = e.value;
|
||||
expect(connections.length).toBe(2);
|
||||
return true;
|
||||
}),
|
||||
b: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||
const connections = e.value;
|
||||
|
||||
expect(connections.length).toBe(1);
|
||||
// The second connection should have been stopped has it is no longer needed.
|
||||
const connection2 = findConnection(
|
||||
TRANSPORT_2,
|
||||
allCreatedConnections,
|
||||
);
|
||||
expect(connection2).toBeDefined();
|
||||
expect(connection2!.stop).toHaveBeenCalled();
|
||||
|
||||
// The first connection should still be active
|
||||
const conn1 = connections[0];
|
||||
expect(conn1.stop).not.toHaveBeenCalledOnce();
|
||||
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConnectionMap", () => {
|
||||
test("warns when a second connection to the same URL is merged", () => {
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const data = new ConnectionMap(logger);
|
||||
const connection = TRANSPORT_1 as unknown as Connection;
|
||||
const p = (identity: string): RemoteParticipant =>
|
||||
({ identity }) as unknown as RemoteParticipant;
|
||||
data.add(connection, [p("a")]);
|
||||
data.add({ ...connection } as Connection, [p("b")]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
|
||||
),
|
||||
);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionMap$", () => {
|
||||
// Used in test to control fake connections' remoteParticipants$ streams
|
||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeRemoteParticipantsStreams = new Map();
|
||||
|
||||
function getRemoteParticipantsFor(
|
||||
transport: TransportLocator,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
return (
|
||||
fakeRemoteParticipantsStreams.get(keyFor(transport)) ??
|
||||
new BehaviorSubject([])
|
||||
);
|
||||
}
|
||||
|
||||
// need a more advanced fake connection factory
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
scope: ObservableScope,
|
||||
_role: unknown,
|
||||
transport: UnstableLivekitTransport,
|
||||
serverName: string,
|
||||
) => {
|
||||
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||
RemoteParticipant[]
|
||||
>([]);
|
||||
const mockConnection = {
|
||||
transport,
|
||||
serverName,
|
||||
remoteParticipants$: getRemoteParticipantsFor({
|
||||
transport,
|
||||
serverName,
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
vi.mocked(mockConnection).start = vi.fn();
|
||||
vi.mocked(mockConnection).stop = vi.fn();
|
||||
// Tie the connection's lifecycle to the scope to test scope lifecycle management
|
||||
scope.onEnd(() => {
|
||||
void mockConnection.stop();
|
||||
});
|
||||
|
||||
fakeRemoteParticipantsStreams.set(
|
||||
keyFor({ transport, serverName }),
|
||||
fakeRemoteParticipants$,
|
||||
);
|
||||
return mockConnection;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Should report connections with the remote participants", () => {
|
||||
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
||||
// Setup the fake participants streams behavior
|
||||
// ==============================
|
||||
fakeRemoteParticipantsStreams.set(
|
||||
keyFor(TRANSPORT_1),
|
||||
behavior("oa-b", {
|
||||
o: [],
|
||||
a: [{ identity: "user1A" } as RemoteParticipant],
|
||||
b: [
|
||||
{ identity: "user1A" } as RemoteParticipant,
|
||||
{ identity: "user1B" } as RemoteParticipant,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
fakeRemoteParticipantsStreams.set(
|
||||
keyFor(TRANSPORT_2),
|
||||
behavior("o-a", {
|
||||
o: [],
|
||||
a: [{ identity: "user2A" } as RemoteParticipant],
|
||||
}),
|
||||
);
|
||||
// ==============================
|
||||
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_1,
|
||||
remoteTransports$: behavior("a", {
|
||||
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||
}),
|
||||
logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(connectionMap$).toBe("abcd", {
|
||||
a: expect.toSatisfy((e) => {
|
||||
const data: ConnectionMap = e.value;
|
||||
expect(data.getConnections().length).toBe(2);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||
return true;
|
||||
}),
|
||||
b: expect.toSatisfy((e) => {
|
||||
const data: ConnectionMap = e.value;
|
||||
expect(data.getConnections().length).toBe(2);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||
).toBe("user1A");
|
||||
return true;
|
||||
}),
|
||||
c: expect.toSatisfy((e) => {
|
||||
const data: ConnectionMap = e.value;
|
||||
expect(data.getConnections().length).toBe(2);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||
).toBe("user1A");
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||
).toBe("user2A");
|
||||
return true;
|
||||
}),
|
||||
d: expect.toSatisfy((e) => {
|
||||
const data: ConnectionMap = e.value;
|
||||
expect(data.getConnections().length).toBe(2);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||
).toBe("user1A");
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_1)[1].identity,
|
||||
).toBe("user1B");
|
||||
expect(
|
||||
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||
).toBe("user2A");
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, of, switchMap, tap } 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";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
type ObservableScope,
|
||||
} from "../../ObservableScope.ts";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth/types.ts";
|
||||
|
||||
// Branded string type, so that only values returned by `keyFor` can be used
|
||||
// as keys
|
||||
export type TransportKey = string & { __transportKey: true };
|
||||
|
||||
/**
|
||||
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
|
||||
* {@link Map} or {@link Set}. Locators with the same key share a connection.
|
||||
*/
|
||||
export function keyFor({
|
||||
transport,
|
||||
serverName,
|
||||
}: TransportLocator): TransportKey {
|
||||
// The server name only takes part in authentication for a transport with a
|
||||
// `url`, where our homeserver asks that server for the token. A JWT service
|
||||
// is reached directly, so members of several homeservers advertising the
|
||||
// same one must share a connection rather than each get their own.
|
||||
const hasUrl = "url" in transport;
|
||||
// Serialize the fields in a consistent order
|
||||
return JSON.stringify([
|
||||
hasUrl ? transport.url : null,
|
||||
"livekit_service_url" in transport ? transport.livekit_service_url : null,
|
||||
hasUrl ? serverName : null,
|
||||
]) as TransportKey;
|
||||
}
|
||||
|
||||
function removeDuplicateTransports(
|
||||
transports: TransportLocator[],
|
||||
): TransportLocator[] {
|
||||
// The first locator wins, so the local one stays first in the list
|
||||
const map = new Map<TransportKey, TransportLocator>();
|
||||
for (const transport of transports) {
|
||||
const key = keyFor(transport);
|
||||
if (!map.has(key)) map.set(key, transport);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export class ConnectionMap {
|
||||
private readonly map = new Map<
|
||||
TransportKey,
|
||||
{ connection: Connection; participants: RemoteParticipant[] }
|
||||
>();
|
||||
|
||||
public constructor(private readonly logger?: Logger) {}
|
||||
|
||||
public add(connection: Connection, participants: RemoteParticipant[]): void {
|
||||
const key = keyFor(connection);
|
||||
const existing = this.map.get(key);
|
||||
if (!existing) {
|
||||
this.map.set(key, { connection, participants });
|
||||
} else {
|
||||
// Transports are deduplicated by key upstream, so this should never
|
||||
// happen; if it does, members may be matched against the wrong room.
|
||||
this.logger?.warn(
|
||||
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
|
||||
);
|
||||
existing.participants.push(...participants);
|
||||
}
|
||||
}
|
||||
|
||||
public getConnections(): Connection[] {
|
||||
return Array.from(this.map.values()).map(({ connection }) => connection);
|
||||
}
|
||||
|
||||
public getConnectionForTransport(
|
||||
transport: TransportLocator,
|
||||
): Connection | null {
|
||||
return this.map.get(keyFor(transport))?.connection ?? null;
|
||||
}
|
||||
|
||||
public getParticipantsForTransport(
|
||||
transport: TransportLocator,
|
||||
): RemoteParticipant[] {
|
||||
const existing = this.map.get(keyFor(transport));
|
||||
if (existing) {
|
||||
return existing.participants;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
localTransport: TransportLocator;
|
||||
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
|
||||
logger: Logger;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic map of connections and their associated backend participants.
|
||||
* @param props - Configuration object
|
||||
* @param props.scope - The observable scope used by this object
|
||||
* @param props.connectionFactory - Used to create new connections
|
||||
* @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport)
|
||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||
* @param props.logger - The logger to use.
|
||||
*/
|
||||
export function createConnectionMap$({
|
||||
scope,
|
||||
connectionFactory,
|
||||
localTransport,
|
||||
remoteTransports$,
|
||||
logger: parentLogger,
|
||||
ownMembershipIdentity,
|
||||
}: Props): Behavior<Epoch<ConnectionMap>> {
|
||||
const logger = parentLogger.getChild("[ConnectionMap]");
|
||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||
|
||||
/**
|
||||
* All transports currently represented in the connection map. This list does
|
||||
* not include duplicate transports.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
mapEpoch((remoteTransports) =>
|
||||
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||
),
|
||||
tap((transports) =>
|
||||
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
|
||||
/**
|
||||
* Connections for each transport in use by one or more session members.
|
||||
*/
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
generateItemsWithEpoch(
|
||||
"ConnectionMap connections$",
|
||||
function* (transports) {
|
||||
for (const transport of transports) {
|
||||
const role =
|
||||
keyFor(transport) === localTransportKey
|
||||
? ("publisher" as const)
|
||||
: ("subscriber" as const);
|
||||
yield {
|
||||
// The locator is an object, so a stable key stands in for it
|
||||
keys: [role, keyFor(transport)],
|
||||
data: transport,
|
||||
};
|
||||
}
|
||||
},
|
||||
(scope, locator$, role, _key) => {
|
||||
const { transport, serverName } = locator$.value;
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
role,
|
||||
transport,
|
||||
serverName,
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
);
|
||||
// Start the connection immediately
|
||||
// Use connection state to track connection progress
|
||||
void connection.start();
|
||||
// TODO subscribe to connection state to retry or log issues?
|
||||
return connection;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return scope.behavior(
|
||||
connections$.pipe(
|
||||
switchMap(({ value: connections, epoch }) => {
|
||||
if (connections.length === 0)
|
||||
return of(new Epoch(new ConnectionMap(), epoch));
|
||||
|
||||
return combineLatest(
|
||||
// Map the connections to list of Observable<{connection, participants}>
|
||||
connections.map((connection) =>
|
||||
connection.remoteParticipants$.pipe(
|
||||
map((participants) => ({ connection, participants })),
|
||||
),
|
||||
),
|
||||
// Collect into a single ConnectionMap
|
||||
(...list) => {
|
||||
const map = new ConnectionMap(logger);
|
||||
for (const item of list)
|
||||
map.add(item.connection, item.participants);
|
||||
return new Epoch(map, epoch);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Room as LivekitRoom } from "livekit-client";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import { ECConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
} from "../../../livekit/auth";
|
||||
import {
|
||||
exampleTransport,
|
||||
mockMediaDevices,
|
||||
ownMemberMock,
|
||||
} from "../../../utils/test.ts";
|
||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { constant } from "../../Behavior";
|
||||
import {
|
||||
echoCancellationSetting,
|
||||
noiseSuppressionSetting,
|
||||
autoGainControlSetting,
|
||||
advancedCamera,
|
||||
cameraResolution,
|
||||
cameraFramerate,
|
||||
cameraBitrate,
|
||||
cameraCodec,
|
||||
} from "../../../settings/settings.ts";
|
||||
import { testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||
|
||||
// At the top of your test file, after imports
|
||||
vi.mock("livekit-client", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
Room: vi.fn().mockImplementation(function (this: LivekitRoom, options) {
|
||||
const emitter = new EventEmitter();
|
||||
return {
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
disconnect: vi.fn(),
|
||||
remoteParticipants: new Map(),
|
||||
} as unknown as LivekitRoom;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let testScope: ObservableScope;
|
||||
let mockClient: ClientGetTokenParts & ClientOpenIDParts;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
mockClient = {
|
||||
getOpenIdToken: vi.fn().mockReturnValue(""),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ECConnectionFactory - Audio inputs options", () => {
|
||||
test.each([
|
||||
{ echo: true, noise: true },
|
||||
{ echo: true, noise: false },
|
||||
{ echo: false, noise: true },
|
||||
{ echo: false, noise: false },
|
||||
])(
|
||||
"it sets echoCancellation=$echo and noiseSuppression=$noise based on settings",
|
||||
({ echo, noise }) => {
|
||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||
|
||||
// Set audio processing settings
|
||||
echoCancellationSetting.setValue(echo);
|
||||
noiseSuppressionSetting.setValue(noise);
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
"subscriber",
|
||||
exampleTransport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
);
|
||||
|
||||
// Check if Room was constructed with expected options
|
||||
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioCaptureDefaults: expect.objectContaining({
|
||||
echoCancellation: echo,
|
||||
noiseSuppression: noise,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||
test.each([{ controlled: true }, { controlled: false }])(
|
||||
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
||||
({ controlled }) => {
|
||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||
|
||||
// Explicitly set audio settings so the test doesn't depend on defaults
|
||||
echoCancellationSetting.setValue(true);
|
||||
noiseSuppressionSetting.setValue(true);
|
||||
autoGainControlSetting.setValue(true);
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map<never, never>()),
|
||||
selected$: constant({ id: "DEV00", virtualEarpiece: false }),
|
||||
select: () => {},
|
||||
},
|
||||
}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
controlled,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
"subscriber",
|
||||
exampleTransport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
);
|
||||
|
||||
// Check if Room was constructed with expected options
|
||||
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioOutput: expect.objectContaining({
|
||||
deviceId: controlled ? undefined : "DEV00",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("ECConnectionFactory - Camera quality settings", () => {
|
||||
test("it uses default video options when advancedCamera is disabled", () => {
|
||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||
advancedCamera.setValue(false);
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
"subscriber",
|
||||
exampleTransport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
);
|
||||
|
||||
// publishDefaults should use config defaults (vp8), not custom settings
|
||||
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publishDefaults: expect.objectContaining({
|
||||
videoCodec: "vp8",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("it applies custom camera resolution, encoding, and codec when advancedCamera is enabled", () => {
|
||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||
|
||||
advancedCamera.setValue(true);
|
||||
cameraResolution.setValue("1920x1080");
|
||||
cameraFramerate.setValue(60);
|
||||
cameraBitrate.setValue(4_000_000);
|
||||
cameraCodec.setValue("vp9");
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
"subscriber",
|
||||
exampleTransport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
videoCaptureDefaults: expect.objectContaining({
|
||||
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||
}),
|
||||
publishDefaults: expect.objectContaining({
|
||||
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
|
||||
videoCodec: "vp9",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("it applies autoGainControl from settings", () => {
|
||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||
|
||||
autoGainControlSetting.setValue(false);
|
||||
echoCancellationSetting.setValue(true);
|
||||
noiseSuppressionSetting.setValue(true);
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
"subscriber",
|
||||
exampleTransport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioCaptureDefaults: expect.objectContaining({
|
||||
autoGainControl: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
fetchMock.reset();
|
||||
});
|
||||
@@ -1,400 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallMembership,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
type RemoteMatrixLivekitMember,
|
||||
createRemoteMatrixLivekitMembers$,
|
||||
} from "./MatrixLivekitMembers.ts";
|
||||
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope.ts";
|
||||
import { ConnectionMap } from "./ConnectionMap.ts";
|
||||
import {
|
||||
flushPromises,
|
||||
mockRtcMembership,
|
||||
mockRemoteParticipant,
|
||||
} from "../../../utils/test.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { constant } from "../../Behavior.ts";
|
||||
import { localRtcMember } from "../../../utils/test-fixtures.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth";
|
||||
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
||||
|
||||
let testScope: ObservableScope;
|
||||
|
||||
const fallbackMemberId = (userId: string, deviceId: string): string =>
|
||||
`${userId}:${deviceId}`;
|
||||
|
||||
const transportA: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
|
||||
const transportB: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
};
|
||||
|
||||
const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", {
|
||||
fociPreferred: [transportA],
|
||||
});
|
||||
const carlMembership = mockRtcMembership("@carl:sample.com", "DEV111", {
|
||||
fociPreferred: [transportB],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
});
|
||||
|
||||
function epochMeWith$<T, U>(
|
||||
source$: Observable<Epoch<U>>,
|
||||
me$: Observable<T>,
|
||||
): Observable<Epoch<T>> {
|
||||
return combineLatest([source$, me$]).pipe(
|
||||
map(([ep, cd]) => {
|
||||
return new Epoch(cd, ep.epoch);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("should signal participant not yet connected to livekit", async () => {
|
||||
const info = vi.spyOn(logger, "info");
|
||||
const mockedMemberships$ = new BehaviorSubject([bobMembership]);
|
||||
const mockConnectionMap$ = new BehaviorSubject(new ConnectionMap());
|
||||
const { memberships$, membershipsWithTransport$ } =
|
||||
createEpochedMemberships$(mockedMemberships$);
|
||||
|
||||
const connectionMap$ = testScope.behavior(
|
||||
epochMeWith$(memberships$, mockConnectionMap$),
|
||||
);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].membership$.value).toBe(bobMembership);
|
||||
expect(data[0].participant.value$.value).toBe(null);
|
||||
expect(data[0].connection$.value).toBe(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection",
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
|
||||
function createEpochedMemberships$(m$: Observable<CallMembership[]>): {
|
||||
memberships$: Observable<Epoch<CallMembership[]>>;
|
||||
membershipsWithTransport$: Observable<
|
||||
Epoch<
|
||||
{ membership: CallMembership; transport: TransportLocator | undefined }[]
|
||||
>
|
||||
>;
|
||||
} {
|
||||
const memberships$ = testScope.behavior(m$.pipe(trackEpoch()));
|
||||
const { membershipsWithTransport$ } = membershipsAndTransports$(
|
||||
testScope,
|
||||
memberships$,
|
||||
);
|
||||
return {
|
||||
memberships$,
|
||||
membershipsWithTransport$,
|
||||
};
|
||||
}
|
||||
|
||||
test("should signal participant on a connection that is publishing", async () => {
|
||||
const bobParticipantId = fallbackMemberId(
|
||||
bobMembership.userId,
|
||||
bobMembership.deviceId,
|
||||
);
|
||||
|
||||
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
|
||||
constant([bobMembership]),
|
||||
);
|
||||
|
||||
const connection = {
|
||||
transport: bobMembership.getTransport(),
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const dataWithPublisher = new ConnectionMap();
|
||||
dataWithPublisher.add(connection, [
|
||||
mockRemoteParticipant({ identity: bobParticipantId }),
|
||||
]);
|
||||
|
||||
const connectionMap$ = testScope.behavior(
|
||||
epochMeWith$(memberships$, constant(dataWithPublisher)),
|
||||
);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].membership$.value).toBe(bobMembership);
|
||||
expect(data[0].participant.value$.value).toSatisfy((participant) => {
|
||||
expect(participant).toBeDefined();
|
||||
expect(participant!.identity).toEqual(bobParticipantId);
|
||||
return true;
|
||||
});
|
||||
expect(data[0].connection$.value).toBe(connection);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// A member advertising both a `url` and a `livekit_service_url` may have
|
||||
// authenticated through either flow, so it has two candidate identities.
|
||||
const bothTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
const bobLegacyId = fallbackMemberId("@bob:example.org", "DEV000");
|
||||
const bobHashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4";
|
||||
const bobWithBothIdentities = mockRtcMembership("@bob:example.org", "DEV000", {
|
||||
fociPreferred: [bothTransport],
|
||||
backendIdentities: [bobHashedId, bobLegacyId],
|
||||
});
|
||||
|
||||
test.each([
|
||||
["legacy", bobLegacyId],
|
||||
["hashed", bobHashedId],
|
||||
])(
|
||||
"matches a member with two candidate identities publishing under the %s one",
|
||||
async (_name, identity) => {
|
||||
const { memberships$, membershipsWithTransport$ } =
|
||||
createEpochedMemberships$(constant([bobWithBothIdentities]));
|
||||
|
||||
const connection = {
|
||||
transport: bothTransport,
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const dataWithPublisher = new ConnectionMap();
|
||||
dataWithPublisher.add(connection, [mockRemoteParticipant({ identity })]);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$: testScope.behavior(
|
||||
epochMeWith$(memberships$, constant(dataWithPublisher)),
|
||||
),
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].participant.value$.value?.identity).toEqual(identity);
|
||||
expect(data[0].connection$.value).toBe(connection);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("does not match a member with two candidate identities to anyone else", async () => {
|
||||
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
|
||||
constant([bobWithBothIdentities]),
|
||||
);
|
||||
|
||||
const connection = {
|
||||
transport: bothTransport,
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const dataWithStranger = new ConnectionMap();
|
||||
dataWithStranger.add(connection, [
|
||||
mockRemoteParticipant({ identity: "@stranger:example.org:DEV999" }),
|
||||
]);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$: testScope.behavior(
|
||||
epochMeWith$(memberships$, constant(dataWithStranger)),
|
||||
),
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].participant.value$.value).toBe(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("should signal participant on a connection that is not publishing", async () => {
|
||||
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
|
||||
constant([bobMembership]),
|
||||
);
|
||||
|
||||
const connection = {
|
||||
transport: bobMembership.getTransport(),
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const dataWithPublisher = new ConnectionMap();
|
||||
dataWithPublisher.add(connection, []);
|
||||
|
||||
const connectionMap$ = testScope.behavior(
|
||||
epochMeWith$(memberships$, constant(dataWithPublisher)),
|
||||
);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].membership$.value).toBe(bobMembership);
|
||||
expect(data[0].participant.value$.value).toBe(null);
|
||||
expect(data[0].connection$.value).toBe(connection);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Publication edge case", () => {
|
||||
test("bob is publishing in several connections", async () => {
|
||||
const { memberships$, membershipsWithTransport$ } =
|
||||
createEpochedMemberships$(constant([bobMembership, carlMembership]));
|
||||
|
||||
const connectionWithPublisher = new ConnectionMap();
|
||||
const bobParticipantId = fallbackMemberId(
|
||||
bobMembership.userId,
|
||||
bobMembership.deviceId,
|
||||
);
|
||||
const connectionA = {
|
||||
transport: transportA,
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const connectionB = {
|
||||
transport: transportB,
|
||||
serverName: "sample.com",
|
||||
} as unknown as Connection;
|
||||
|
||||
connectionWithPublisher.add(connectionA, [
|
||||
mockRemoteParticipant({ identity: bobParticipantId }),
|
||||
]);
|
||||
connectionWithPublisher.add(connectionB, [
|
||||
mockRemoteParticipant({ identity: bobParticipantId }),
|
||||
]);
|
||||
|
||||
const connectionMap$ = testScope.behavior(
|
||||
epochMeWith$(memberships$, constant(connectionWithPublisher)),
|
||||
);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].membership$.value).toBe(bobMembership);
|
||||
expect(data[0].connection$.value).toBe(connectionA);
|
||||
expect(data[0].participant.value$.value).toSatisfy((participant) => {
|
||||
expect(participant).toBeDefined();
|
||||
expect(participant!.identity).toEqual(bobParticipantId);
|
||||
return true;
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("bob is publishing in the wrong connection", async () => {
|
||||
const mockedMemberships$ = new BehaviorSubject([
|
||||
bobMembership,
|
||||
carlMembership,
|
||||
]);
|
||||
|
||||
const { memberships$, membershipsWithTransport$ } =
|
||||
createEpochedMemberships$(mockedMemberships$);
|
||||
|
||||
const connectionWithPublisher = new ConnectionMap();
|
||||
|
||||
const bobParticipantId = fallbackMemberId(
|
||||
bobMembership.userId,
|
||||
bobMembership.deviceId,
|
||||
);
|
||||
const connectionA = {
|
||||
transport: transportA,
|
||||
serverName: "example.org",
|
||||
} as unknown as Connection;
|
||||
const connectionB = {
|
||||
transport: transportB,
|
||||
serverName: "sample.com",
|
||||
} as unknown as Connection;
|
||||
|
||||
// Bob is not publishing on A
|
||||
connectionWithPublisher.add(connectionA, []);
|
||||
// Bob is publishing on B but his membership says A
|
||||
connectionWithPublisher.add(connectionB, [
|
||||
mockRemoteParticipant({ identity: bobParticipantId }),
|
||||
]);
|
||||
|
||||
const connectionsWithPublisher$ = new BehaviorSubject(
|
||||
connectionWithPublisher,
|
||||
);
|
||||
const connectionMap$ = testScope.behavior(
|
||||
epochMeWith$(memberships$, connectionsWithPublisher$),
|
||||
);
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
|
||||
(data: RemoteMatrixLivekitMember[]) => {
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].membership$.value).toBe(bobMembership);
|
||||
expect(data[0].connection$.value).toBe(connectionA);
|
||||
expect(data[0].participant.value$.value).toBe(null);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, filter, map } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type ConnectionMap } from "./ConnectionMap";
|
||||
import { Epoch, type ObservableScope } from "../../ObservableScope";
|
||||
import { type Connection } from "./Connection";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||
import { type TransportLocator } from "../../../livekit/auth";
|
||||
|
||||
interface LocalTaggedParticipant {
|
||||
type: "local";
|
||||
value$: Behavior<LocalParticipant | null>;
|
||||
}
|
||||
interface RemoteTaggedParticipant {
|
||||
type: "remote";
|
||||
value$: Behavior<RemoteParticipant | null>;
|
||||
}
|
||||
export type TaggedParticipant =
|
||||
| LocalTaggedParticipant
|
||||
| RemoteTaggedParticipant;
|
||||
|
||||
export interface MatrixLivekitMember {
|
||||
membership$: Behavior<CallMembership>;
|
||||
connection$: Behavior<Connection | null>;
|
||||
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
||||
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the local Matrix call member and their associated LiveKit participation.
|
||||
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||
* or if it has no livekit transport at all.
|
||||
*/
|
||||
export interface LocalMatrixLivekitMember extends MatrixLivekitMember {
|
||||
participant: LocalTaggedParticipant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a remote Matrix call member and their associated LiveKit participation.
|
||||
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||
* or if it has no livekit transport at all.
|
||||
*/
|
||||
export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||
participant: RemoteTaggedParticipant;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{ membership: CallMembership; transport: TransportLocator | undefined }[]
|
||||
>
|
||||
>;
|
||||
connectionMap$: Behavior<Epoch<ConnectionMap>>;
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines MatrixRTC and Livekit worlds.
|
||||
*
|
||||
* It has a small public interface:
|
||||
* - in (via constructor):
|
||||
* - an observable of CallMembership[] to track the call members (The matrix side)
|
||||
* - a `ConnectionMap` for the lk rooms (The livekit side)
|
||||
* - out (via public Observable):
|
||||
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
|
||||
*/
|
||||
export function createRemoteMatrixLivekitMembers$({
|
||||
scope,
|
||||
membershipsWithTransport$,
|
||||
connectionMap$,
|
||||
localUser,
|
||||
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
|
||||
/**
|
||||
* Behavior of all the remote call members and their associated livekit data (if available).
|
||||
*/
|
||||
return scope.behavior(
|
||||
combineLatest([membershipsWithTransport$, connectionMap$]).pipe(
|
||||
filter((values) =>
|
||||
values.every((value) => value.epoch === values[0].epoch),
|
||||
),
|
||||
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
|
||||
generateItemsWithEpoch(
|
||||
"RemoteMatrixLivekitMembers",
|
||||
// Generator function.
|
||||
// creates an array of `{key, data}[]`
|
||||
// Each change in the keys (new key) will result in a call to the factory function.
|
||||
function* ([membershipsWithTransport, managerData]) {
|
||||
for (const { membership, transport } of membershipsWithTransport) {
|
||||
// Exclude the local membership
|
||||
if (
|
||||
membership.userId === localUser.userId &&
|
||||
membership.deviceId === localUser.deviceId
|
||||
)
|
||||
continue;
|
||||
|
||||
const participants = transport
|
||||
? managerData.getParticipantsForTransport(transport)
|
||||
: [];
|
||||
const matches = participants.filter((p) =>
|
||||
membership.backendIdentities.includes(p.identity),
|
||||
);
|
||||
const participant = matches[0] ?? null;
|
||||
const connection = transport
|
||||
? managerData.getConnectionForTransport(transport)
|
||||
: null;
|
||||
if (matches.length > 1)
|
||||
logger.warn(
|
||||
`[RemoteMatrixLivekitMembers] ${membership.rtcBackendIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`,
|
||||
);
|
||||
|
||||
yield {
|
||||
// This could just be the backend identity without the other keys.
|
||||
// The user ID, device ID, and member ID are included however so
|
||||
// they show up in debug logs.
|
||||
keys: [
|
||||
membership.userId,
|
||||
membership.deviceId,
|
||||
membership.memberId,
|
||||
membership.rtcBackendIdentity,
|
||||
],
|
||||
data: { membership, participant, connection },
|
||||
};
|
||||
}
|
||||
},
|
||||
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
|
||||
(scope, data$, userId, _deviceId, _memberId, rtcBackendIdentity) => {
|
||||
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||
// Log whether the member could be matched to a LiveKit participant,
|
||||
// since a tile shows "waiting for media" for as long as it cannot.
|
||||
participant$.pipe(scope.bind()).subscribe((p) => {
|
||||
const transport = data$.value.connection?.transport;
|
||||
logger.info(
|
||||
`[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${transport ? JSON.stringify(transport) : "no connection"}`,
|
||||
);
|
||||
});
|
||||
// will only get called once per backend identity.
|
||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||
return {
|
||||
userId,
|
||||
participant: { type: "remote" as const, value$: participant$ },
|
||||
...rest,
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
new Epoch([], -1),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
||||
@@ -1,632 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, vi } from "vitest";
|
||||
import {
|
||||
type MatrixEvent,
|
||||
type RoomMember,
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import EventEmitter from "events";
|
||||
import { it } from "vitest";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
mockMatrixRoomMember,
|
||||
withTestScheduler,
|
||||
} from "../../../utils/test.ts";
|
||||
import {
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
} from "./MatrixMemberMetadata.ts";
|
||||
let testScope: ObservableScope;
|
||||
let mockMatrixRoom: MatrixRoom;
|
||||
|
||||
describe("MatrixMemberMetadata", () => {
|
||||
/*
|
||||
* To be populated in the test setup.
|
||||
* Maps userId to a partial/mock RoomMember object.
|
||||
*/
|
||||
let fakeMembersMap: Map<string, Partial<RoomMember>>;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
fakeMembersMap = new Map<string, Partial<RoomMember>>();
|
||||
|
||||
const roomEmitter = new EventEmitter();
|
||||
mockMatrixRoom = {
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
// addListener: roomEmitter.addListener.bind(roomEmitter),
|
||||
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
|
||||
getMember: vi.fn().mockImplementation((userId: string) => {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
return member as RoomMember;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
getMembers: vi.fn().mockImplementation(() => {
|
||||
const members = Array.from(fakeMembersMap.values());
|
||||
return members;
|
||||
}),
|
||||
getMembersWithMembership: vi.fn().mockImplementation(() => {
|
||||
const members = Array.from(fakeMembersMap.values());
|
||||
return members;
|
||||
}),
|
||||
} as unknown as MatrixRoom;
|
||||
});
|
||||
|
||||
function fakeMemberWith(data: Partial<RoomMember>): void {
|
||||
const userId = data.userId || "@alice:example.com";
|
||||
const member: Partial<RoomMember> = {
|
||||
userId: userId,
|
||||
rawDisplayName: data.rawDisplayName ?? userId,
|
||||
getMxcAvatarUrl:
|
||||
data.getMxcAvatarUrl ||
|
||||
vi.fn().mockImplementation(() => {
|
||||
return `mxc://example.com/${userId}`;
|
||||
}),
|
||||
...data,
|
||||
} as unknown as RoomMember;
|
||||
fakeMembersMap.set(userId, member);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fakeMembersMap.clear();
|
||||
});
|
||||
|
||||
describe("displayname", () => {
|
||||
function updateDisplayName(
|
||||
userId: `@${string}:${string}`,
|
||||
newDisplayName: string,
|
||||
): void {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
member.rawDisplayName = newDisplayName;
|
||||
// Emit the event to notify listeners
|
||||
mockMatrixRoom.emit(
|
||||
RoomStateEvent.Members,
|
||||
{} as unknown as MatrixEvent,
|
||||
{} as unknown as RoomState,
|
||||
member as RoomMember,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`No member found with userId: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
it("should show our own user if present in rtc session and room", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
rawDisplayName: "it's a me",
|
||||
});
|
||||
const memberships$ = behavior("a", {
|
||||
a: [mockRtcMembership("@local:example.com", "DEVICE1")],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const dn$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
expectObservable(dn$).toBe("a", {
|
||||
a: "it's a me",
|
||||
});
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setUpBasicRoom(): void {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
rawDisplayName: "it's a me",
|
||||
});
|
||||
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
|
||||
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
|
||||
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
|
||||
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
|
||||
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
|
||||
fakeMemberWith({ userId: "@no-name:foo.bar" });
|
||||
}
|
||||
|
||||
it("should get displayName for users", () => {
|
||||
setUpBasicRoom();
|
||||
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(aliceDispName$).toBe("a", {
|
||||
a: "Alice",
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@alice:example.com", "Alice"],
|
||||
["@bob:example.com", "Bob"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should use userId if no display name", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [mockRtcMembership("@no-name:foo.bar", "D000")],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@no-name:foo.bar", "@no-name:foo.bar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate users with same display name", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:example.com", "DEVICE2"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
mockRtcMembership("@evil:example.com", "E000"),
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
// ["@local:example.com", "it's a me"],
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
["@carl:example.com", "Carl (@carl:example.com)"],
|
||||
["@evil:example.com", "Carl (@evil:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should start to disambiguate reactivly when needed", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||
b: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep disambiguated name when other leave", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
],
|
||||
b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate on name change", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "B000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
schedule("-a", {
|
||||
a: () => {
|
||||
updateDisplayName("@carl:example.com", "Bob");
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "Bob"],
|
||||
["@carl:example.com", "Carl"],
|
||||
]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@carl:example.com", "Bob (@carl:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should track individual member id with createDisplayNameBehavior", () => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
const BOB = "@bob:example.com";
|
||||
const CARL = "@carl:example.com";
|
||||
// for this test we build a mock environment that does all possible changes:
|
||||
// - memberships join/leave
|
||||
// - room join/leave
|
||||
// - disambiguate
|
||||
const memberships$ = behavior("ab-d", {
|
||||
a: [mockRtcMembership(CARL, "C000")],
|
||||
b: [
|
||||
mockRtcMembership(CARL, "C000"),
|
||||
// bob joins
|
||||
mockRtcMembership(BOB, "B000"),
|
||||
],
|
||||
// c carl gets renamed to BOB
|
||||
d: [
|
||||
// carl leaves
|
||||
mockRtcMembership(BOB, "B000"),
|
||||
],
|
||||
});
|
||||
schedule("--a-", {
|
||||
a: () => {
|
||||
// carl renames
|
||||
updateDisplayName(CARL, "Bob");
|
||||
},
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
|
||||
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
|
||||
|
||||
expectObservable(bob$).toBe("abc-", {
|
||||
a: undefined,
|
||||
b: "Bob",
|
||||
c: "Bob (@bob:example.com)",
|
||||
// bob stays disambiguate even though carl left
|
||||
// d: "Bob (@bob:example.com)",
|
||||
});
|
||||
|
||||
expectObservable(carl$).toBe("a-cd", {
|
||||
a: "Carl",
|
||||
// b: "Carl",
|
||||
// carl gets renamed and disambiguate
|
||||
c: "Bob (@carl:example.com)",
|
||||
d: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
|
||||
"@bob2:example.org",
|
||||
"BBBB",
|
||||
);
|
||||
const bob = mockMatrixRoomMember(bobRtcMember, {
|
||||
rawDisplayName: "Bob",
|
||||
});
|
||||
const bobZeroWidthSpace = mockMatrixRoomMember(
|
||||
bobZeroWidthSpaceRtcMember,
|
||||
{
|
||||
rawDisplayName: "Bo\u200bb",
|
||||
},
|
||||
);
|
||||
fakeMemberWith(bob);
|
||||
fakeMemberWith(bobZeroWidthSpace);
|
||||
fakeMemberWith({ userId: "@carol:example.org" });
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
|
||||
b: [
|
||||
mockRtcMembership("@carol:example.org", "1111"),
|
||||
bobRtcMember,
|
||||
bobZeroWidthSpaceRtcMember,
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob:example.org",
|
||||
);
|
||||
const bob2$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob2:example.org",
|
||||
);
|
||||
const carol$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@carol:example.org",
|
||||
);
|
||||
expectObservable(bob$).toBe("ab", {
|
||||
a: "Bob",
|
||||
b: "Bob (@bob:example.org)",
|
||||
});
|
||||
expectObservable(bob2$).toBe("ab", {
|
||||
a: undefined,
|
||||
b: "Bo\u200bb (@bob2:example.org)",
|
||||
});
|
||||
expectObservable(carol$).toBe("a-", {
|
||||
a: "@carol:example.org",
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
["@bob:example.org", "Bob"],
|
||||
]),
|
||||
// Other Bob joins, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
[bobRtcMember.userId, `Bob (@bob:example.org)`],
|
||||
[
|
||||
bobZeroWidthSpace.userId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should strip RTL characters from displayname", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
const daveRTLRtcMember = mockRtcMembership(
|
||||
"@dave2:example.org",
|
||||
"DDDD",
|
||||
);
|
||||
const dave = mockMatrixRoomMember(daveRtcMember, {
|
||||
rawDisplayName: "Dave",
|
||||
});
|
||||
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
||||
rawDisplayName: "\u202eevaD",
|
||||
});
|
||||
|
||||
fakeMemberWith({ userId: "@carol:example.org" });
|
||||
fakeMemberWith(daveRTL);
|
||||
fakeMemberWith(dave);
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@carol:example.org", "DDDD")],
|
||||
b: [
|
||||
mockRtcMembership("@carol:example.org", "DDDD"),
|
||||
daveRtcMember,
|
||||
daveRTLRtcMember,
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([["@carol:example.org", "@carol:example.org"]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
// Not disambiguated
|
||||
["@dave:example.org", "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
["@dave2:example.org", "evaD (@dave2:example.org)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarUrl", () => {
|
||||
function updateAvatarUrl(
|
||||
userId: `@${string}:${string}`,
|
||||
avatarUrl: string,
|
||||
): void {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
|
||||
// Emit the event to notify listeners
|
||||
mockMatrixRoom.emit(
|
||||
RoomStateEvent.Members,
|
||||
{} as unknown as MatrixEvent,
|
||||
{} as unknown as RoomState,
|
||||
member as RoomMember,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`No member found with userId: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
it("should use avatar url from room members", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
});
|
||||
fakeMemberWith({
|
||||
userId: "@alice:example.com",
|
||||
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
|
||||
});
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@local:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const local$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
const alice$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(local$).toBe("a", {
|
||||
a: "mxc://example.com/@local:example.com",
|
||||
});
|
||||
expectObservable(alice$).toBe("a", {
|
||||
a: "mxc://custom.url/avatar",
|
||||
});
|
||||
expectObservable(metadataStore.avatarMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@local:example.com", "mxc://example.com/@local:example.com"],
|
||||
["@alice:example.com", "mxc://custom.url/avatar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should update on avatar change and user join/leave", () => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
fakeMemberWith({ userId: "@carl:example.com" });
|
||||
fakeMemberWith({ userId: "@bob:example.com" });
|
||||
const memberships$ = behavior("ab-d", {
|
||||
a: [mockRtcMembership("@bob:example.com", "B000")],
|
||||
b: [
|
||||
mockRtcMembership("@bob:example.com", "B000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
],
|
||||
d: [mockRtcMembership("@carl:example.com", "C000")],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
schedule("--c-", {
|
||||
c: () => {
|
||||
updateAvatarUrl(
|
||||
"@carl:example.com",
|
||||
"mxc://updated.me/updatedAvatar",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const bob$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@bob:example.com",
|
||||
);
|
||||
const carl$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@carl:example.com",
|
||||
);
|
||||
expectObservable(bob$).toBe("a---", {
|
||||
a: "mxc://example.com/@bob:example.com",
|
||||
});
|
||||
expectObservable(carl$).toBe("a-c-", {
|
||||
a: "mxc://example.com/@carl:example.com",
|
||||
|
||||
c: "mxc://updated.me/updatedAvatar",
|
||||
});
|
||||
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||
["@carl:example.com", "mxc://example.com/@carl:example.com"],
|
||||
]),
|
||||
// expect an update once we update the avatar URL
|
||||
c: new Map<string, string>([
|
||||
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 RoomMember, RoomStateEvent } from "matrix-js-sdk";
|
||||
import { combineLatest, fromEvent, map } from "rxjs";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
KnownMembership,
|
||||
type Room as MatrixRoom,
|
||||
} from "matrix-js-sdk/lib/matrix";
|
||||
// eslint-disable-next-line rxjs/no-internal
|
||||
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import {
|
||||
calculateDisplayName,
|
||||
shouldDisambiguate,
|
||||
} from "../../../utils/displayname";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
|
||||
export type RoomMemberMap = Map<
|
||||
string,
|
||||
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||
>;
|
||||
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
|
||||
const members = matrixRoom
|
||||
.getMembersWithMembership(KnownMembership.Join)
|
||||
.concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite));
|
||||
return members.reduce((acc, member) => {
|
||||
acc.set(member.userId, {
|
||||
userId: member.userId,
|
||||
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
|
||||
rawDisplayName: member.rawDisplayName,
|
||||
});
|
||||
return acc;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
export function createRoomMembers$(
|
||||
scope: ObservableScope,
|
||||
matrixRoom: MatrixRoom,
|
||||
): Behavior<RoomMemberMap> {
|
||||
return scope.behavior(
|
||||
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
||||
map(() => roomToMembersMap(matrixRoom)),
|
||||
),
|
||||
roomToMembersMap(matrixRoom),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayname for each member of the call. This will disambiguate
|
||||
* any displayname that clashes with another member. Only members
|
||||
* joined to the call are considered here.
|
||||
*
|
||||
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
|
||||
*/
|
||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||
export const memberDisplaynames$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): Behavior<Map<string, string>> => {
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
// This map tracks userIds that at some point needed disambiguation.
|
||||
// This is a memory leak bound to the number of participants.
|
||||
// A call application will always increase the memory if there have been more members in a call.
|
||||
// Its capped by room member participants.
|
||||
const shouldDisambiguateTrackerMap = new Set<string>();
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
// Handle call membership changes
|
||||
memberships$,
|
||||
// Additionally handle display name changes (implicitly reacting to them)
|
||||
roomMembers$,
|
||||
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
|
||||
]).pipe(
|
||||
map(([memberships, roomMembers]) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
const member = roomMembers.get(rtcMember.userId);
|
||||
if (!member) {
|
||||
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
||||
continue;
|
||||
}
|
||||
const disambiguateComputed = shouldDisambiguate(
|
||||
member,
|
||||
memberships,
|
||||
roomMembers,
|
||||
);
|
||||
|
||||
const disambiguate =
|
||||
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
|
||||
disambiguateComputed;
|
||||
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
|
||||
displaynameMap.set(
|
||||
rtcMember.userId,
|
||||
calculateDisplayName(member, disambiguate),
|
||||
);
|
||||
}
|
||||
return displaynameMap;
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const createMatrixMemberMetadata$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): {
|
||||
createDisplayNameBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
createAvatarUrlBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
displaynameMap$: Behavior<Map<string, string>>;
|
||||
avatarMap$: Behavior<Map<string, string | undefined>>;
|
||||
} => {
|
||||
const displaynameMap$ = memberDisplaynames$(
|
||||
scope,
|
||||
memberships$,
|
||||
roomMembers$,
|
||||
);
|
||||
const avatarMap$ = scope.behavior(
|
||||
roomMembers$.pipe(
|
||||
map((roomMembers) =>
|
||||
Array.from(roomMembers.keys()).reduce((acc, key) => {
|
||||
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
|
||||
return acc;
|
||||
}, new Map<string, string | undefined>()),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
displaynameMap$.pipe(
|
||||
map((displaynameMap) => displaynameMap.get(userId)),
|
||||
),
|
||||
),
|
||||
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
roomMembers$.pipe(
|
||||
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
|
||||
),
|
||||
),
|
||||
// mostly for testing purposes
|
||||
displaynameMap$,
|
||||
avatarMap$,
|
||||
};
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 { test, vi, expect, beforeEach, afterEach } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import EventEmitter from "events";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
type Epoch,
|
||||
ObservableScope,
|
||||
trackEpoch,
|
||||
} from "../../ObservableScope.ts";
|
||||
import { ECConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
} from "../../../livekit/auth";
|
||||
import {
|
||||
exampleTransportLocator,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
ownMemberMock,
|
||||
withTestScheduler,
|
||||
} from "../../../utils/test.ts";
|
||||
import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import {
|
||||
createRemoteMatrixLivekitMembers$,
|
||||
type RemoteMatrixLivekitMember,
|
||||
} from "./MatrixLivekitMembers.ts";
|
||||
import { createConnectionMap$ } from "./ConnectionMap.ts";
|
||||
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
||||
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
// Test the integration of ConnectionMap and MatrixLivekitMembers
|
||||
|
||||
let testScope: ObservableScope;
|
||||
let ecConnectionFactory: ECConnectionFactory;
|
||||
let mockClient: ClientGetTokenParts & ClientOpenIDParts;
|
||||
let lkRoomFactory: () => LivekitRoom;
|
||||
|
||||
const createdMockLivekitRooms: Map<string, LivekitRoom> = new Map();
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
mockClient = {
|
||||
getOpenIdToken: vi.fn().mockReturnValue(""),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||
};
|
||||
|
||||
lkRoomFactory = vi.fn().mockImplementation(() => {
|
||||
const emitter = new EventEmitter();
|
||||
const base = {
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
disconnect: vi.fn(),
|
||||
remoteParticipants: new Map(),
|
||||
} as unknown as LivekitRoom;
|
||||
|
||||
vi.mocked(base).connect = vi.fn().mockImplementation(({ url }) => {
|
||||
createdMockLivekitRooms.set(url, base);
|
||||
});
|
||||
return base;
|
||||
});
|
||||
|
||||
ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
"m.call#ROOM",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
lkRoomFactory,
|
||||
);
|
||||
|
||||
//TODO a bit annoying to have to do a http mock?
|
||||
fetchMock.post(`path:/sfu/get`, (url) => {
|
||||
const domain = new URL(url).hostname; // Extract the domain from the URL
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
url: `wss://${domain}/livekit/sfu`,
|
||||
jwt: testJWTToken,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testScope.end();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
test("bob, carl, then bob joining no tracks yet", () => {
|
||||
withTestScheduler(({ expectObservable, behavior, scope }) => {
|
||||
const bobMembership = mockRtcMembership("@bob:example.com", "BDEV000");
|
||||
const carlMembership = mockRtcMembership("@carl:example.com", "CDEV000");
|
||||
const daveMembership = mockRtcMembership("@dave:foo.bar", "DDEV000");
|
||||
|
||||
const eMarble = "abc";
|
||||
const vMarble = "abc";
|
||||
const memberships$ = scope.behavior(
|
||||
behavior(eMarble, {
|
||||
a: [bobMembership],
|
||||
b: [bobMembership, carlMembership],
|
||||
c: [bobMembership, carlMembership, daveMembership],
|
||||
}).pipe(trackEpoch()),
|
||||
);
|
||||
|
||||
const membershipsAndTransports = membershipsAndTransports$(
|
||||
testScope,
|
||||
memberships$,
|
||||
);
|
||||
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope: testScope,
|
||||
connectionFactory: ecConnectionFactory,
|
||||
localTransport: exampleTransportLocator,
|
||||
remoteTransports$: membershipsAndTransports.transports$,
|
||||
logger: logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
|
||||
scope: testScope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionMap$,
|
||||
localUser: localRtcMember,
|
||||
});
|
||||
|
||||
expectObservable(remoteMatrixLivekitMembers$).toBe(vMarble, {
|
||||
a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||
const items = e.value;
|
||||
expect(items.length).toBe(1);
|
||||
const item = items[0]!;
|
||||
expectObservable(item.membership$).toBe("a", {
|
||||
a: bobMembership,
|
||||
});
|
||||
expectObservable(item.connection$).toBe("a", {
|
||||
a: expect.toSatisfy((co) =>
|
||||
deepCompare(co.transport, bobMembership.transports[0]),
|
||||
),
|
||||
});
|
||||
expectObservable(item.participant.value$).toBe("a", {
|
||||
a: null,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
b: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||
const items = e.value;
|
||||
expect(items.length).toBe(2);
|
||||
|
||||
{
|
||||
const item = items[0]!;
|
||||
expectObservable(item.membership$).toBe("a", {
|
||||
a: bobMembership,
|
||||
});
|
||||
expectObservable(item.participant.value$).toBe("a", {
|
||||
a: null,
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const item = items[1]!;
|
||||
|
||||
expectObservable(item.membership$).toBe("a", {
|
||||
a: carlMembership,
|
||||
});
|
||||
expectObservable(item.participant.value$).toBe("a", {
|
||||
a: null,
|
||||
});
|
||||
expectObservable(item.connection$).toBe("a", {
|
||||
a: expect.toSatisfy((connection) =>
|
||||
deepCompare(connection.transport, carlMembership.transports[0]),
|
||||
),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
c: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||
const items = e.value;
|
||||
expect(items.length).toBe(3);
|
||||
|
||||
expectObservable(items[0].membership$).toBe("a", {
|
||||
a: bobMembership,
|
||||
});
|
||||
expectObservable(items[1].membership$).toBe("b", {
|
||||
a: carlMembership,
|
||||
});
|
||||
|
||||
{
|
||||
const item = items[2]!;
|
||||
expectObservable(item.membership$).toBe("a", {
|
||||
a: daveMembership,
|
||||
});
|
||||
expectObservable(item.connection$).toBe("a", {
|
||||
a: expect.toSatisfy((connection) =>
|
||||
deepCompare(connection.transport, daveMembership.transports[0]),
|
||||
),
|
||||
});
|
||||
expectObservable(item.participant.value$).toBe("a", {
|
||||
a: null,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
x: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import { it, vi, expect } from "vitest";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
// import * as ComponentsCore from "@livekit/components-core";
|
||||
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
|
||||
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
|
||||
import { constant } from "./Behavior.ts";
|
||||
@@ -21,8 +20,6 @@ import {
|
||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
||||
|
||||
vi.mock("@livekit/components-core", { spy: true });
|
||||
|
||||
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
||||
"expect leave when the host asks us to hang up (%s mode)",
|
||||
async (mode) => {
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject, combineLatest, Subject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
ObservableScope,
|
||||
trackEpoch,
|
||||
} from "./ObservableScope";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
|
||||
describe("Epoch", () => {
|
||||
it("should map the value correctly", () => {
|
||||
const epoch = new Epoch(1);
|
||||
const mappedEpoch = epoch.mapInner((v) => v + 1);
|
||||
expect(mappedEpoch.value).toBe(2);
|
||||
expect(mappedEpoch.epoch).toBe(0);
|
||||
});
|
||||
|
||||
it("should be tracked from an observable", () => {
|
||||
withTestScheduler(({ expectObservable, behavior }) => {
|
||||
const observable$ = behavior("abc", {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
const epochObservable$ = observable$.pipe(trackEpoch());
|
||||
expectObservable(epochObservable$).toBe("abc", {
|
||||
a: expect.toSatisfy((e) => e.epoch === 0 && e.value === 1),
|
||||
b: expect.toSatisfy((e) => e.epoch === 1 && e.value === 2),
|
||||
c: expect.toSatisfy((e) => e.epoch === 2 && e.value === 3),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("can be mapped without loosing epoch information", () => {
|
||||
withTestScheduler(({ expectObservable, behavior }) => {
|
||||
const observable$ = behavior("abc", {
|
||||
a: "A",
|
||||
b: "B",
|
||||
c: "C",
|
||||
});
|
||||
const epochObservable$ = observable$.pipe(trackEpoch());
|
||||
const derivedEpoch$ = epochObservable$.pipe(
|
||||
mapEpoch((e) => e + "-mapped"),
|
||||
);
|
||||
|
||||
expectObservable(derivedEpoch$).toBe("abc", {
|
||||
a: new Epoch("A-mapped", 0),
|
||||
b: new Epoch("B-mapped", 1),
|
||||
c: new Epoch("C-mapped", 2),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("diamonds emits in a predictable order", () => {
|
||||
const sb$ = new BehaviorSubject("initial");
|
||||
const root$ = sb$.pipe(trackEpoch());
|
||||
const derivedA$ = root$.pipe(mapEpoch((e) => e + "-A"));
|
||||
const derivedB$ = root$.pipe(mapEpoch((e) => e + "-B"));
|
||||
combineLatest([root$, derivedB$, derivedA$]).subscribe(
|
||||
([root, derivedA, derivedB]) => {
|
||||
logger.log(
|
||||
"combined" +
|
||||
root.epoch +
|
||||
root.value +
|
||||
"\n" +
|
||||
derivedA.epoch +
|
||||
derivedA.value +
|
||||
"\n" +
|
||||
derivedB.epoch +
|
||||
derivedB.value,
|
||||
);
|
||||
},
|
||||
);
|
||||
sb$.next("updated");
|
||||
sb$.next("ANOTERUPDATE");
|
||||
});
|
||||
|
||||
it("behavior test", () => {
|
||||
const scope = testScope();
|
||||
const s$ = new Subject();
|
||||
const behavior$ = scope.behavior(s$, 0);
|
||||
behavior$.subscribe((value) => {
|
||||
logger.log(`Received value: ${value}`);
|
||||
});
|
||||
s$.next(1);
|
||||
s$.next(2);
|
||||
s$.next(3);
|
||||
s$.next(3);
|
||||
s$.next(3);
|
||||
s$.next(3);
|
||||
s$.next(3);
|
||||
s$.complete();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reconcile", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should wait clean up before processing next", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = testScope();
|
||||
const behavior$ = new BehaviorSubject<number>(0);
|
||||
|
||||
const setup = vi.fn().mockImplementation(async () => await sleep(100));
|
||||
const cleanup = vi
|
||||
.fn()
|
||||
.mockImplementation(async (n: number) => await sleep(100));
|
||||
scope.reconcile(behavior$, async (value) => {
|
||||
await setup();
|
||||
return async (): Promise<void> => {
|
||||
await cleanup(value);
|
||||
};
|
||||
});
|
||||
// Let the initial setup process
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Send next value
|
||||
behavior$.next(1);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
// Should not have started setup for 1 yet
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledWith(0);
|
||||
|
||||
// Let cleanup finish
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
// Now setup for 1 should have started
|
||||
expect(setup).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should skip intermediates values that are not setup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = testScope();
|
||||
const behavior$ = new BehaviorSubject<number>(0);
|
||||
|
||||
const setup = vi
|
||||
.fn()
|
||||
.mockImplementation(async (n: number) => await sleep(100));
|
||||
|
||||
const cleanupLock = Promise.withResolvers();
|
||||
const cleanup = vi
|
||||
.fn()
|
||||
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||
|
||||
scope.reconcile(behavior$, async (value) => {
|
||||
await setup(value);
|
||||
return async (): Promise<void> => {
|
||||
await cleanup(value);
|
||||
};
|
||||
});
|
||||
// Let the initial setup process (0)
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
|
||||
// Send 4 next values quickly
|
||||
behavior$.next(1);
|
||||
behavior$.next(2);
|
||||
behavior$.next(3);
|
||||
behavior$.next(4);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
// should have only called cleanup for 0
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledWith(0);
|
||||
// Let cleanup finish
|
||||
cleanupLock.resolve(undefined);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
|
||||
// Now setup for 4 should have started, skipping 1,2,3
|
||||
expect(setup).toHaveBeenCalledTimes(2);
|
||||
expect(setup).toHaveBeenCalledWith(4);
|
||||
expect(setup).not.toHaveBeenCalledWith(1);
|
||||
expect(setup).not.toHaveBeenCalledWith(2);
|
||||
expect(setup).not.toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("should wait for setup to complete before starting cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = testScope();
|
||||
const behavior$ = new BehaviorSubject<number>(0);
|
||||
|
||||
const setup = vi
|
||||
.fn()
|
||||
.mockImplementation(async (n: number) => await sleep(3000));
|
||||
|
||||
const cleanupLock = Promise.withResolvers();
|
||||
const cleanup = vi
|
||||
.fn()
|
||||
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||
|
||||
scope.reconcile(behavior$, async (value) => {
|
||||
await setup(value);
|
||||
return async (): Promise<void> => {
|
||||
await cleanup(value);
|
||||
};
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Setup for 0 should be in progress
|
||||
expect(setup).toHaveBeenCalledTimes(1);
|
||||
|
||||
behavior$.next(1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
// Should not have started setup for 1 yet
|
||||
expect(setup).not.toHaveBeenCalledWith(1);
|
||||
// Should not have called cleanup yet, because the setup for 0 is not done
|
||||
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Let setup for 0 finish
|
||||
await vi.advanceTimersByTimeAsync(2500 + 100);
|
||||
// Now cleanup for 0 should have started
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledWith(0);
|
||||
|
||||
cleanupLock.resolve(undefined);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
// Now setup for 1 should have started
|
||||
expect(setup).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("behavior", () => {
|
||||
it("warns with the tag and nesting depth, logging each new depth once", () => {
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const scope = testScope();
|
||||
const source$ = new Subject<number>();
|
||||
const behavior$ = scope.behavior(source$, 0, "tagged");
|
||||
// Re-enter once on value 1 (depth 2), then a nested re-entry on value 2
|
||||
// (depth 3), which is the shape needed to strand a middle subscriber.
|
||||
behavior$.subscribe((v) => {
|
||||
if (v === 1) source$.next(2);
|
||||
});
|
||||
behavior$.subscribe((v) => {
|
||||
if (v === 2) source$.next(3);
|
||||
});
|
||||
const seen: number[] = [];
|
||||
behavior$.subscribe((v) => seen.push(v));
|
||||
|
||||
source$.next(1);
|
||||
|
||||
const messages = warn.mock.calls.map((c) => c[0] as string);
|
||||
expect(messages).toEqual([
|
||||
expect.stringContaining("Behavior (tagged) re-entered at depth 2"),
|
||||
expect.stringContaining("Behavior (tagged) re-entered at depth 3"),
|
||||
]);
|
||||
warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String)));
|
||||
// The behavior settles on the newest value while the last subscriber is
|
||||
// left stranded on the oldest.
|
||||
expect(behavior$.value).toBe(3);
|
||||
expect(seen.at(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it("warns when the scope is ended while a behavior is mid-delivery", () => {
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const scope = new ObservableScope();
|
||||
const source$ = new Subject<number>();
|
||||
const behavior$ = scope.behavior(source$, 0);
|
||||
behavior$.subscribe((v) => {
|
||||
if (v === 1) scope.end();
|
||||
});
|
||||
// A derived behavior is bound to the scope, so ending the scope
|
||||
// mid-delivery unsubscribes it before the value reaches it.
|
||||
const derived$ = scope.behavior(behavior$);
|
||||
|
||||
source$.next(1);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Scope ended while 1 of its behaviors"),
|
||||
expect.any(String),
|
||||
);
|
||||
expect(behavior$.value).toBe(1);
|
||||
expect(derived$.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,334 +1,20 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
endWith,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
type OperatorFunction,
|
||||
share,
|
||||
take,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { ObservableScope } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||
|
||||
type SplitBehavior<T> = keyof T extends string | number
|
||||
? { [K in keyof T as `${K}$`]: Behavior<T[K]> }
|
||||
: never;
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
/**
|
||||
* A scope which limits the execution lifetime of its bound Observables.
|
||||
*/
|
||||
export class ObservableScope {
|
||||
/**
|
||||
* Number of this scope's behaviors currently mid-delivery. Used to detect a
|
||||
* scope being torn down synchronously from within one of its own emissions.
|
||||
*/
|
||||
private delivering = 0;
|
||||
|
||||
private readonly ended$ = new BehaviorSubject(false);
|
||||
|
||||
private readonly bindImpl: MonoTypeOperator = takeUntil(
|
||||
this.ended$.pipe(filter((ended) => ended)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Binds an Observable to this scope, so that it completes when the scope
|
||||
* ends.
|
||||
*/
|
||||
public bind(): MonoTypeOperator {
|
||||
return this.bindImpl;
|
||||
}
|
||||
|
||||
private readonly shareImpl: MonoTypeOperator = share({
|
||||
resetOnError: false,
|
||||
resetOnComplete: false,
|
||||
resetOnRefCountZero: false,
|
||||
});
|
||||
/**
|
||||
* Shares (multicasts) the Observable as a hot Observable.
|
||||
*/
|
||||
public readonly share: MonoTypeOperator = (input$) =>
|
||||
input$.pipe(this.bindImpl, this.shareImpl);
|
||||
|
||||
/**
|
||||
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||
* Observable must synchronously emit an initial value.
|
||||
*/
|
||||
public behavior<T>(
|
||||
setValue$: Observable<T>,
|
||||
initialValue: T | typeof nothing = nothing,
|
||||
// An optional label used only in the re-entrancy warning below, so that a
|
||||
// torn behavior can be identified from a rageshake.
|
||||
tag?: string,
|
||||
): Behavior<T> {
|
||||
const subject$ = new BehaviorSubject(initialValue);
|
||||
// Push values from the Observable into the BehaviorSubject.
|
||||
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||
// they will no longer re-emit their current value upon subscription. We want
|
||||
// to support Observables that complete (for example `of({})`), so we have to
|
||||
// take care to not propagate the completion event.
|
||||
// If a subscriber synchronously causes this same behavior to emit again,
|
||||
// rxjs delivers the nested value to every subscriber first and then
|
||||
// resumes delivering the outer (older) value to the remaining subscribers,
|
||||
// leaving them permanently out of sync with the others. A single such
|
||||
// re-entry can only strand a contiguous run of subscribers; stranding a
|
||||
// subscriber in the middle of the list needs a nested (deeper) re-entry, so
|
||||
// we report the depth and log each new depth (with a stack trace) rather
|
||||
// than only the first occurrence, to make a nested re-entry visible.
|
||||
let depth = 0;
|
||||
let maxReportedDepth = 1;
|
||||
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
|
||||
next: (value) => {
|
||||
if (depth > 0 && depth + 1 > maxReportedDepth) {
|
||||
maxReportedDepth = depth + 1;
|
||||
logger.warn(
|
||||
`Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${
|
||||
depth + 1
|
||||
} while delivering a value; later subscribers will be left with a stale value`,
|
||||
new Error().stack,
|
||||
);
|
||||
}
|
||||
depth++;
|
||||
this.delivering++;
|
||||
try {
|
||||
subject$.next(value);
|
||||
} finally {
|
||||
depth--;
|
||||
this.delivering--;
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
subject$.error(err);
|
||||
},
|
||||
});
|
||||
if (subject$.value === nothing)
|
||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||
return subject$ as Behavior<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the scope, causing any bound Observables to complete.
|
||||
*/
|
||||
public end(): void {
|
||||
if (this.delivering > 0)
|
||||
logger.warn(
|
||||
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
|
||||
new Error().stack,
|
||||
);
|
||||
this.ended$.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be executed when the scope is ended.
|
||||
*/
|
||||
public onEnd(callback: () => void): void {
|
||||
this.ended$
|
||||
.pipe(
|
||||
filter((ended) => ended),
|
||||
take(1),
|
||||
)
|
||||
.subscribe(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the duration of the scope, sync some external state with the value of
|
||||
* the provided Behavior by way of an async function which attempts to update
|
||||
* (reconcile) the external state. The reconciliation function may return a
|
||||
* clean-up callback which will be called and awaited before the next change
|
||||
* in value (or the end of the scope).
|
||||
*
|
||||
* All calls to the function and its clean-up callbacks are serialized. If the
|
||||
* value changes faster than the handlers can keep up with, intermediate
|
||||
* values may be skipped.
|
||||
*
|
||||
* Basically, this is like React's useEffect but async and for Behaviors.
|
||||
*/
|
||||
public reconcile<T>(
|
||||
value$: Behavior<T>,
|
||||
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
||||
): void {
|
||||
let latestValue: T | typeof nothing = nothing;
|
||||
let reconcilePromise: Promise<void> | undefined = undefined;
|
||||
let cleanUp: (() => Promise<void>) | void = undefined;
|
||||
let prevVal: T | typeof nothing = nothing;
|
||||
|
||||
// While this loop runs it will process the latest from `value$` until it caught up with the updates.
|
||||
// It might skip updates from `value$` and only process the newest value after callback has resolved.
|
||||
const reconcileLoop = async (): Promise<void> => {
|
||||
while (latestValue !== prevVal) {
|
||||
await cleanUp?.(); // Call the previous value's clean-up handler
|
||||
prevVal = latestValue;
|
||||
|
||||
if (latestValue !== nothing) cleanUp = await callback(latestValue); // Sync current value...
|
||||
// `latestValue` might have gotten updated during the `await callback`. That is why we loop here
|
||||
}
|
||||
};
|
||||
|
||||
value$
|
||||
.pipe(
|
||||
catchError(() => EMPTY), // Ignore errors
|
||||
this.bind(), // Limit to the duration of the scope
|
||||
endWith(nothing), // Clean up when the scope ends
|
||||
)
|
||||
.subscribe((value) => {
|
||||
// Always track the latest value! The `reconcileLoop` will run until it "processed" the "last" `latestValue`.
|
||||
latestValue = value;
|
||||
// There's already an instance of the below 'reconcileLoop' loop running
|
||||
// concurrently. So lets let the loop handle it. NEVER instanciate two `reconcileLoop`s.
|
||||
if (reconcilePromise) return;
|
||||
|
||||
reconcilePromise = reconcileLoop().finally(() => {
|
||||
reconcilePromise = undefined;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a Behavior of objects with static properties into an object with
|
||||
* Behavior properties.
|
||||
*
|
||||
* For example, splitting a `Behavior<{ name: string; age: number }>` results
|
||||
* in an object of type `{ name$: Behavior<string>; age$: Behavior<number> }`.
|
||||
*/
|
||||
public splitBehavior<T extends object>(
|
||||
input$: Behavior<T>,
|
||||
): SplitBehavior<T> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(input$.value).map((key) => [
|
||||
`${key}$`,
|
||||
this.behavior(
|
||||
input$.pipe(map((input) => input[key as keyof T])),
|
||||
nothing,
|
||||
key,
|
||||
),
|
||||
]),
|
||||
) as SplitBehavior<T>;
|
||||
}
|
||||
}
|
||||
export {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
ObservableScope,
|
||||
trackEpoch,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
/**
|
||||
* The global scope, a scope which never ends.
|
||||
*/
|
||||
export const globalScope = new ObservableScope();
|
||||
|
||||
/**
|
||||
* `Epoch`'s can be used to create `Behavior`s and `Observable`s which derivitives can be merged
|
||||
* with `combinedLatest` without duplicated emissions.
|
||||
*
|
||||
* This is useful in the following example:
|
||||
* ```
|
||||
* const rootObs$ = of("red","green","blue");
|
||||
* const derivedObs$ = rootObs$.pipe(
|
||||
* map((v)=> {red:"fire", green:"grass", blue:"water"}[v])
|
||||
* );
|
||||
* const otherDerivedObs$ = rootObs$.pipe(
|
||||
* map((v)=> {red:"tomatoes", green:"leaves", blue:"sky"}[v])
|
||||
* );
|
||||
* const mergedObs$ = combineLatest([rootObs$, derivedObs$, otherDerivedObs$]).pipe(
|
||||
* map(([color, a,b]) => color + " like " + a + " and " + b)
|
||||
* );
|
||||
*
|
||||
* ```
|
||||
* will result in 6 emissions with mismatching items like "red like fire and leaves"
|
||||
*
|
||||
* # Use Epoch
|
||||
* ```
|
||||
* const ancestorObs$ = of(1,2,3).pipe(trackEpoch());
|
||||
* const derivedObs$ = ancestorObs$.pipe(
|
||||
* mapEpoch((v)=> "this number: " + v)
|
||||
* );
|
||||
* const otherDerivedObs$ = ancestorObs$.pipe(
|
||||
* mapEpoch((v)=> "multiplied by: " + v)
|
||||
* );
|
||||
* const mergedObs$ = combineLatest([derivedObs$, otherDerivedObs$]).pipe(
|
||||
* filter((values) => values.every((v) => v.epoch === values[0].v)),
|
||||
* map(([color, a, b]) => color + " like " + a + " and " + b)
|
||||
* );
|
||||
*
|
||||
* ```
|
||||
* will result in 3 emissions all matching (e.g. "blue like water and sky")
|
||||
*/
|
||||
export class Epoch<T> {
|
||||
public readonly epoch: number;
|
||||
public readonly value: T;
|
||||
|
||||
public constructor(value: T, epoch?: number) {
|
||||
this.value = value;
|
||||
this.epoch = epoch ?? 0;
|
||||
}
|
||||
/**
|
||||
* Maps the value inside the epoch to a new value while keeping the epoch number.
|
||||
* # usage
|
||||
* ```
|
||||
* const myEpoch$ = myObservable$.pipe(
|
||||
* map(trackEpoch()),
|
||||
* // this is the preferred way using mapEpoch
|
||||
* mapEpoch((v)=> v+1)
|
||||
* // This is how inner map can be used:
|
||||
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||
* // It is equivalent to:
|
||||
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
|
||||
* )
|
||||
* ```
|
||||
* See also `Epoch<T>`
|
||||
*/
|
||||
public mapInner<U>(map: (value: T) => U): Epoch<U> {
|
||||
return new Epoch<U>(map(this.value), this.epoch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `pipe` compatible map oparator that keeps the epoch in tact but allows mapping the value.
|
||||
* # usage
|
||||
* ```
|
||||
* const myEpoch$ = myObservable$.pipe(
|
||||
* map(trackEpoch()),
|
||||
* // this is the preferred way using mapEpoch
|
||||
* mapEpoch((v)=> v+1)
|
||||
* // This is how inner map can be used:
|
||||
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||
* // It is equivalent to:
|
||||
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
|
||||
* )
|
||||
* ```
|
||||
* See also `Epoch<T>`
|
||||
*/
|
||||
export function mapEpoch<T, U>(
|
||||
mapFn: (value: T) => U,
|
||||
): OperatorFunction<Epoch<T>, Epoch<U>> {
|
||||
return map((e) => e.mapInner(mapFn));
|
||||
}
|
||||
|
||||
/**
|
||||
* # usage
|
||||
* ```
|
||||
* const myEpoch$ = myObservable$.pipe(
|
||||
* map(trackEpoch()),
|
||||
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||
* )
|
||||
* const derived = myEpoch$.pipe(
|
||||
* mapEpoch((v)=>v^2)
|
||||
* )
|
||||
* ```
|
||||
* See also `Epoch<T>`
|
||||
*/
|
||||
export function trackEpoch<T>(): OperatorFunction<T, Epoch<T>> {
|
||||
return map<T, Epoch<T>>((value, number) => new Epoch(value, number));
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
Copyright 2025, 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 {
|
||||
MatrixRTCSessionEvent,
|
||||
type Transport,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import {
|
||||
createKeyRotationSuppressed$,
|
||||
membershipsAndTransports$,
|
||||
} from "./SessionBehaviors";
|
||||
import { Epoch } from "./ObservableScope";
|
||||
import { constant } from "./Behavior";
|
||||
import { mockRtcMembership, testScope } from "../utils/test";
|
||||
|
||||
describe("SessionBehaviors", () => {
|
||||
describe("createKeyRotationSuppressed$", () => {
|
||||
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
|
||||
const scope = testScope();
|
||||
const emitter = new EventEmitter();
|
||||
const mockSession = Object.assign(emitter, {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
isKeyRotationSuppressed: false,
|
||||
});
|
||||
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
mockSession as any,
|
||||
);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(true);
|
||||
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("membershipsAndTransports$", () => {
|
||||
const serviceTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
const urlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.sample.com",
|
||||
};
|
||||
|
||||
it("locates each member's transport on the member's homeserver", () => {
|
||||
const bob = mockRtcMembership("@bob:example.org", "DEV000", {
|
||||
fociPreferred: [serviceTransport],
|
||||
});
|
||||
const carl = mockRtcMembership("@carl:sample.com", "DEV111", {
|
||||
fociPreferred: [urlTransport],
|
||||
});
|
||||
|
||||
const { membershipsWithTransport$, transports$ } =
|
||||
membershipsAndTransports$(
|
||||
testScope(),
|
||||
constant(new Epoch([bob, carl])),
|
||||
);
|
||||
|
||||
expect(transports$.value.value).toEqual([
|
||||
{ transport: serviceTransport, serverName: "example.org" },
|
||||
{ transport: urlTransport, serverName: "sample.com" },
|
||||
]);
|
||||
expect(
|
||||
membershipsWithTransport$.value.value.map(
|
||||
(m) => m.transport?.serverName,
|
||||
),
|
||||
).toEqual(["example.org", "sample.com"]);
|
||||
});
|
||||
|
||||
it("leaves out members without a LiveKit transport", () => {
|
||||
const dave = mockRtcMembership("@dave:example.org", "DEV222", {
|
||||
fociPreferred: [{ type: "something-else" } as Transport],
|
||||
});
|
||||
|
||||
const { membershipsWithTransport$, transports$ } =
|
||||
membershipsAndTransports$(testScope(), constant(new Epoch([dave])));
|
||||
|
||||
expect(transports$.value.value).toEqual([]);
|
||||
expect(membershipsWithTransport$.value.value).toEqual([
|
||||
{ membership: dave, transport: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
isUnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { fromEvent } from "rxjs";
|
||||
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
trackEpoch,
|
||||
type ObservableScope,
|
||||
} from "./ObservableScope";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type TransportLocator } from "../livekit/auth";
|
||||
|
||||
/**
|
||||
* Tracks the transports used by ourselves, plus all other MatrixRTC session
|
||||
* members.
|
||||
*/
|
||||
export const membershipsAndTransports$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
): {
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{ membership: CallMembership; transport: TransportLocator | undefined }[]
|
||||
>
|
||||
>;
|
||||
transports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
} => {
|
||||
const membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{ membership: CallMembership; transport: TransportLocator | undefined }[]
|
||||
>
|
||||
> = scope.behavior(
|
||||
memberships$.pipe(
|
||||
mapEpoch((memberships) => {
|
||||
return memberships.map((membership) => {
|
||||
const transport = membership.getTransport();
|
||||
return {
|
||||
membership,
|
||||
transport: isUnstableLivekitTransport(transport)
|
||||
? {
|
||||
transport,
|
||||
serverName: membership.userId.replace(/^.*?:/, ""),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const transports$: Behavior<Epoch<TransportLocator[]>> = scope.behavior(
|
||||
membershipsWithTransport$.pipe(
|
||||
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
membershipsWithTransport$,
|
||||
transports$,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMemberships$ = (
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<Epoch<CallMembership[]>> => {
|
||||
return scope.behavior(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
(_, memberships: CallMembership[]) => memberships,
|
||||
).pipe(trackEpoch()),
|
||||
new Epoch(matrixRTCSession.memberships),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
|
||||
* encryption key. While this is true the key in use is still shared with new joiners, but no new
|
||||
* key is generated when someone joins or leaves.
|
||||
*/
|
||||
export const createKeyRotationSuppressed$ = (
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<boolean> => {
|
||||
return scope.behavior(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
|
||||
(suppressed: boolean) => suppressed,
|
||||
),
|
||||
matrixRTCSession.isKeyRotationSuppressed,
|
||||
);
|
||||
};
|
||||
@@ -6,23 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { type ObservableScope } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface LocalScreenShareViewModel extends BaseScreenShareViewModel {
|
||||
local: true;
|
||||
}
|
||||
|
||||
export interface LocalScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
}
|
||||
export type LocalScreenShareInputs = BaseScreenShareInputs;
|
||||
|
||||
export function createLocalScreenShare(
|
||||
scope: ObservableScope,
|
||||
|
||||
@@ -7,29 +7,20 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
type Behavior,
|
||||
type LocalRTCMember,
|
||||
type LocalVideoMediaTrack,
|
||||
type ObservableScope,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map, of, switchMap } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { alwaysShowSelf } from "../../settings/settings";
|
||||
import { platform } from "../../Platform";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
@@ -51,9 +42,9 @@ export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
|
||||
export interface LocalUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
"member"
|
||||
> {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
member: Pick<LocalRTCMember, "local" | "tracks$" | "encryptionError$">;
|
||||
mediaDevices: MediaDevices;
|
||||
}
|
||||
|
||||
@@ -61,76 +52,47 @@ export function createLocalUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ mediaDevices, ...inputs }: LocalUserMediaInputs,
|
||||
): LocalUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "outbound-rtp",
|
||||
});
|
||||
|
||||
/**
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
const videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
baseUserMedia.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const baseUserMedia = createBaseUserMedia(scope, inputs);
|
||||
// Our own camera track, which carries the controls a remote one lacks
|
||||
const camera$ = trackBySource$(
|
||||
scope,
|
||||
inputs.member.tracks$,
|
||||
"camera",
|
||||
) as Behavior<LocalVideoMediaTrack | undefined>;
|
||||
const facingMode$ = scope.behavior(
|
||||
camera$.pipe(switchMap((camera) => camera?.facingMode$ ?? of(undefined))),
|
||||
);
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
local: true,
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
mirror$: scope.behavior(
|
||||
videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
facingMode$.pipe(map((facingMode) => facingMode === "user")),
|
||||
),
|
||||
alwaysShow$: alwaysShowSelf.value$,
|
||||
setAlwaysShow: alwaysShowSelf.setValue,
|
||||
switchCamera$: scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
: facingMode$.pipe(
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
map((facingMode) =>
|
||||
facingMode === undefined
|
||||
? null
|
||||
: (): void =>
|
||||
void camera$.value
|
||||
?.switchFacingMode()
|
||||
.then((deviceId) => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
if (deviceId !== undefined)
|
||||
mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -6,35 +6,23 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { constant } from "@element-hq/matrixrtc-sdk";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
mockAudioTrack,
|
||||
mockLocalMedia,
|
||||
mockMediaDevices,
|
||||
mockRemoteMedia,
|
||||
withTestScheduler,
|
||||
mockRemoteParticipant,
|
||||
mockRemoteScreenShare,
|
||||
mockRtcMembership,
|
||||
type MockTracks,
|
||||
mockVideoTrack,
|
||||
withTestScheduler,
|
||||
} from "../../utils/test";
|
||||
import { constant } from "../Behavior";
|
||||
import { showConnectionStats } from "../../settings/settings";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
new (): MediaStreamTrack;
|
||||
prototype: MediaStreamTrack;
|
||||
};
|
||||
global.MediaStream = class {} as unknown as {
|
||||
new (): MediaStream;
|
||||
prototype: MediaStream;
|
||||
};
|
||||
import { EncryptionStatus } from "./MemberMediaViewModel";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../../Platform", () => ({
|
||||
@@ -43,49 +31,41 @@ vi.mock("../../Platform", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const observeRtpStreamStatsMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./observeRtpStreamStats", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
observeRtpStreamStats$: observeRtpStreamStatsMock,
|
||||
}));
|
||||
|
||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
const setVolume = vi.fn();
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({ setVolume }),
|
||||
]);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
expect(setVolume).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
expect(setVolume).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
@@ -101,58 +81,33 @@ test("control a participant's volume", () => {
|
||||
});
|
||||
|
||||
test("control a participant's screen share volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteScreenShare(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
const setVolume = vi.fn();
|
||||
const vm = mockRemoteScreenShare(rtcMembership, {}, [
|
||||
mockAudioTrack({ source: "screenShareAudio", setVolume }),
|
||||
]);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.6,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolume).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.2,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolume).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolume).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
@@ -165,27 +120,17 @@ test("control a participant's screen share volume", () => {
|
||||
g: 0.8,
|
||||
});
|
||||
});
|
||||
expect(vm.audioEnabled$.value).toBe(true);
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
const vm1 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
const vm1 = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm1.setAlwaysShow(false) });
|
||||
expectObservable(vm1.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||
});
|
||||
|
||||
// Next local media should start out *not* always shown
|
||||
const vm2 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
const vm2 = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm2.setAlwaysShow(true) });
|
||||
expectObservable(vm2.alwaysShow$).toBe("ab", { a: false, b: true });
|
||||
@@ -196,44 +141,21 @@ test("switch cameras", async () => {
|
||||
// Camera switching is only available on mobile
|
||||
platformMock.mockReturnValue("android");
|
||||
onTestFinished(() => void platformMock.mockReset());
|
||||
|
||||
// Construct a mock video track which knows how to be restarted
|
||||
const track = new LocalVideoTrack({
|
||||
getConstraints() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as MediaStreamTrack);
|
||||
|
||||
let deviceId = "front camera";
|
||||
const restartTrack = vi.fn(async ({ facingMode }) => {
|
||||
deviceId = facingMode === "user" ? "front camera" : "back camera";
|
||||
track.emit(TrackEvent.Restarted);
|
||||
return Promise.resolve();
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment" | undefined>(
|
||||
"user",
|
||||
);
|
||||
// The SDK restarts the camera the other way round and says which device it
|
||||
// ended up on
|
||||
const switchFacingMode = vi.fn(async (): Promise<string> => {
|
||||
const back = facingMode$.value === "user";
|
||||
facingMode$.next(back ? "environment" : "user");
|
||||
return Promise.resolve(back ? "back camera" : "front camera");
|
||||
});
|
||||
track.restartTrack = restartTrack;
|
||||
|
||||
Object.defineProperty(track, "mediaStreamTrack", {
|
||||
get() {
|
||||
return {
|
||||
label: "Video",
|
||||
getSettings: (): object => ({
|
||||
deviceId,
|
||||
facingMode: deviceId === "front camera" ? "user" : "environment",
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const selectVideoInput = vi.fn();
|
||||
|
||||
const vm = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({
|
||||
getTrackPublication() {
|
||||
return { track } as unknown as LocalTrackPublication;
|
||||
},
|
||||
}),
|
||||
[mockVideoTrack({ facingMode$, switchFacingMode })],
|
||||
mockMediaDevices({
|
||||
videoInput: {
|
||||
available$: constant(new Map()),
|
||||
@@ -242,70 +164,84 @@ test("switch cameras", async () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(vm.mirror$.value).toBe(true);
|
||||
|
||||
// Switch to back camera
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
expect(switchFacingMode).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||
});
|
||||
expect(deviceId).toBe("back camera");
|
||||
expect(vm.mirror$.value).toBe(false);
|
||||
|
||||
// Switch to front camera
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
expect(switchFacingMode).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||
});
|
||||
expect(deviceId).toBe("front camera");
|
||||
expect(vm.mirror$.value).toBe(true);
|
||||
});
|
||||
|
||||
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, null); // null participant
|
||||
test("no camera switch where the facing mode is unknown", () => {
|
||||
platformMock.mockReturnValue("android");
|
||||
onTestFinished(() => void platformMock.mockReset());
|
||||
const vm = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
[mockVideoTrack()],
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
expect(vm.switchCamera$.value).toBeNull();
|
||||
});
|
||||
|
||||
test("remote media is in waiting state while its media has not arrived", () => {
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, null);
|
||||
expect(vm.waitingForMedia$.value).toBe(true);
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when participant is connected", () => {
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
test("remote media is not in waiting state once its media is there", () => {
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, []);
|
||||
expect(vm.waitingForMedia$.value).toBe(false);
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when participant is connected with no publications", () => {
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({
|
||||
getTrackPublication: () => undefined,
|
||||
getTrackPublications: () => [],
|
||||
}),
|
||||
);
|
||||
test("remote media stops waiting once its tracks arrive, however few", () => {
|
||||
const tracks$ = new BehaviorSubject<MockTracks>(null);
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, tracks$);
|
||||
expect(vm.waitingForMedia$.value).toBe(true);
|
||||
expect(vm.encryptionStatus$.value).toBe(EncryptionStatus.Connecting);
|
||||
tracks$.next([]);
|
||||
expect(vm.waitingForMedia$.value).toBe(false);
|
||||
});
|
||||
|
||||
test("local media never reports an encryption problem", () => {
|
||||
const vm = mockLocalMedia(rtcMembership, {}, [], mockMediaDevices({}));
|
||||
expect(vm.encryptionStatus$.value).toBe(EncryptionStatus.Okay);
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
undefined, // No room (no advertised transport)
|
||||
);
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, null, {});
|
||||
expect(vm.waitingForMedia$.value).toBe(false);
|
||||
});
|
||||
|
||||
test("audio and video follow the tracks' mute state", () => {
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({ muted$: constant(false) }),
|
||||
mockVideoTrack({ muted$: constant(true) }),
|
||||
]);
|
||||
expect(vm.audioEnabled$.value).toBe(true);
|
||||
expect(vm.videoEnabled$.value).toBe(false);
|
||||
});
|
||||
|
||||
test("user media polls stream stats only while the setting is on", () => {
|
||||
const participant = mockRemoteParticipant({});
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, participant);
|
||||
onTestFinished(() => showConnectionStats.setValue(false));
|
||||
withTestScheduler(({ cold, expectObservable, schedule }) => {
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
observeRtpStreamStatsMock.mockImplementation(() =>
|
||||
cold("-s", { s: stats }),
|
||||
);
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({
|
||||
stats$: () => cold("-s", { s: stats }),
|
||||
}),
|
||||
]);
|
||||
schedule("-a-b", {
|
||||
a() {
|
||||
showConnectionStats.setValue(true);
|
||||
@@ -319,14 +255,4 @@ test("user media polls stream stats only while the setting is on", () => {
|
||||
s: stats,
|
||||
});
|
||||
});
|
||||
expect(observeRtpStreamStatsMock).toHaveBeenCalledWith(
|
||||
participant,
|
||||
Track.Source.Microphone,
|
||||
"inbound-rtp",
|
||||
);
|
||||
expect(observeRtpStreamStatsMock).toHaveBeenCalledWith(
|
||||
participant,
|
||||
Track.Source.Camera,
|
||||
"inbound-rtp",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,18 +7,18 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
type Participant,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
type AudioSource,
|
||||
roomEventSelector,
|
||||
type TrackReference,
|
||||
type VideoSource,
|
||||
} from "@livekit/components-core";
|
||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
type Behavior,
|
||||
E2eeType,
|
||||
type EncryptionError,
|
||||
type EncryptionSystem,
|
||||
type MediaTrack,
|
||||
type ObservableScope,
|
||||
type MemberMedia,
|
||||
type RTCMember,
|
||||
trackBySource$,
|
||||
type VideoMediaTrack,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
@@ -31,16 +31,12 @@ import {
|
||||
throttleTime,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/** How often a view model reads a track's RTP statistics while it needs them. */
|
||||
export const statsIntervalMs = 1000;
|
||||
|
||||
// TODO: Encryption status is kinda broken and thus unused right now. Remove?
|
||||
export enum EncryptionStatus {
|
||||
@@ -56,14 +52,14 @@ export enum EncryptionStatus {
|
||||
*/
|
||||
export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
* The video track for this media.
|
||||
*/
|
||||
video$: Behavior<TrackReference | undefined>;
|
||||
video$: Behavior<VideoMediaTrack | undefined>;
|
||||
/**
|
||||
* The LiveKit transport on which this member should be publishing. Exposed
|
||||
* for debugging.
|
||||
* The transport on which this member should be publishing, as it appears
|
||||
* in the membership. Exposed for debugging.
|
||||
*/
|
||||
transport$: Behavior<UnstableLivekitTransport | undefined>;
|
||||
transport$: Behavior<Transport | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
@@ -71,20 +67,21 @@ export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
|
||||
encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
}
|
||||
|
||||
/** The member whose media this is, as far as the media view models read it. */
|
||||
export type MediaMember = Pick<RTCMember, "local"> & MemberMedia;
|
||||
|
||||
export interface MemberMediaInputs extends BaseMediaViewModel {
|
||||
participant$: Behavior<LocalParticipant | RemoteParticipant | null>;
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>;
|
||||
audioSource: AudioSource;
|
||||
videoSource: VideoSource;
|
||||
transport$: Behavior<UnstableLivekitTransport | undefined>;
|
||||
member: MediaMember;
|
||||
audioSource: "microphone" | "screenShareAudio";
|
||||
videoSource: "camera" | "screenShare";
|
||||
transport$: Behavior<Transport | undefined>;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
export function createMemberMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant$,
|
||||
livekitRoom$,
|
||||
member,
|
||||
audioSource,
|
||||
videoSource,
|
||||
transport$,
|
||||
@@ -92,20 +89,12 @@ export function createMemberMedia(
|
||||
...inputs
|
||||
}: MemberMediaInputs,
|
||||
): BaseMemberMediaViewModel {
|
||||
const trackBehavior$ = (
|
||||
scope: ObservableScope,
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReference | undefined> =>
|
||||
scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const audio$ = trackBehavior$(scope, audioSource);
|
||||
const video$ = trackBehavior$(scope, videoSource);
|
||||
const audio$ = trackBySource$(scope, member.tracks$, audioSource);
|
||||
const video$ = trackBySource$(scope, member.tracks$, videoSource);
|
||||
const unencrypted$ = (
|
||||
track$: Behavior<MediaTrack | undefined>,
|
||||
): Observable<boolean> =>
|
||||
track$.pipe(map((track) => track !== undefined && !track.encrypted));
|
||||
|
||||
return {
|
||||
...createBaseMedia(inputs),
|
||||
@@ -113,162 +102,81 @@ export function createMemberMedia(
|
||||
transport$,
|
||||
unencryptedWarning$: scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
[unencrypted$(audio$), unencrypted$(video$)],
|
||||
(a, v) => encryptionSystem.kind !== E2eeType.NONE && (a || v),
|
||||
),
|
||||
),
|
||||
encryptionStatus$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
member.tracks$.pipe(
|
||||
switchMap((tracks): Observable<EncryptionStatus> => {
|
||||
if (tracks === null) return of(EncryptionStatus.Connecting);
|
||||
if (member.local || encryptionSystem.kind === E2eeType.NONE)
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
const perParticipant =
|
||||
encryptionSystem.kind === E2eeType.PER_PARTICIPANT;
|
||||
return combineLatest([
|
||||
encryptionError$(member.encryptionError$, "MissingKey"),
|
||||
encryptionError$(member.encryptionError$, "InvalidKey"),
|
||||
receivingOkay$(audio$),
|
||||
receivingOkay$(video$),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (perParticipant && keyMissing)
|
||||
return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid)
|
||||
return perParticipant
|
||||
? EncryptionStatus.KeyInvalid
|
||||
: EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => x !== undefined),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function encryptionErrorObservable$(
|
||||
room$: Behavior<LivekitRoom | undefined>,
|
||||
participant: Participant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
criteria: string,
|
||||
function encryptionError$(
|
||||
errors$: Observable<EncryptionError>,
|
||||
criteria: EncryptionError,
|
||||
): Observable<boolean> {
|
||||
return room$.pipe(
|
||||
switchMap((room) => {
|
||||
if (room === undefined) return of(false);
|
||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||
map((e) => {
|
||||
const [err] = e;
|
||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return (
|
||||
// Ideally we would pull the participant identity from the field on the error.
|
||||
// However, it gets lost in the serialization process between workers.
|
||||
// So, instead we do a string match
|
||||
(err?.message.includes(participant.identity) &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||
return !!err?.message.includes(criteria);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
return errors$.pipe(
|
||||
map((error) => error === criteria),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
|
||||
function observeRemoteTrackReceivingOkay$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
/**
|
||||
* Whether frames arrive and decode, which is the only sign that the key in
|
||||
* use is the right one.
|
||||
*/
|
||||
function receivingOkay$(
|
||||
track$: Behavior<MediaTrack | undefined>,
|
||||
): Observable<boolean | undefined> {
|
||||
let lastStats: {
|
||||
framesDecoded: number | undefined;
|
||||
framesDropped: number | undefined;
|
||||
framesReceived: number | undefined;
|
||||
} = {
|
||||
framesDecoded: undefined,
|
||||
framesDropped: undefined,
|
||||
framesReceived: undefined,
|
||||
};
|
||||
|
||||
return observeInboundRtpStreamStats$(participant, source).pipe(
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
const { framesDecoded, framesDropped, framesReceived } = stats;
|
||||
return {
|
||||
framesDecoded,
|
||||
framesDropped,
|
||||
framesReceived,
|
||||
};
|
||||
}),
|
||||
filter((newStats) => !!newStats),
|
||||
map((newStats): boolean | undefined => {
|
||||
const oldStats = lastStats;
|
||||
lastStats = newStats;
|
||||
let last: { framesDecoded?: number; framesReceived?: number } = {};
|
||||
return track$.pipe(
|
||||
switchMap((track) => track?.stats$(statsIntervalMs) ?? of(undefined)),
|
||||
map((stats): boolean | undefined => {
|
||||
if (stats === undefined || stats.type !== "inbound-rtp") return undefined;
|
||||
const { framesDecoded, framesReceived } =
|
||||
stats as RTCInboundRtpStreamStats;
|
||||
const previous = last;
|
||||
last = { framesDecoded, framesReceived };
|
||||
if (
|
||||
typeof newStats.framesReceived === "number" &&
|
||||
typeof oldStats.framesReceived === "number" &&
|
||||
typeof newStats.framesDecoded === "number" &&
|
||||
typeof oldStats.framesDecoded === "number"
|
||||
) {
|
||||
const framesReceivedDelta =
|
||||
newStats.framesReceived - oldStats.framesReceived;
|
||||
const framesDecodedDelta =
|
||||
newStats.framesDecoded - oldStats.framesDecoded;
|
||||
|
||||
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||
|
||||
if (framesReceivedDelta > 0) {
|
||||
return framesDecodedDelta > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// no change
|
||||
return undefined;
|
||||
framesReceived === undefined ||
|
||||
previous.framesReceived === undefined ||
|
||||
framesDecoded === undefined ||
|
||||
previous.framesDecoded === undefined
|
||||
)
|
||||
return undefined;
|
||||
const received = framesReceived - previous.framesReceived;
|
||||
if (received > 0) return framesDecoded - previous.framesDecoded > 0;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => typeof x === "boolean"),
|
||||
startWith(undefined),
|
||||
|
||||
@@ -6,18 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track, type RemoteParticipant } from "livekit-client";
|
||||
import { map, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type Behavior,
|
||||
type ObservableScope,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
export interface RemoteScreenShareViewModel
|
||||
extends BaseScreenShareViewModel, VolumeControls {
|
||||
@@ -33,7 +34,6 @@ export interface RemoteScreenShareViewModel
|
||||
}
|
||||
|
||||
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
@@ -41,16 +41,18 @@ export function createRemoteScreenShare(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
|
||||
): RemoteScreenShareViewModel {
|
||||
const audio$ = trackBySource$(
|
||||
scope,
|
||||
inputs.member.tracks$,
|
||||
"screenShareAudio",
|
||||
);
|
||||
return {
|
||||
...createBaseScreenShare(scope, inputs),
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
map(
|
||||
(p) => (volume) =>
|
||||
p?.setVolume(volume, Track.Source.ScreenShareAudio),
|
||||
),
|
||||
audio$.pipe(
|
||||
map((track) => (volume: number) => track?.setVolume(volume)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
@@ -59,14 +61,7 @@ export function createRemoteScreenShare(
|
||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
||||
: of(null),
|
||||
),
|
||||
map(Boolean),
|
||||
),
|
||||
audio$.pipe(map((track) => track !== undefined)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,34 +6,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
type Behavior,
|
||||
type ObservableScope,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface RemoteUserMediaViewModel
|
||||
extends BaseUserMediaViewModel, VolumeControls {
|
||||
local: false;
|
||||
/**
|
||||
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||
* could be because either we or the remote party are still connecting.
|
||||
* Whether we are waiting for this user's media to arrive. This could be
|
||||
* because either we or the remote party are still connecting.
|
||||
*/
|
||||
waitingForMedia$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
> {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
export interface RemoteUserMediaInputs extends BaseUserMediaInputs {
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
@@ -41,19 +39,15 @@ export function createRemoteUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
|
||||
): RemoteUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "inbound-rtp",
|
||||
});
|
||||
const baseUserMedia = createBaseUserMedia(scope, inputs);
|
||||
|
||||
const waitingForMedia$ = scope.behavior(
|
||||
combineLatest(
|
||||
[inputs.livekitRoom$, inputs.participant$],
|
||||
(livekitRoom, participant) =>
|
||||
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||
// any transport and so we shouldn't expect a participant. (They might
|
||||
// be a subscribe-only bot for example.)
|
||||
livekitRoom !== undefined && participant === null,
|
||||
[inputs.transport$, inputs.member.tracks$],
|
||||
// Without a transport the user is not attempting to publish anywhere
|
||||
// and so we shouldn't expect media. (They might be a subscribe-only bot
|
||||
// for example.)
|
||||
(transport, tracks) => transport !== undefined && tracks === null,
|
||||
),
|
||||
);
|
||||
waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => {
|
||||
@@ -65,7 +59,9 @@ export function createRemoteUserMedia(
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||
trackBySource$(scope, inputs.member.tracks$, "microphone").pipe(
|
||||
map((track) => (volume: number) => track?.setVolume(volume)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
local: false,
|
||||
|
||||
@@ -6,9 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track } from "livekit-client";
|
||||
import { type ObservableScope } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
@@ -43,8 +42,8 @@ export function createBaseScreenShare(
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.ScreenShareAudio,
|
||||
videoSource: Track.Source.ScreenShare,
|
||||
audioSource: "screenShareAudio",
|
||||
videoSource: "screenShare",
|
||||
}),
|
||||
type: "screen share",
|
||||
};
|
||||
|
||||
@@ -6,6 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Behavior,
|
||||
type MediaStreamStats,
|
||||
type ObservableScope,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
@@ -14,24 +20,17 @@ import {
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { ParticipantEvent, Track } from "livekit-client";
|
||||
|
||||
import { type ReactionOption } from "../../reactions";
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
type MemberMediaInputs,
|
||||
type BaseMemberMediaViewModel,
|
||||
statsIntervalMs,
|
||||
} from "./MemberMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { showConnectionStats } from "../../settings/settings";
|
||||
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
|
||||
/**
|
||||
* A participant's user media (i.e. their microphone and camera feed).
|
||||
@@ -48,17 +47,13 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
||||
videoOrientation$: Behavior<"landscape" | "portrait">;
|
||||
toggleCropVideo: () => void;
|
||||
/**
|
||||
* The expected identity of the LiveKit participant. Exposed for debugging.
|
||||
* The expected identity of the member on the media backend. Exposed for debugging.
|
||||
*/
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
audioStreamStats$: Behavior<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
videoStreamStats$: Behavior<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
audioStreamStats$: Behavior<MediaStreamStats>;
|
||||
videoStreamStats$: Behavior<MediaStreamStats>;
|
||||
/**
|
||||
* Set the aspect ratio of the video track to determine the orientation.
|
||||
*/
|
||||
@@ -72,7 +67,6 @@ export interface BaseUserMediaInputs extends Omit<
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
statsType: "inbound-rtp" | "outbound-rtp";
|
||||
}
|
||||
|
||||
export function createBaseUserMedia(
|
||||
@@ -81,29 +75,38 @@ export function createBaseUserMedia(
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
statsType,
|
||||
...inputs
|
||||
}: BaseUserMediaInputs,
|
||||
): BaseUserMediaViewModel {
|
||||
const { participant$ } = inputs;
|
||||
const media$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
const { tracks$ } = inputs.member;
|
||||
const toggleCropVideo$ = new Subject<void>();
|
||||
const videoAspectRatio$ = new BehaviorSubject(NaN);
|
||||
const enabled$ = (
|
||||
scope: ObservableScope,
|
||||
source: "microphone" | "camera",
|
||||
): Behavior<boolean> =>
|
||||
scope.behavior(
|
||||
trackBySource$(scope, tracks$, source).pipe(
|
||||
switchMap((track) =>
|
||||
track === undefined
|
||||
? of(false)
|
||||
: track.muted$.pipe(map((muted) => !muted)),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The statistics are only polled while the setting asks for them
|
||||
const streamStats$ = (
|
||||
scope: ObservableScope,
|
||||
source: Track.Source,
|
||||
): Behavior<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> =>
|
||||
source: "microphone" | "camera",
|
||||
): Behavior<MediaStreamStats> =>
|
||||
scope.behavior(
|
||||
combineLatest([participant$, showConnectionStats.value$]).pipe(
|
||||
switchMap(([p, showConnectionStats]) =>
|
||||
p && showConnectionStats
|
||||
? observeRtpStreamStats$(p, source, statsType)
|
||||
combineLatest([
|
||||
trackBySource$(scope, tracks$, source),
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([track, show]) =>
|
||||
track !== undefined && show
|
||||
? track.stats$(statsIntervalMs)
|
||||
: of(undefined),
|
||||
),
|
||||
),
|
||||
@@ -112,28 +115,18 @@ export function createBaseUserMedia(
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.Microphone,
|
||||
videoSource: Track.Source.Camera,
|
||||
audioSource: "microphone",
|
||||
videoSource: "camera",
|
||||
}),
|
||||
type: "user",
|
||||
// What a call calls speaking is the microphone carrying sound
|
||||
speaking$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
trackBySource$(scope, tracks$, "microphone").pipe(
|
||||
switchMap((track) => track?.isActive$ ?? of(false)),
|
||||
),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
),
|
||||
videoEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
),
|
||||
audioEnabled$: enabled$(scope, "microphone"),
|
||||
videoEnabled$: enabled$(scope, "camera"),
|
||||
videoOrientation$: scope.behavior(
|
||||
videoAspectRatio$.pipe(
|
||||
map((aspect) => (aspect > 1 ? "landscape" : "portrait")),
|
||||
@@ -144,8 +137,8 @@ export function createBaseUserMedia(
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
audioStreamStats$: streamStats$(scope, Track.Source.Microphone),
|
||||
videoStreamStats$: streamStats$(scope, Track.Source.Camera),
|
||||
audioStreamStats$: streamStats$(scope, "microphone"),
|
||||
videoStreamStats$: streamStats$(scope, "camera"),
|
||||
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,20 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
type Behavior,
|
||||
generateItems,
|
||||
type LocalRTCMember,
|
||||
type ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { combineLatest, distinctUntilChanged, map, of } from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
import type { Behavior } from "../Behavior.ts";
|
||||
import type { MediaDevices } from "../MediaDevices.ts";
|
||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||
import { generateItems } from "../../utils/observable.ts";
|
||||
import { type TaggedParticipant } from "../CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel.ts";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts";
|
||||
import {
|
||||
@@ -82,82 +79,51 @@ export type WrappedUserMediaViewModel = UserMediaViewModel & {
|
||||
bin$: Behavior<SortingBin>;
|
||||
};
|
||||
|
||||
interface WrappedUserMediaInputs extends Omit<
|
||||
type WrappedUserMediaInputs = Omit<
|
||||
LocalUserMediaInputs & RemoteUserMediaInputs,
|
||||
"participant$"
|
||||
> {
|
||||
participant: TaggedParticipant;
|
||||
mediaDevices: MediaDevices;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
"member"
|
||||
> & { member: LocalRTCMember | RemoteRTCMember };
|
||||
|
||||
export function createWrappedUserMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant,
|
||||
member,
|
||||
mediaDevices,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs
|
||||
}: WrappedUserMediaInputs,
|
||||
): WrappedUserMediaViewModel {
|
||||
const userMedia =
|
||||
participant.type === "local"
|
||||
? createLocalUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
mediaDevices,
|
||||
...inputs,
|
||||
})
|
||||
: createRemoteUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs,
|
||||
});
|
||||
|
||||
// TypeScript needs this widening of the type to happen in a separate statement
|
||||
const participant$: Behavior<LocalParticipant | RemoteParticipant | null> =
|
||||
participant.value$;
|
||||
const userMedia = member.local
|
||||
? createLocalUserMedia(scope, { member, mediaDevices, ...inputs })
|
||||
: createRemoteUserMedia(scope, {
|
||||
member,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs,
|
||||
});
|
||||
|
||||
const screenShares$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? of([])
|
||||
: observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
`${inputs.id} screenShares$`,
|
||||
function* (p) {
|
||||
if (p.isScreenShareEnabled)
|
||||
yield {
|
||||
keys: ["screen-share"],
|
||||
data: undefined,
|
||||
};
|
||||
},
|
||||
(scope, _data$, key) => {
|
||||
const id = `${inputs.id}:${key}`;
|
||||
return participant.type === "local"
|
||||
? createLocalScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
})
|
||||
: createRemoteScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
trackBySource$(scope, member.tracks$, "screenShare").pipe(
|
||||
map((track) => track !== undefined),
|
||||
distinctUntilChanged(),
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
`${inputs.id} screenShares$`,
|
||||
function* (enabled) {
|
||||
if (enabled) yield { keys: ["screen-share"], data: undefined };
|
||||
},
|
||||
(scope, _data$, key) => {
|
||||
const id = `${inputs.id}:${key}`;
|
||||
return member.local
|
||||
? createLocalScreenShare(scope, { ...inputs, id, member })
|
||||
: createRemoteScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
member,
|
||||
pretendToBeDisconnected$,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalTrack,
|
||||
type Participant,
|
||||
RemoteTrack,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
interval,
|
||||
type Observable,
|
||||
startWith,
|
||||
switchMap,
|
||||
map,
|
||||
share,
|
||||
} from "rxjs";
|
||||
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
// Use a shared timer for all the stats observers so that we don't clog up the
|
||||
// event loop with hundreds of timers in large calls
|
||||
const refreshStats$ = interval(1000).pipe(share());
|
||||
|
||||
export function observeRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
type: "inbound-rtp" | "outbound-rtp",
|
||||
): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
refreshStats$.pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
if (
|
||||
!track ||
|
||||
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const report = await track.getRTCStatsReport();
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const v of report.values()) {
|
||||
if (v.type === type) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeInboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
observeParticipantMedia,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import { type Participant, type Track } from "livekit-client";
|
||||
import { distinctUntilChanged, map, type Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* Reactively reads a participant's track reference for a given media source.
|
||||
*/
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
type RemoteTrackPublication,
|
||||
} from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
@@ -19,9 +15,7 @@ import { GridTile } from "./GridTile";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
mockLocalMedia,
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
@@ -67,11 +61,7 @@ test("GridTile displays remote media", async () => {
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockRemoteParticipant({
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
@@ -101,10 +91,7 @@ test("GridTile displays local media", async () => {
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockLocalParticipant({
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
}),
|
||||
[],
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaVie
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -104,7 +104,7 @@ interface UserMediaTileProps extends TileProps {
|
||||
playbackMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
transport: UnstableLivekitTransport | undefined;
|
||||
transport: Transport | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-26
@@ -9,26 +9,13 @@ import { describe, expect, it, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import {
|
||||
type TrackReference,
|
||||
type TrackReferencePlaceholder,
|
||||
} from "@livekit/components-core";
|
||||
import { type LocalTrackPublication, Track } from "livekit-client";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
import { mockLocalParticipant } from "../utils/test";
|
||||
import { mockVideoTrack } from "../utils/test";
|
||||
|
||||
describe("MediaView", () => {
|
||||
const participant = mockLocalParticipant({});
|
||||
const trackReferencePlaceholder: TrackReferencePlaceholder = {
|
||||
participant,
|
||||
source: Track.Source.Camera,
|
||||
};
|
||||
const trackReference: TrackReference = {
|
||||
...trackReferencePlaceholder,
|
||||
publication: {} as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
};
|
||||
const track = mockVideoTrack({ source: "camera" });
|
||||
|
||||
const baseProps: ComponentProps<typeof MediaView> = {
|
||||
displayName: "some name",
|
||||
@@ -38,7 +25,7 @@ describe("MediaView", () => {
|
||||
mirror: false,
|
||||
unencryptedWarning: false,
|
||||
showNameTags: true,
|
||||
video: trackReference,
|
||||
video: track,
|
||||
userId: "@alice:example.com",
|
||||
mxcAvatarUrl: undefined,
|
||||
focusable: true,
|
||||
@@ -49,16 +36,6 @@ describe("MediaView", () => {
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
describe("placeholder track", () => {
|
||||
test("neither video nor avatar are shown", () => {
|
||||
render(<MediaView {...baseProps} video={trackReferencePlaceholder} />);
|
||||
expect(screen.queryByTestId("video")).toBeNull();
|
||||
expect(
|
||||
screen.queryAllByRole("img", { name: "@alice:example.com" }).length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no video", () => {
|
||||
it("shows avatar", () => {
|
||||
render(<MediaView {...baseProps} video={undefined} />);
|
||||
@@ -69,6 +46,21 @@ describe("MediaView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("with video", () => {
|
||||
it("labels the video element with the track source", () => {
|
||||
render(
|
||||
<MediaView
|
||||
{...baseProps}
|
||||
video={mockVideoTrack({ source: "screenShare" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("video")).toHaveAttribute(
|
||||
"data-source",
|
||||
"screenShare",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("name tag", () => {
|
||||
test("is shown with name", () => {
|
||||
render(<MediaView {...baseProps} displayName="Bob" />);
|
||||
|
||||
+23
-10
@@ -5,18 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { type VideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { animated } from "@react-spring/web";
|
||||
import {
|
||||
type FC,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
import { Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
@@ -33,14 +34,14 @@ import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
||||
import { RTCConnectionStats } from "../RTCConnectionStats";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
import { autoVideoFit } from "../utils/videoFit";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
interface Props extends ComponentProps<typeof animated.div> {
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
video: VideoMediaTrack | undefined;
|
||||
/**
|
||||
* How to fit the video content inside the tile. When undefined, MediaView
|
||||
* chooses a smart default based on the aspect ratios of the tile and video.
|
||||
@@ -68,9 +69,9 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
rtcBackendIdentity?: string;
|
||||
/**
|
||||
* The LiveKit transport, mainly for debugging purposes.
|
||||
* The member's transport, mainly for debugging purposes.
|
||||
*/
|
||||
transport?: UnstableLivekitTransport;
|
||||
transport?: Transport;
|
||||
/**
|
||||
* Called whenever the aspect ratio of the video content becomes known or
|
||||
* otherwise changes.
|
||||
@@ -128,13 +129,23 @@ export const MediaView: FC<Props> = ({
|
||||
setOurVideoAspectRatio(ratio);
|
||||
setTheirVideoAspectRatio?.(ratio);
|
||||
};
|
||||
const videoElement = useRef<HTMLVideoElement | null>(null);
|
||||
const videoRef = (el: HTMLVideoElement | null) => {
|
||||
videoElement.current = el;
|
||||
if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight);
|
||||
};
|
||||
const onResize = (ev: SyntheticEvent<HTMLVideoElement>) =>
|
||||
setVideoAspectRatio(
|
||||
ev.currentTarget.videoWidth / ev.currentTarget.videoHeight,
|
||||
);
|
||||
// The SDK plays the track on the element and watches its size, so the
|
||||
// element has to be handed back before it leaves the DOM
|
||||
useEffect(() => {
|
||||
const el = videoElement.current;
|
||||
if (video === undefined || el === null) return;
|
||||
video.attach(el);
|
||||
return (): void => video.detach(el);
|
||||
}, [video]);
|
||||
|
||||
const warnings = unencryptedWarning && (
|
||||
<Tooltip
|
||||
@@ -186,13 +197,15 @@ export const MediaView: FC<Props> = ({
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
{video !== undefined && (
|
||||
// A live camera has no captions to offer
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
data-testid="video"
|
||||
data-source={video.source}
|
||||
// Set the placeholder to a small transparent image. (On Android web
|
||||
// views the default poster image is particularly ugly.)
|
||||
poster={videoPlaceholder}
|
||||
|
||||
@@ -14,12 +14,10 @@ import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
mockLocalMedia,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
mockRemoteScreenShare,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
@@ -42,7 +40,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockRemoteParticipant({}),
|
||||
[],
|
||||
);
|
||||
|
||||
const vm2 = mockLocalMedia(
|
||||
@@ -51,7 +49,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
mockLocalParticipant({}),
|
||||
[],
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
|
||||
@@ -99,7 +97,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
||||
const vm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
[],
|
||||
);
|
||||
|
||||
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true));
|
||||
@@ -137,7 +135,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
||||
const vm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
[],
|
||||
);
|
||||
|
||||
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false));
|
||||
|
||||
@@ -29,7 +29,7 @@ import { animated } from "@react-spring/web";
|
||||
import { BehaviorSubject, type Observable, map } from "rxjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { type VideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { Menu, MenuItem, Text } from "@vector-im/compound-web";
|
||||
|
||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||
@@ -54,7 +54,7 @@ import { platform } from "../Platform";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -73,9 +73,9 @@ interface SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
video: VideoMediaTrack | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
transport: UnstableLivekitTransport | undefined;
|
||||
transport: Transport | undefined;
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { act, render, type RenderResult } from "@testing-library/react";
|
||||
import { type Behavior, type RemoteRTCMember } from "@element-hq/matrixrtc-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MemberAudioRenderer } from "./MemberAudioRenderer";
|
||||
import {
|
||||
mockAudioTrack,
|
||||
mockMediaDevices,
|
||||
mockRTCMember,
|
||||
mockRtcMembership,
|
||||
type MockTracks,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
|
||||
/** The renderer creates one for the earpiece graph; jsdom has none. */
|
||||
class FakeAudioContext {
|
||||
public createGain(): unknown {
|
||||
return { gain: { value: 1 } };
|
||||
}
|
||||
public createStereoPanner(): unknown {
|
||||
return { pan: { value: 0 } };
|
||||
}
|
||||
public async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", FakeAudioContext);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderMembers(
|
||||
members: { id: string; tracks$: Behavior<MockTracks> }[],
|
||||
): RenderResult {
|
||||
const rtcMembers: RemoteRTCMember[] = members.map(({ id, tracks$ }) =>
|
||||
mockRTCMember(false, {
|
||||
membership: mockRtcMembership(`@${id}:example.org`, "DEVICE"),
|
||||
tracks$,
|
||||
}),
|
||||
);
|
||||
return render(
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
|
||||
<MemberAudioRenderer members={rtcMembers} />
|
||||
</MediaDevicesContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("plays nothing for a member whose media has not arrived", () => {
|
||||
const { queryAllByTestId } = renderMembers([
|
||||
{ id: "alice", tracks$: new BehaviorSubject<MockTracks>(null) },
|
||||
]);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("plays the microphone and the screen share audio, and nothing else", () => {
|
||||
const microphone = mockAudioTrack({ id: "mic" });
|
||||
const screenShareAudio = mockAudioTrack({
|
||||
id: "share-audio",
|
||||
source: "screenShareAudio",
|
||||
});
|
||||
const camera = mockVideoTrack({ id: "camera" });
|
||||
const unknownAudio = mockAudioTrack({ id: "unknown", source: "unknown" });
|
||||
const { queryAllByTestId } = renderMembers([
|
||||
{
|
||||
id: "alice",
|
||||
tracks$: new BehaviorSubject<MockTracks>([
|
||||
microphone,
|
||||
camera,
|
||||
screenShareAudio,
|
||||
unknownAudio,
|
||||
]),
|
||||
},
|
||||
]);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(2);
|
||||
expect(microphone.attach).toHaveBeenCalledTimes(1);
|
||||
expect(screenShareAudio.attach).toHaveBeenCalledTimes(1);
|
||||
expect(camera.attach).not.toHaveBeenCalled();
|
||||
expect(unknownAudio.attach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("starts playing once the member's tracks arrive", () => {
|
||||
const tracks$ = new BehaviorSubject<MockTracks>(null);
|
||||
const { queryAllByTestId } = renderMembers([{ id: "alice", tracks$ }]);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
|
||||
const microphone = mockAudioTrack({ id: "mic" });
|
||||
act(() => tracks$.next([microphone]));
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
expect(microphone.attach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
Copyright 2025 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type MediaSource,
|
||||
type RemoteRTCMember,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { type FC, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import * as controls from "../controls";
|
||||
|
||||
export interface MemberAudioRendererProps {
|
||||
/** The remote members whose audio should be heard; never the local one. */
|
||||
members: RemoteRTCMember[];
|
||||
/**
|
||||
* If set to `true`, mutes every audio track.
|
||||
* @remarks
|
||||
* If set to `true`, the server will stop sending audio track data to the client.
|
||||
*/
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays every remote member's microphone and screen share audio, one audio
|
||||
* element per track. Only members of the session reach here, so anything
|
||||
* else that publishes on the transport stays inaudible.
|
||||
*
|
||||
* It also takes care of the earpiece audio configuration for iOS devices.
|
||||
* This is done by using the WebAudio API to create a stereo pan effect that
|
||||
* mimics the earpiece audio.
|
||||
*/
|
||||
export const MemberAudioRenderer: FC<MemberAudioRendererProps> = ({
|
||||
members,
|
||||
muted,
|
||||
}) => {
|
||||
// This component is also (in addition to the "only play audio for session
|
||||
// members" logic above) responsible for mimicking earpiece audio on iPhones.
|
||||
// The Safari audio devices enumeration does not expose an earpiece audio device.
|
||||
// We alternatively use the audioContext pan node to only use one of the stereo channels.
|
||||
|
||||
// This component does get additionally complicated because of a Safari bug.
|
||||
// (see: https://bugs.webkit.org/show_bug.cgi?id=251532
|
||||
// and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878
|
||||
// and https://bugs.webkit.org/show_bug.cgi?id=231105)
|
||||
//
|
||||
// AudioContext gets stopped if the webview gets moved into the background.
|
||||
// Once the phone is in standby audio playback will stop.
|
||||
// So we can only use the pan trick only works is the phone is not in standby.
|
||||
// If earpiece mode is not used we do not use audioContext to allow standby playback.
|
||||
// shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback.
|
||||
|
||||
const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig();
|
||||
const shouldUseAudioContext = stereoPan !== 0;
|
||||
|
||||
// initialize the potentially used audio context.
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
const ctx = new AudioContext();
|
||||
setAudioContext(ctx);
|
||||
return (): void => {
|
||||
void ctx.close();
|
||||
};
|
||||
}, []);
|
||||
const audioNodes = useMemo(
|
||||
() => ({
|
||||
gain: audioContext?.createGain(),
|
||||
pan: audioContext?.createStereoPanner(),
|
||||
}),
|
||||
[audioContext],
|
||||
);
|
||||
|
||||
// Simple effects to update the gain and pan node based on the props
|
||||
useEffect(() => {
|
||||
if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan;
|
||||
}, [audioNodes.pan, stereoPan]);
|
||||
useEffect(() => {
|
||||
if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor;
|
||||
}, [audioNodes.gain, volumeFactor]);
|
||||
|
||||
const plugins = useMemo(
|
||||
() =>
|
||||
shouldUseAudioContext && audioNodes.gain && audioNodes.pan
|
||||
? { context: audioContext, nodes: [audioNodes.gain, audioNodes.pan] }
|
||||
: { context: undefined, nodes: [] },
|
||||
[shouldUseAudioContext, audioContext, audioNodes],
|
||||
);
|
||||
|
||||
return (
|
||||
// We add all audio elements into one <div> for the browser developer tool experience/tidyness.
|
||||
<div style={{ display: "none" }}>
|
||||
{members.map((member) => (
|
||||
<MemberAudio
|
||||
key={member.memberId}
|
||||
tracks$={member.tracks$}
|
||||
muted={muted}
|
||||
plugins={plugins}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AudioPlugins {
|
||||
context: AudioContext | undefined;
|
||||
nodes: AudioNode[];
|
||||
}
|
||||
|
||||
/** The sources a call plays; anything published without one stays silent. */
|
||||
const playedSources = new Set<MediaSource>(["microphone", "screenShareAudio"]);
|
||||
|
||||
/** One member's audio tracks; nothing while its media has not arrived. */
|
||||
const MemberAudio: FC<{
|
||||
tracks$: RemoteRTCMember["tracks$"];
|
||||
muted?: boolean;
|
||||
plugins: AudioPlugins;
|
||||
}> = ({ tracks$, muted, plugins }) => {
|
||||
const tracks = useBehavior(tracks$) ?? [];
|
||||
return tracks
|
||||
.filter(
|
||||
(track): track is AudioMediaTrack =>
|
||||
track.kind === "audio" && playedSources.has(track.source),
|
||||
)
|
||||
.map((track) => (
|
||||
<TrackAudio
|
||||
key={track.id}
|
||||
track={track}
|
||||
muted={muted}
|
||||
plugins={plugins}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
/**
|
||||
* One audio element, handed to the SDK to play on. The Web Audio graph for the
|
||||
* earpiece is applied before the element so that playback starts through it.
|
||||
*/
|
||||
const TrackAudio: FC<{
|
||||
track: AudioMediaTrack;
|
||||
muted?: boolean;
|
||||
plugins: AudioPlugins;
|
||||
}> = ({ track, muted, plugins }) => {
|
||||
const [element, setElement] = useState<HTMLAudioElement | null>(null);
|
||||
useEffect(() => {
|
||||
track.setAudioContext(plugins.context, plugins.nodes);
|
||||
}, [track, plugins]);
|
||||
useEffect(() => {
|
||||
if (element === null) return;
|
||||
track.attach(element);
|
||||
controls.setPlaybackStarted();
|
||||
return (): void => track.detach(element);
|
||||
}, [track, element]);
|
||||
// A live microphone has no captions to offer
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio ref={setElement} muted={muted} data-testid="audio" />;
|
||||
};
|
||||
+51
-5
@@ -11,16 +11,21 @@ import {
|
||||
type BackgroundOptions,
|
||||
type ProcessorWrapper,
|
||||
} from "@livekit/track-processors";
|
||||
import { type VideoProcessor } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
|
||||
import {
|
||||
applyProcessor,
|
||||
fromLivekitProcessor,
|
||||
trackProcessorSync,
|
||||
} from "./TrackProcessorContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const processor = {} as ProcessorWrapper<BackgroundOptions>;
|
||||
const processor = { name: "blur" } as VideoProcessor;
|
||||
|
||||
function mockTrack(
|
||||
readyState: MediaStreamTrackState,
|
||||
current?: ProcessorWrapper<BackgroundOptions>,
|
||||
current?: VideoProcessor,
|
||||
): LocalVideoTrack {
|
||||
return {
|
||||
mediaStreamTrack: { readyState },
|
||||
@@ -34,7 +39,9 @@ describe("applyProcessor", () => {
|
||||
it("attaches the processor to a live track", () => {
|
||||
const track = mockTrack("live");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "blur" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not attach the processor to an ended track", () => {
|
||||
@@ -82,6 +89,45 @@ describe("trackProcessorSync", () => {
|
||||
constant(track),
|
||||
constant({ supported: true, processor }),
|
||||
);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "blur" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromLivekitProcessor", () => {
|
||||
const captured = { id: "captured" } as MediaStreamTrack;
|
||||
const processed = { id: "processed" } as MediaStreamTrack;
|
||||
|
||||
function mockWrapper(
|
||||
processedTrack: MediaStreamTrack | undefined,
|
||||
): ProcessorWrapper<BackgroundOptions> {
|
||||
return {
|
||||
name: "background-blur",
|
||||
processedTrack,
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ProcessorWrapper<BackgroundOptions>;
|
||||
}
|
||||
|
||||
it("resolves with the track the wrapper produced", async () => {
|
||||
const wrapper = mockWrapper(processed);
|
||||
const sdkProcessor = fromLivekitProcessor(wrapper);
|
||||
expect(sdkProcessor.name).toBe("background-blur");
|
||||
await expect(sdkProcessor.init(captured)).resolves.toBe(processed);
|
||||
expect(wrapper.init).toHaveBeenCalledWith({
|
||||
kind: "video",
|
||||
track: captured,
|
||||
});
|
||||
await expect(sdkProcessor.restart(captured)).resolves.toBe(processed);
|
||||
await sdkProcessor.destroy();
|
||||
expect(wrapper.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects where the wrapper produced no track", async () => {
|
||||
await expect(
|
||||
fromLivekitProcessor(mockWrapper(undefined)).init(captured),
|
||||
).rejects.toThrow("produced no track");
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,13 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { type LocalVideoTrack, Track } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest } from "rxjs";
|
||||
import {
|
||||
convertToLivekitProcessor,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
@@ -38,9 +42,36 @@ import { useValueBehavior } from "../useValueBehavior";
|
||||
|
||||
export type ProcessorState = {
|
||||
supported: boolean | undefined;
|
||||
processor: undefined | ProcessorWrapper<BackgroundOptions>;
|
||||
processor: undefined | VideoProcessor;
|
||||
};
|
||||
|
||||
/**
|
||||
* This allows to use a LiveKit processor for the matrixRTCSDK (VideoProcessor).
|
||||
* They have slightly different apis so we need to convert them with this
|
||||
* function before we can use them in the matrixRTCSDK.
|
||||
*/
|
||||
export function fromLivekitProcessor(
|
||||
wrapper: ProcessorWrapper<BackgroundOptions>,
|
||||
): VideoProcessor {
|
||||
const processedTrack = (): MediaStreamTrack => {
|
||||
if (wrapper.processedTrack === undefined)
|
||||
throw new Error(`Processor ${wrapper.name} produced no track`);
|
||||
return wrapper.processedTrack;
|
||||
};
|
||||
return {
|
||||
name: wrapper.name,
|
||||
init: async (track: MediaStreamTrack, element?: HTMLMediaElement) => {
|
||||
await wrapper.init({ kind: Track.Kind.Video, track, element });
|
||||
return processedTrack();
|
||||
},
|
||||
restart: async (track: MediaStreamTrack, element?: HTMLMediaElement) => {
|
||||
await wrapper.restart({ kind: Track.Kind.Video, track, element });
|
||||
return processedTrack();
|
||||
},
|
||||
destroy: async () => wrapper.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
|
||||
|
||||
export function useTrackProcessor(): ProcessorState {
|
||||
@@ -62,7 +93,7 @@ export function useTrackProcessorState$(): Behavior<ProcessorState> {
|
||||
*/
|
||||
export function applyProcessor(
|
||||
videoTrack: LocalVideoTrack,
|
||||
processor: ProcessorWrapper<BackgroundOptions> | undefined,
|
||||
processor: VideoProcessor | undefined,
|
||||
): void {
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
// A MediaStreamTrackProcessor cannot be constructed on an ended track
|
||||
@@ -73,7 +104,7 @@ export function applyProcessor(
|
||||
logger.debug("Not attaching video processor to an ended track");
|
||||
return;
|
||||
}
|
||||
videoTrack.setProcessor(processor).catch((e) => {
|
||||
videoTrack.setProcessor(convertToLivekitProcessor(processor)).catch((e) => {
|
||||
logger.warn("Failed to attach video processor", e);
|
||||
});
|
||||
}
|
||||
@@ -125,9 +156,11 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
|
||||
const supported = useMemo(() => supportsBackgroundProcessors(), []);
|
||||
const blur = useMemo(
|
||||
() =>
|
||||
new ProcessorWrapper(
|
||||
new BlurBackgroundTransformer({ blurRadius: 15 }),
|
||||
"background-blur",
|
||||
fromLivekitProcessor(
|
||||
new ProcessorWrapper(
|
||||
new BlurBackgroundTransformer({ blurRadius: 15 }),
|
||||
"background-blur",
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
|
||||
import { shouldDisambiguate } from "./displayname";
|
||||
import { alice } from "./test-fixtures";
|
||||
|
||||
// Ideally these tests would be in ./displayname.test.ts but I can't figure out how to
|
||||
// just spy on the removeHiddenChars() function without impacting the other tests.
|
||||
// So, these tests are in this separate test file.
|
||||
vi.mock("matrix-js-sdk/lib/utils");
|
||||
|
||||
describe("shouldDisambiguate", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
let jsUtils: typeof import("matrix-js-sdk/lib/utils");
|
||||
|
||||
beforeAll(async () => {
|
||||
jsUtils = await import("matrix-js-sdk/lib/utils");
|
||||
vi.spyOn(jsUtils, "removeHiddenChars").mockImplementation((str) => str);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should only call removeHiddenChars once for a single displayname", () => {
|
||||
const room: Map<string, Pick<RoomMember, "userId">> = new Map([]);
|
||||
shouldDisambiguate(alice, [], room);
|
||||
expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
shouldDisambiguate(alice, [], room);
|
||||
}
|
||||
expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { calculateDisplayName, shouldDisambiguate } from "./displayname";
|
||||
import {
|
||||
alice,
|
||||
aliceDoppelganger,
|
||||
aliceDoppelgangerRtcMember,
|
||||
aliceRtcMember,
|
||||
bob,
|
||||
bobRtcMember,
|
||||
bobZeroWidthSpace,
|
||||
bobZeroWidthSpaceRtcMember,
|
||||
daveRTL,
|
||||
} from "./test-fixtures";
|
||||
import { mockMatrixRoom } from "./test";
|
||||
import { roomToMembersMap } from "../state/CallViewModel/remoteMembers/MatrixMemberMetadata";
|
||||
|
||||
describe("shouldDisambiguate", () => {
|
||||
test("should not disambiguate a solo member", () => {
|
||||
const room = mockMatrixRoom({
|
||||
getMembersWithMembership: () => [],
|
||||
});
|
||||
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("should not disambiguate a member with an empty displayname", () => {
|
||||
const room = mockMatrixRoom({
|
||||
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||
});
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
{ rawDisplayName: "", userId: alice.userId },
|
||||
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(false);
|
||||
});
|
||||
test("should disambiguate a member with RTL characters", () => {
|
||||
const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
|
||||
expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("should disambiguate a member with a matching displayname", () => {
|
||||
const room = mockMatrixRoom({
|
||||
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||
});
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
alice,
|
||||
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
aliceDoppelganger,
|
||||
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(true);
|
||||
});
|
||||
test("should disambiguate a member with a matching displayname with hidden spaces", () => {
|
||||
const room = mockMatrixRoom({
|
||||
getMembersWithMembership: () => [bob, bobZeroWidthSpace],
|
||||
});
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
bob,
|
||||
[bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
bobZeroWidthSpace,
|
||||
[bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(true);
|
||||
});
|
||||
test.for(["Alice @foo:bar", "@foo:b", "A@foo:lice", "A @f oo: ba r"])(
|
||||
"should disambiguate a member with a displayname containing a mxid-like string '%s'",
|
||||
(rawDisplayName) => {
|
||||
const room = mockMatrixRoom({
|
||||
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||
});
|
||||
expect(
|
||||
shouldDisambiguate(
|
||||
{ rawDisplayName, userId: alice.userId },
|
||||
[],
|
||||
roomToMembersMap(room),
|
||||
),
|
||||
).toEqual(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("calculateDisplayName", () => {
|
||||
test.for<[{ rawDisplayName?: string; userId: string }, boolean, string]>([
|
||||
[alice, false, alice.rawDisplayName],
|
||||
[alice, true, `${alice.rawDisplayName} (${alice.userId})`],
|
||||
// Empty strings and zero width strings that are effectively empty are resolved as userIds
|
||||
[{ rawDisplayName: "", userId: alice.userId }, false, alice.userId],
|
||||
[
|
||||
{ rawDisplayName: "\u200b\u200b\u200b", userId: alice.userId },
|
||||
false,
|
||||
alice.userId,
|
||||
],
|
||||
[
|
||||
{ rawDisplayName: alice.userId, userId: alice.userId },
|
||||
false,
|
||||
alice.userId,
|
||||
],
|
||||
// Zero width strings are kept intact
|
||||
[bobZeroWidthSpace, false, bobZeroWidthSpace.rawDisplayName],
|
||||
// Directional characters are stripped.
|
||||
[daveRTL, false, daveRTL.rawDisplayName.slice(1)],
|
||||
[daveRTL, true, `${daveRTL.rawDisplayName.slice(1)} (${daveRTL.userId})`],
|
||||
// Ensure we do NOT unhomoglyth
|
||||
[{ ...alice, rawDisplayName: "alice m" }, false, "alice m"],
|
||||
])("correctly calculates displayname", ([member, disambiguate, result]) =>
|
||||
expect(calculateDisplayName(member, disambiguate)).toEqual(result),
|
||||
);
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
removeDirectionOverrideChars,
|
||||
removeHiddenChars as removeHiddenCharsUncached,
|
||||
} from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import type { RoomMember } from "matrix-js-sdk";
|
||||
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
|
||||
// To illustrate a simple benchmark:
|
||||
// Chrome: 10,000 calls took 2.599ms
|
||||
// Safari: 10,000 calls took 242ms
|
||||
// See: https://github.com/element-hq/element-call/issues/3065
|
||||
|
||||
const removeHiddenCharsCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Calls removeHiddenCharsUncached and caches the result
|
||||
*/
|
||||
function removeHiddenChars(str: string): string {
|
||||
if (removeHiddenCharsCache.has(str)) {
|
||||
return removeHiddenCharsCache.get(str)!;
|
||||
}
|
||||
const result = removeHiddenCharsUncached(str);
|
||||
// this is naive but should be good enough for our purposes
|
||||
if (removeHiddenCharsCache.size > 500) {
|
||||
removeHiddenCharsCache.clear();
|
||||
}
|
||||
removeHiddenCharsCache.set(str, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
|
||||
export function shouldDisambiguate(
|
||||
member: { rawDisplayName?: string; userId: string },
|
||||
memberships: Pick<CallMembership, "userId">[],
|
||||
roomMembers: Map<string, Pick<RoomMember, "userId">>,
|
||||
): boolean {
|
||||
const { rawDisplayName: displayName, userId } = member;
|
||||
if (!displayName || displayName === userId) return false;
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
const strippedDisplayName = removeHiddenChars(displayName);
|
||||
if (!strippedDisplayName) return false;
|
||||
|
||||
// Next check if the name contains something that look like a mxid
|
||||
// If it does, it may be someone trying to impersonate someone else
|
||||
// Show full mxid in this case
|
||||
if (/@.+:.+/.test(displayName)) return true;
|
||||
|
||||
// Also show mxid if the display name contains any LTR/RTL characters as these
|
||||
// make it very difficult for us to find similar *looking* display names
|
||||
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
|
||||
if (/[\u200E\u200F\u202A-\u202F]/.test(displayName)) return true;
|
||||
|
||||
// Also show mxid if there are other people with the same or similar
|
||||
// displayname, after hidden character removal.
|
||||
return (
|
||||
memberships
|
||||
.map((m) => m.userId && roomMembers.get(m.userId))
|
||||
// NOTE: We *should* have a room member for everyone.
|
||||
.filter((m) => !!m)
|
||||
.filter((m) => m.userId !== userId)
|
||||
.some(
|
||||
(m) =>
|
||||
removeHiddenChars(calculateDisplayName(m, false)) ===
|
||||
strippedDisplayName,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a display name for a member, optionally disambiguating it.
|
||||
* @param member - The member to calculate the display name for.
|
||||
* @param member.rawDisplayName - The raw display name of the member
|
||||
* @param member.userId - The user ID of the member
|
||||
* @param disambiguate - Whether to disambiguate the display name.
|
||||
*/
|
||||
export function calculateDisplayName(
|
||||
member: { rawDisplayName?: string; userId: string },
|
||||
disambiguate: boolean,
|
||||
): string {
|
||||
const { rawDisplayName: displayName, userId } = member;
|
||||
if (!displayName || displayName === userId) return userId;
|
||||
|
||||
const resultDisplayname = removeDirectionOverrideChars(displayName);
|
||||
|
||||
if (disambiguate) return resultDisplayname + " (" + userId + ")";
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!removeHiddenChars(displayName)) return userId;
|
||||
|
||||
// We always strip the direction override characters (LRO and RLO).
|
||||
// These override the text direction for all subsequent characters
|
||||
// in the paragraph so if display names contained these, they'd
|
||||
// need to be wrapped in something to prevent this from leaking out
|
||||
// (which we can do in HTML but not text) or we'd need to add
|
||||
// control characters to the string to reset any overrides (eg.
|
||||
// adding PDF characters at the end). As far as we can see,
|
||||
// there should be no reason these would be necessary - rtl display
|
||||
// names should flip into the correct direction automatically based on
|
||||
// the characters, and you can still embed rtl in ltr or vice versa
|
||||
// with the embed chars or marker chars.
|
||||
return resultDisplayname;
|
||||
}
|
||||
@@ -6,14 +6,37 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ConnectionError } from "livekit-client";
|
||||
import {
|
||||
ErrorCode as RTCErrorCode,
|
||||
FailToGetOpenIdToken as RTCFailToGetOpenIdToken,
|
||||
FailToStartLivekitConnection as RTCFailToStartLivekitConnection,
|
||||
InsufficientCapacityError as RTCInsufficientCapacityError,
|
||||
LivekitConnectionError as RTCLivekitConnectionError,
|
||||
MatrixRTCError,
|
||||
MatrixRTCTransportMissingError as RTCTransportMissingError,
|
||||
MembershipManagerError as RTCMembershipManagerError,
|
||||
NoMatrix2AuthorizationService as RTCNoMatrix2AuthorizationService,
|
||||
PeerConnectionTimeoutError as RTCPeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError as RTCSFURoomCreationRestrictedError,
|
||||
UnknownRTCError,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
FailToGetOpenIdToken,
|
||||
FailToStartLivekitConnection,
|
||||
fromMatrixRTCError,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
MatrixRTCTransportMissingError,
|
||||
MembershipManagerError,
|
||||
NoMatrix2AuthorizationService,
|
||||
PeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "./errors";
|
||||
|
||||
// These errors take their wording from Element Call's own i18next instance
|
||||
@@ -60,3 +83,113 @@ describe("localised errors", () => {
|
||||
expect(error.localisedMessage).not.toContain("error.");
|
||||
});
|
||||
});
|
||||
|
||||
// The SDK reports failures as its own error classes; the app turns each into
|
||||
// the translated error it shows, so every class the SDK can throw needs a
|
||||
// counterpart with a title and no raw i18n key leaking through.
|
||||
describe("fromMatrixRTCError", () => {
|
||||
const cause = new Error("the underlying problem");
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "missing transport",
|
||||
input: new RTCTransportMissingError("example.org"),
|
||||
expected: MatrixRTCTransportMissingError,
|
||||
code: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||
},
|
||||
{
|
||||
name: "membership manager failure",
|
||||
input: new RTCMembershipManagerError(cause),
|
||||
expected: MembershipManagerError,
|
||||
code: ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||
},
|
||||
{
|
||||
name: "OpenID token failure",
|
||||
input: new RTCFailToGetOpenIdToken(cause),
|
||||
expected: FailToGetOpenIdToken,
|
||||
code: ErrorCode.OPEN_ID_ERROR,
|
||||
},
|
||||
{
|
||||
name: "missing authorization service",
|
||||
input: new RTCNoMatrix2AuthorizationService(cause),
|
||||
expected: NoMatrix2AuthorizationService,
|
||||
code: ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
||||
},
|
||||
{
|
||||
name: "LiveKit start failure",
|
||||
input: new RTCFailToStartLivekitConnection("could not publish"),
|
||||
expected: FailToStartLivekitConnection,
|
||||
code: ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||
},
|
||||
{
|
||||
name: "insufficient capacity",
|
||||
input: new RTCInsufficientCapacityError(),
|
||||
expected: InsufficientCapacityError,
|
||||
code: ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||
},
|
||||
{
|
||||
name: "restricted room creation",
|
||||
input: new RTCSFURoomCreationRestrictedError(),
|
||||
expected: SFURoomCreationRestrictedError,
|
||||
code: ErrorCode.SFU_ERROR,
|
||||
},
|
||||
{
|
||||
name: "peer connection timeout",
|
||||
input: new RTCPeerConnectionTimeoutError(),
|
||||
expected: PeerConnectionTimeoutError,
|
||||
code: ErrorCode.SFU_ERROR,
|
||||
},
|
||||
{
|
||||
name: "LiveKit connection failure",
|
||||
input: new RTCLivekitConnectionError(
|
||||
ConnectionError.timeout("signalling timed out"),
|
||||
),
|
||||
expected: LivekitConnectionError,
|
||||
code: ErrorCode.SFU_ERROR,
|
||||
},
|
||||
{
|
||||
name: "lost connection",
|
||||
input: new MatrixRTCError("gone", RTCErrorCode.CONNECTION_LOST_ERROR),
|
||||
expected: ConnectionLostError,
|
||||
code: ErrorCode.CONNECTION_LOST_ERROR,
|
||||
},
|
||||
{
|
||||
name: "anything else",
|
||||
input: new UnknownRTCError(cause),
|
||||
expected: UnknownCallError,
|
||||
code: ErrorCode.UNKNOWN_ERROR,
|
||||
},
|
||||
])("maps a $name to the app's error", ({ input, expected, code }) => {
|
||||
const error = fromMatrixRTCError(input);
|
||||
|
||||
expect(error).toBeInstanceOf(expected);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.localisedTitle).not.toContain("error.");
|
||||
expect(error.localisedMessage ?? "").not.toContain("error.");
|
||||
});
|
||||
|
||||
test("keeps the SDK error's cause for reporting", () => {
|
||||
const error = fromMatrixRTCError(new RTCMembershipManagerError(cause));
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test("keeps the SDK error itself as the cause when it has none", () => {
|
||||
const input = new MatrixRTCError("no cause attached");
|
||||
const error = fromMatrixRTCError(input);
|
||||
|
||||
expect(error).toBeInstanceOf(UnknownCallError);
|
||||
expect(error.cause).toBe(input);
|
||||
});
|
||||
|
||||
test("keeps the LiveKit reason in the connection error's description", () => {
|
||||
const error = fromMatrixRTCError(
|
||||
new RTCLivekitConnectionError(ConnectionError.timeout("timed out")),
|
||||
);
|
||||
|
||||
expect(error.localisedMessageKey).toBe(
|
||||
"error.livekit_connection_error_description",
|
||||
);
|
||||
expect(error.localisedMessageValues?.reason).toBe("Timeout");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,19 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ConnectionError } from "livekit-client";
|
||||
import {
|
||||
ErrorCode as RTCErrorCode,
|
||||
FailToGetOpenIdToken as RTCFailToGetOpenIdToken,
|
||||
FailToStartLivekitConnection as RTCFailToStartLivekitConnection,
|
||||
InsufficientCapacityError as RTCInsufficientCapacityError,
|
||||
LivekitConnectionError as RTCLivekitConnectionError,
|
||||
type MatrixRTCError,
|
||||
MatrixRTCTransportMissingError as RTCTransportMissingError,
|
||||
MembershipManagerError as RTCMembershipManagerError,
|
||||
NoMatrix2AuthorizationService as RTCNoMatrix2AuthorizationService,
|
||||
PeerConnectionTimeoutError as RTCPeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError as RTCSFURoomCreationRestrictedError,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { i18n, i18nKey } from "./i18n";
|
||||
|
||||
@@ -292,3 +305,35 @@ export class LivekitConnectionError extends ElementCallError {
|
||||
this.localisedMessageValues = { reason: cause.reasonName };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's error as the one Element Call shows, with its translation. The
|
||||
* SDK speaks in codes and classes; the title and description come from here.
|
||||
*/
|
||||
export function fromMatrixRTCError(error: MatrixRTCError): ElementCallError {
|
||||
if (error instanceof RTCTransportMissingError)
|
||||
return new MatrixRTCTransportMissingError(error.domain);
|
||||
if (error instanceof RTCMembershipManagerError)
|
||||
return new MembershipManagerError(causeOf(error));
|
||||
if (error instanceof RTCFailToGetOpenIdToken)
|
||||
return new FailToGetOpenIdToken(causeOf(error));
|
||||
if (error instanceof RTCNoMatrix2AuthorizationService)
|
||||
return new NoMatrix2AuthorizationService(causeOf(error));
|
||||
if (error instanceof RTCFailToStartLivekitConnection)
|
||||
return new FailToStartLivekitConnection(error.message);
|
||||
if (error instanceof RTCInsufficientCapacityError)
|
||||
return new InsufficientCapacityError();
|
||||
if (error instanceof RTCSFURoomCreationRestrictedError)
|
||||
return new SFURoomCreationRestrictedError();
|
||||
if (error instanceof RTCPeerConnectionTimeoutError)
|
||||
return new PeerConnectionTimeoutError();
|
||||
if (error instanceof RTCLivekitConnectionError)
|
||||
return new LivekitConnectionError(error.cause as ConnectionError);
|
||||
if (error.code === RTCErrorCode.CONNECTION_LOST_ERROR)
|
||||
return new ConnectionLostError();
|
||||
return new UnknownCallError(causeOf(error));
|
||||
}
|
||||
|
||||
function causeOf(error: MatrixRTCError): Error {
|
||||
return error.cause instanceof Error ? error.cause : error;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
calculateRetryBackoff,
|
||||
createClient,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
@@ -333,36 +331,3 @@ export function getRelativeRoomUrl(
|
||||
: "";
|
||||
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a network operation with retries on ConnectionError.
|
||||
* If the error is not retryable, or the max number of retries is reached, the error is rethrown.
|
||||
* Supports handling of matrix quotas.
|
||||
* @param operation The operation to perform.
|
||||
* @param shouldRetry Rules out errors that retrying cannot fix, on top of the
|
||||
* standard backoff rule. Retries everything by default.
|
||||
*/
|
||||
export async function doNetworkOperationWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
shouldRetry: (e: unknown) => boolean = () => true,
|
||||
): Promise<T> {
|
||||
let currentRetryCount = 0;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (e) {
|
||||
currentRetryCount++;
|
||||
const backoff = shouldRetry(e)
|
||||
? calculateRetryBackoff(e, currentRetryCount, true)
|
||||
: -1;
|
||||
if (backoff < 0) {
|
||||
// Max number of retries reached, or error is not retryable. rethrow the error
|
||||
throw e;
|
||||
}
|
||||
// wait for the specified time and then retry the request
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user