callParticipation -> rtcParticipationManager
driverCapabilites -> MatrixClientFeatures
This commit is contained in:
Timo K.
2026-09-17 21:02:52 +02:00
parent 7132e6e6ec
commit 14b595345f
38 changed files with 813 additions and 781 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ export { supportedLanguages } from "./localization";
export { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
export {
type ElementCallMatrixClientDriver,
type DriverCapabilities,
type MatrixClientFeatures,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
+33 -33
View File
@@ -157,13 +157,13 @@ membership, roster, encryption, impairments} | Leaving`.
| Own profile read/write | not RTC | `ProfileDriver` |
| `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` |
| Homeserver sync connectivity | needed by the crate too | `RtcMatrixDriver` (`isHomeserverConnected`, `subscribeConnectivity`, C12); reaches Element Call as `HomeserverUnreachable` in the status |
| Sticky-events support probe | capability probe | `MatrixDriver.getCapabilities()` |
| Sticky-events support probe | capability probe | `MatrixDriver.getMatrixClientFeatures()` |
**Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`CallParticipation.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`RtcParticipationManager.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
@@ -175,7 +175,7 @@ membership, roster, encryption, impairments} | Leaving`.
| C10 | `MediaKeyState` has `holds_our_key`, `have_their_key` and `rejection`, so a tile learns about an unsigned sender only when `require_cross_signed_sender` is on (the key is discarded, `rejection: NotCrossSigned`). With the check off the verdict is dropped on accept and the tile cannot show an unverified sender. | **Done.** The inbound key store keeps the MSC4153 verdict of the accepted key per member and exposes it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `CallParticipation.session$` is fed from it. |
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `RtcParticipationManager.session$` is fed from it. |
| C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). |
No crate work is deferred: `update_application` is C11.
@@ -193,8 +193,8 @@ host (SPA · widget · sdk · a third-party page)
│
▼ component/index.tsx <ElementCall rtcDriver clientDriver …/>
┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐
│ CallView owns one CallParticipation for lobby → call → ended │
│ CallParticipation (src/state/rtc/CallParticipation.ts) │
│ CallView owns one RtcParticipationManager for lobby → call → ended │
│ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
@@ -231,11 +231,11 @@ export interface ElementCallMatrixClientDriver
readonly deviceId: string;
/** The room this driver is bound to (one driver per room, as in the crate). */
readonly roomId: string;
getCapabilities(): Promise<DriverCapabilities>;
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>;
}
export interface DriverCapabilities {
export interface MatrixClientFeatures {
stickyEvents: boolean;
/** The host's events carry decryption metadata (false on a widget client). */
verifiedEventOrigins: boolean;
@@ -320,16 +320,16 @@ its own driver; the RTC driver is the crate's contract untouched, so a host
with a crate-side adapter (matrix-rust-sdk) implements nothing extra for
MatrixRTC.
### 4.2 `CallParticipation` (Element Call's RxJS view of the manager)
### 4.2 `RtcParticipationManager` (Element Call's RxJS view of the manager)
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
`FfiParticipationConfig`); `CallParticipation` is Element Call's RxJS wrapper
`FfiParticipationConfig`); `RtcParticipationManager` is Element Call's RxJS wrapper
over it.
`src/state/rtc/CallParticipation.ts`, a class taking the scope in its constructor:
`src/state/rtc/RtcParticipationManager.ts`, a class taking the scope in its constructor:
```ts
new CallParticipation(scope, driver, {
new RtcParticipationManager(scope, driver, {
slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender,
useKeyDelayMs, transportFallbackUrl?, logger })
memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1)
@@ -384,7 +384,7 @@ createCallViewModel$(
| `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` |
| `MatrixKeyProvider.setRTCSession` | `MatrixKeyProvider.attach(participation)`: `keyChanges$` × `memberships$` × `ownTransportIdentity$` → `onSetEncryptionKey(material, identity, index)`; keys whose member has no identity yet are held per member id and replayed |
| `enterRTCSession` (`joinRTCSession(...)`) | `callParticipation.join(intent, joinParamsFromConfig(...), { encrypted: roomInfo.encrypted, canOpen: roomInfo.canOpenSlot })` in the same `scope.reconcile`: opens the room's slot first when none is open (power level permitting, otherwise `NoOpenSlotError`), then joins; cleanup calls `callParticipation.leave()` |
| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `CallParticipation.homeserverConnected$` from the manager's getter |
| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `RtcParticipationManager.homeserverConnected$` from the manager's getter |
| `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) |
| `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold |
| `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` |
@@ -416,7 +416,7 @@ MSC4143: sticky member events, slots, the spec key message), in
drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every
`useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell.
- `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`.
It creates the `CallParticipation` (scope tied to its mount) and hands it to
It creates the `RtcParticipationManager` (scope tied to its mount) and hands it to
`LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`.
@@ -431,7 +431,7 @@ MSC4143: sticky member events, slots, the spec key message), in
- `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`.
- `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`;
rageshake requests via `subscribeTimeline`.
- `DeveloperSettingsTab`: sticky probe → `getCapabilities()`; custom LiveKit
- `DeveloperSettingsTab`: sticky probe → `getMatrixClientFeatures()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`.
- `window.rtcSession` debug handle → `window.matrixRtc = { participation }`.
@@ -454,7 +454,7 @@ MSC4143: sticky member events, slots, the spec key message), in
`org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key`
(alongside `io.element.call.encryption_keys`); events `m.rtc.decline`.
- **`sdk/main.ts`**: builds the driver from the widget client, a
`CallParticipation`, and waits on `status$` instead of `JoinStateChanged`.
`RtcParticipationManager`, and waits on `status$` instead of `JoinStateChanged`.
- **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each
@@ -480,7 +480,7 @@ Differences from the draft, all required by the widget client:
reports `Encrypted{ senderDeviceId: content.member.device_id }` for member
events and `Encrypted{ senderDeviceId: content.device_id }` for key
events, i.e. the _claimed_ trust level js-sdk applies today
(`ToDeviceKeyTransport.ts:133-140`). `getCapabilities().verifiedEventOrigins`
(`ToDeviceKeyTransport.ts:133-140`). `getMatrixClientFeatures().verifiedEventOrigins`
says which.
- cross-signing verdict: `undefined` on a widget client
(`crossSigningVerdicts: false`); Element Call then forces
@@ -525,7 +525,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app
`useCallParticipation` awaits `initMatrixRtcSdk()` before constructing
`useRtcParticipationManager` awaits `initMatrixRtcSdk()` before constructing
the participation (so the js-sdk path never fetches it, §5.15), not the
`Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in
@@ -561,7 +561,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
the session from the MSC3401 state events alone and requires the legacy
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`,
`slotIdForCompat`); `CallParticipation` picks it from the config and skips
`slotIdForCompat`); `RtcParticipationManager` picks it from the config and skips
the slot check and the slot open. Found by the real-backend check: with
`m.call#ROOM` the crate saw its own legacy membership as a candidate but
never projected it. Element Call's own rooms already let every member send
@@ -587,7 +587,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
against the old path in the same session, and that Playwright can run
both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore
stays reachable from `CallView` until S6, and the `CallParticipation` is
stays reachable from `CallView` until S6, and the `RtcParticipationManager` is
created only when the crate path is selected, so neither path pays for
the other.
@@ -642,11 +642,11 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
`encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients,
sink emission and origin synthesis, room-info/member updates.
### S2 — `CallParticipation` ☑
### S2 — `RtcParticipationManager` ☑
- `src/state/rtc/CallParticipation.ts`, `joinParams.ts`, `transportIntent.ts`,
- `src/state/rtc/RtcParticipationManager.ts`, `joinParams.ts`, `transportIntent.ts`,
`errors.ts` (cause → `ElementCallError`).
- `CallParticipation.test.ts` through the real wasm + `MockMatrixDriver`:
- `RtcParticipationManager.test.ts` through the real wasm + `MockMatrixDriver`:
memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave →
@@ -713,7 +713,7 @@ participation, clientDriver, …)` sits next to it; both build a
- **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`;
`useCallParticipation(drivers, config)` owned by `CallView`, created only
`useRtcParticipationManager(drivers, config)` owned by `CallView`, created only
when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute
and the error boundary's "were we joined" read from whichever side carries
@@ -723,7 +723,7 @@ participation, clientDriver, …)` sits next to it; both build a
`matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the
`MatrixDriverProvider` and the `CallParticipation` from `CallView`, for
`MatrixDriverProvider` and the `RtcParticipationManager` from `CallView`, for
`"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a
report says which path it came from. Playwright gets a helper that sets
@@ -732,7 +732,7 @@ participation, clientDriver, …)` sits next to it; both build a
Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`CallParticipation` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`RtcParticipationManager` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
@@ -747,7 +747,7 @@ participation, clientDriver, …)` sits next to it; both build a
(`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`,
`useCallParticipation.test.tsx`, three `CallView.test.tsx` cases for the
`useRtcParticipationManager.test.tsx`, three `CallView.test.tsx` cases for the
switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads the
@@ -771,7 +771,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
`client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the
sticky probe from `getCapabilities()`, the crypto version from
sticky probe from `getMatrixClientFeatures()`, the crypto version from
`getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported
@@ -800,7 +800,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it
has passed once). `CallParticipation.mediaKeyStatistics()` counts sent
has passed once). `RtcParticipationManager.mediaKeyStatistics()` counts sent
and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits;
@@ -877,7 +877,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
| four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
| Real backend (`CallParticipation.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
| Real backend (`RtcParticipationManager.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
---
@@ -908,7 +908,7 @@ Independent review findings incorporated in this revision: slot enforcement
blocker (C1); delegation protocol and 1 h arm (C5, §5.9; later redefined as crate-only with arm-after-confirm); `StickyEvents` key
type interop (C3, then made moot by removing the mode, C9); MSC4153 default (§5.8); widget-client differences for the
js-sdk drivers (§4.6) and missing widget capabilities (§4.5); own identity
export (C6); `CallParticipation` lifetime at `CallView` (§4.2); notification
export (C6); `RtcParticipationManager` lifetime at `CallView` (§4.2); notification
timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
@@ -916,8 +916,8 @@ the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper.
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/CallParticipation.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/CallParticipation.backend.test.ts`;
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/RtcParticipationManager.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts`;
two registered users with rust crypto in an encrypted room, `matrix_2_0` and
`compatibility`): passes end to end — transport discovery from
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`,
@@ -946,7 +946,7 @@ the S6 deletions.
**S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a
`CallParticipation` when the crate path is selected and `ActiveCall` builds
`RtcParticipationManager` when the crate path is selected and `ActiveCall` builds
the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
+5 -1
View File
@@ -37,7 +37,11 @@ export const Header: FC<HeaderProps> = ({
...rest
}) => {
return (
<header ref={ref} className={classNames(styles.header, className)} {...rest}>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
+9 -3
View File
@@ -24,7 +24,12 @@ Please see LICENSE in the repository root for full details.
/** Removes the listener it was returned for. */
export type Unsubscribe = () => void;
export interface DriverCapabilities {
/**
* What this client and its homeserver can do, as far as Element Call cares —
* not to be confused with widget capabilities, which are what a host *permits*
* rather than what the stack supports.
*/
export interface MatrixClientFeatures {
/** The homeserver accepts sticky events (MSC4354). */
stickyEvents: boolean;
/**
@@ -145,7 +150,7 @@ export interface MediaDriver {
/**
* Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to
* one room. One object, sliced into the capabilities above the way the crate
* one room. One object, sliced into the driver interfaces above the way the crate
* slices its own driver, so a piece of Element Call can ask for no more than
* it needs.
*/
@@ -156,7 +161,8 @@ export interface ElementCallMatrixClientDriver
readonly deviceId: string;
/** The room this driver is bound to. */
readonly roomId: string;
getCapabilities(): Promise<DriverCapabilities>;
/** What this client and homeserver support; see {@link MatrixClientFeatures}. */
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form facts for a rageshake: crypto version, sync state, and so on. */
getDiagnostics?(): Promise<Record<string, string>>;
}
@@ -82,7 +82,7 @@ describe("MockElementCallMatrixClientDriver", () => {
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
).resolves.toBeNull();
expect(driver.roomId).toBe(MOCK_ROOM_ID);
await expect(driver.getCapabilities()).resolves.toMatchObject({
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
stickyEvents: true,
});
});
+12 -10
View File
@@ -12,7 +12,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
type DriverCapabilities,
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
@@ -42,7 +42,7 @@ export interface MockElementCallMatrixClientDriverOptions {
roomInfo?: Partial<RoomInfo>;
members?: RoomMemberProfile[];
ownProfile?: Partial<OwnProfile>;
capabilities?: Partial<DriverCapabilities>;
features?: Partial<MatrixClientFeatures>;
}
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
@@ -51,7 +51,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
public readonly roomId: string;
public readonly outbound: ClientCall[] = [];
private capabilities: DriverCapabilities;
private features: MatrixClientFeatures;
private roomInfo: RoomInfo;
private members: RoomMemberProfile[];
private ownProfile: OwnProfile;
@@ -71,11 +71,11 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.capabilities = {
this.features = {
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
...options.capabilities,
...options.features,
};
this.roomInfo = {
name: "Test room",
@@ -194,7 +194,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
});
}
// --- profile, media, capabilities --------------------------------------------
// --- profile, media, features --------------------------------------------
public getOwnProfile(): OwnProfile {
return this.ownProfile;
@@ -229,12 +229,14 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
);
}
public async getCapabilities(): Promise<DriverCapabilities> {
return Promise.resolve(this.capabilities);
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
return Promise.resolve(this.features);
}
public setCapabilities(capabilities: Partial<DriverCapabilities>): void {
this.capabilities = { ...this.capabilities, ...capabilities };
public setMatrixClientFeatures(
features: Partial<MatrixClientFeatures>,
): void {
this.features = { ...this.features, ...features };
}
}
@@ -95,7 +95,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => {
expect.objectContaining({ displayName: "Moi" }),
);
await expect(driver.getCapabilities()).resolves.toEqual({
await expect(driver.getMatrixClientFeatures()).resolves.toEqual({
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
@@ -111,7 +111,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => {
asClient(fakeClient(true)),
asRoom(fakeRoom()),
);
await expect(driver.getCapabilities()).resolves.toMatchObject({
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
verifiedEventOrigins: false,
crossSigningVerdicts: false,
});
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
/**
* An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
* metadata and members, the room's timeline for reactions and notifications,
* the user's own profile, authenticated thumbnails and capability probes.
* the user's own profile, authenticated thumbnails and feature probes.
* Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
* they differ are marked "widget".
*/
@@ -30,7 +30,7 @@ import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
import {
type DriverCapabilities,
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
@@ -61,7 +61,7 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
private readonly logger: Logger;
/** Widget: no crypto backend, no access token, events without metadata. */
private readonly widget: boolean;
private capabilities: Promise<DriverCapabilities> | null = null;
private features: Promise<MatrixClientFeatures> | null = null;
public constructor(
private readonly client: MatrixClient,
@@ -302,14 +302,14 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
return URL.createObjectURL(await response.blob());
}
// --- capabilities and diagnostics ------------------------------------------------
// --- features and diagnostics ------------------------------------------------
public async getCapabilities(): Promise<DriverCapabilities> {
this.capabilities ??= this.probeCapabilities();
return this.capabilities;
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
this.features ??= this.probeMatrixClientFeatures();
return this.features;
}
private async probeCapabilities(): Promise<DriverCapabilities> {
private async probeMatrixClientFeatures(): Promise<MatrixClientFeatures> {
const stickyEvents = await this.client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
.catch((e: unknown) => {
+14 -12
View File
@@ -23,39 +23,41 @@ interface SetKey {
}
function attached(): {
participation: FakeParticipation;
rtcParticipationManager: FakeParticipation;
setKeys: SetKey[];
} {
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const provider = new ParticipationKeyProvider();
const setKeys: SetKey[] = [];
provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) =>
setKeys.push({ participantIdentity, keyIndex }),
);
provider.attach(testScope(), participation);
return { participation, setKeys };
provider.attach(testScope(), rtcParticipationManager);
return { rtcParticipationManager, setKeys };
}
describe("ParticipationKeyProvider", () => {
it("hands our own key to LiveKit under our transport identity", async () => {
const { participation, setKeys } = attached();
participation.ownMemberId$.next("m-me");
participation.ownTransportIdentity$.next("lk-me");
participation.keyMap$.next([fakeMediaKey({ memberId: "m-me", index: 0 })]);
const { rtcParticipationManager, setKeys } = attached();
rtcParticipationManager.ownMemberId$.next("m-me");
rtcParticipationManager.ownTransportIdentity$.next("lk-me");
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-me", index: 0 }),
]);
await waitFor("own key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]);
});
it("waits for a peer's transport identity and sets each key once", async () => {
const { participation, setKeys } = attached();
const { rtcParticipationManager, setKeys } = attached();
// The key arrives before the roster knows the member's identity.
participation.keyMap$.next([
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
]);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(setKeys).toEqual([]);
participation.setMemberships([
rtcParticipationManager.setMemberships([
fakeMembership({
member: { memberId: "m-peer" },
transportIdentity: "lk-peer",
@@ -65,7 +67,7 @@ describe("ParticipationKeyProvider", () => {
expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]);
// The map is re-emitted (a rotation elsewhere): no second delivery.
participation.keyMap$.next([
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
fakeMediaKey({ memberId: "m-peer", index: 3 }),
]);
+6 -6
View File
@@ -13,7 +13,7 @@ import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk";
/** What this provider needs from a {@link CallParticipation}. */
/** What this provider needs from a {@link RtcParticipationManager}. */
export interface ParticipationKeys {
/** Every media key in use, ours and theirs, one per (member, index). */
keyMap$: Behavior<FfiMediaKey[]>;
@@ -43,13 +43,13 @@ export class ParticipationKeyProvider extends BaseKeyProvider {
/** Follow the participation's keys for as long as `scope` lives. */
public attach(
scope: ObservableScope,
participation: ParticipationKeys,
rtcParticipationManager: ParticipationKeys,
): void {
combineLatest([
participation.keyMap$,
participation.memberships$,
participation.ownMemberId$,
participation.ownTransportIdentity$,
rtcParticipationManager.keyMap$,
rtcParticipationManager.memberships$,
rtcParticipationManager.ownMemberId$,
rtcParticipationManager.ownTransportIdentity$,
])
.pipe(scope.bind())
.subscribe(([keys, memberships, ownMemberId, ownIdentity]) => {
@@ -44,16 +44,16 @@ function raisedHand(
}
function setUp(): {
participation: FakeParticipation;
rtcParticipationManager: FakeParticipation;
timeline: MockElementCallMatrixClientDriver;
hands: () => Record<string, RaisedHandInfo>;
reactions: () => string[];
} {
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const reader = new ParticipationReactionsReader(
testScope(),
participation,
rtcParticipationManager,
timeline,
);
let hands: Record<string, RaisedHandInfo> = {};
@@ -63,7 +63,7 @@ function setUp(): {
(r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)),
);
return {
participation,
rtcParticipationManager,
timeline,
hands: () => hands,
reactions: () => reactions,
@@ -72,8 +72,8 @@ function setUp(): {
describe("ParticipationReactionsReader", () => {
it("raises and lowers a hand with the member's reaction and its redaction", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(hands()).toEqual({
[aliceId]: {
@@ -94,8 +94,8 @@ describe("ParticipationReactionsReader", () => {
});
it("ignores a reaction that does not relate to the sender's own membership", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$forged",
type: "m.reaction",
@@ -113,26 +113,26 @@ describe("ParticipationReactionsReader", () => {
});
it("picks up a hand raised before we looked, and drops it when the member leaves", () => {
const { participation, timeline, hands } = setUp();
const { rtcParticipationManager, timeline, hands } = setUp();
// The reaction is already in the room when the roster arrives.
raisedHand(timeline);
expect(hands()).toEqual({});
participation.setMemberships([alice]);
rtcParticipationManager.setMemberships([alice]);
expect(Object.keys(hands())).toEqual([aliceId]);
participation.setMemberships([]);
rtcParticipationManager.setMemberships([]);
expect(hands()).toEqual({});
});
it("re-resolves a hand when the member re-sends their membership", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(Object.keys(hands())).toEqual([aliceId]);
// A new membership event without a hand on it: the hand goes.
const resent = fakeMembership({
member: { ...alice.member, eventId: "$alice-join-2" },
});
participation.setMemberships([resent]);
rtcParticipationManager.setMemberships([resent]);
expect(hands()).toEqual({});
// Raised again on the new event: back.
raisedHand(timeline, resent, "$hand-2");
@@ -140,8 +140,8 @@ describe("ParticipationReactionsReader", () => {
});
it("shows a reaction keyed by the member's media id", () => {
const { participation, timeline, reactions } = setUp();
participation.setMemberships([alice]);
const { rtcParticipationManager, timeline, reactions } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$reaction",
type: ElementCallReactionEventType,
@@ -29,7 +29,7 @@ const RAISED_HAND_KEY = "🖐️";
const REACTION_EVENT_TYPE = "m.reaction";
const REDACTION_EVENT_TYPE = "m.room.redaction";
/** What the reader needs from a {@link CallParticipation}. */
/** What the reader needs from a {@link RtcParticipationManager}. */
export interface ParticipationReactionsSource {
memberships$: Behavior<Epoch<FfiMembership[]>>;
}
@@ -45,7 +45,7 @@ function relationOf(content: Record<string, unknown>): Relation | undefined {
}
/**
* Raised hands and reactions over a {@link CallParticipation} and the client
* Raised hands and reactions over a {@link RtcParticipationManager} and the client
* driver's timeline: the counterpart of {@link ReactionsReader}, which reads
* the same from a matrix-js-sdk session.
*
@@ -68,7 +68,7 @@ export class ParticipationReactionsReader {
public constructor(
scope: ObservableScope,
participation: ParticipationReactionsSource,
rtcParticipationManager: ParticipationReactionsSource,
private readonly timeline: Pick<
TimelineDriver,
"subscribeTimeline" | "getRelatedEvents"
@@ -88,7 +88,7 @@ export class ParticipationReactionsReader {
});
scope.onEnd(timeline.subscribeTimeline(this.handleEvent));
participation.memberships$
rtcParticipationManager.memberships$
.pipe(scope.bind())
.subscribe((memberships) => this.onMembershipsChanged(memberships.value));
}
+9 -5
View File
@@ -461,7 +461,7 @@ describe("the call implementation switch", () => {
createCallView(nullHostBridge);
await waitFor(() => expect(ActiveCall).toHaveBeenCalled());
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation,
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).toBeNull();
expect(window.matrixRtc).toBeUndefined();
});
@@ -489,12 +489,16 @@ describe("the call implementation switch", () => {
createCallView(nullHostBridge, true, { drivers });
await waitFor(() =>
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation,
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).not.toBeNull(),
);
const { participation } = vi.mocked(ActiveCall).mock.calls.at(-1)![0];
expect(window.matrixRtc?.participation).toBe(participation);
const { rtcParticipationManager } = vi
.mocked(ActiveCall)
.mock.calls.at(-1)![0];
expect(window.matrixRtc?.rtcParticipationManager).toBe(
rtcParticipationManager,
);
// The participation is bound to the drivers' room and identity.
expect(participation?.session$.value.roomId).toBe(roomId);
expect(rtcParticipationManager?.session$.value.roomId).toBe(roomId);
});
});
+20 -17
View File
@@ -74,8 +74,8 @@ import { useHostBridge } from "../HostBridge.ts";
import { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { useCallParticipation } from "../state/rtc/useCallParticipation.ts";
import { type CallParticipation } from "../state/rtc/CallParticipation.ts";
import { useRtcParticipationManager } from "../state/rtc/useRtcParticipationManager.ts";
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { participationConfig } from "../state/rtc/joinParams.ts";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts";
import {
@@ -97,8 +97,8 @@ export const MUTE_PARTICIPANT_COUNT = 8;
declare global {
interface Window {
rtcSession?: MatrixRTCSession;
/** The crate's participation, when the Rust implementation carries the call. */
matrixRtc?: { participation: CallParticipation };
/** The crate's participation manager, when the Rust implementation carries the call. */
matrixRtc?: { rtcParticipationManager: RtcParticipationManager };
}
}
@@ -202,6 +202,7 @@ const LoadedCallView: FC<LoadedProps> = ({
const [implementation] = useState(() =>
effectiveCallViewModelImplementation(),
);
// this is the rustsdkmatrix rtc. So we should call it useRustRtcSdk
const useMatrixRtc =
implementation === CallViewModelImplementation.MatrixRtc ||
rtcSession === undefined;
@@ -219,12 +220,12 @@ const LoadedCallView: FC<LoadedProps> = ({
})
: null,
);
const participation = useCallParticipation(
const rtcParticipationManager = useRtcParticipationManager(
useMatrixRtc ? drivers : null,
participationConfigValue,
);
const participationMemberships = useBehavior(
participation?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS,
rtcParticipationManager?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS,
);
// The call's members, whichever side lists them; only who they are matters here.
const memberUserIds = useMemo(
@@ -279,13 +280,15 @@ const LoadedCallView: FC<LoadedProps> = ({
}, [rootElement]);
useEffect(() => {
// Storing in the window to access it for the rageshake summary.
if (rtcSession !== undefined) window.rtcSession = rtcSession;
if (participation !== null) window.matrixRtc = { participation };
if (rtcParticipationManager !== null)
window.matrixRtc = { rtcParticipationManager };
return (): void => {
delete window.rtcSession;
delete window.matrixRtc;
};
}, [rtcSession, participation]);
}, [rtcSession, rtcParticipationManager]);
// TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
@@ -504,8 +507,8 @@ const LoadedCallView: FC<LoadedProps> = ({
roomId,
latestMemberUserIds.current.length,
sendInstantly,
participation !== null
? participation.mediaKeyStatistics()
rtcParticipationManager !== null
? rtcParticipationManager.mediaKeyStatistics()
: rtcSession === undefined
? NO_MEDIA_KEY_STATISTICS
: mediaKeyStatisticsOf(rtcSession),
@@ -556,7 +559,7 @@ const LoadedCallView: FC<LoadedProps> = ({
roomId,
latestMemberUserIds,
rtcSession,
participation,
rtcParticipationManager,
isPasswordlessUser,
confineToRoom,
returnToLobby,
@@ -604,7 +607,7 @@ const LoadedCallView: FC<LoadedProps> = ({
<>
{shareModal}
<LobbyView
client={client}
developerSettingsClient={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
@@ -625,9 +628,9 @@ const LoadedCallView: FC<LoadedProps> = ({
throw externalError;
};
body = <ErrorComponent />;
} else if (joined && useMatrixRtc && participation === null) {
} else if (joined && useMatrixRtc && rtcParticipationManager === null) {
// Joined before the crate is ready (its wasm loads on first use): the
// call appears with the participation, a render later.
// call appears with the participation manager, a render later.
body = null;
} else if (joined) {
body = (
@@ -637,7 +640,7 @@ const LoadedCallView: FC<LoadedProps> = ({
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession}
participation={participation}
rtcParticipationManager={rtcParticipationManager}
roomId={roomId}
onLeft={onLeft}
muteStates={muteStates}
@@ -694,8 +697,8 @@ const LoadedCallView: FC<LoadedProps> = ({
}}
onError={(_error) => {
const joinedViaCrate =
participation !== null &&
FfiStatus.Connected.instanceOf(participation.status$.value);
rtcParticipationManager !== null &&
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value);
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
// If there is an error we need to be dismissible again. This is done in
// `onLeft` as well; we need it here explicitly in case
+2 -2
View File
@@ -253,7 +253,7 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
participation={null}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
@@ -304,7 +304,7 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
participation={null}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
+16 -16
View File
@@ -87,7 +87,7 @@ import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type CallParticipation } from "../state/rtc/CallParticipation.ts";
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
@@ -115,11 +115,11 @@ export interface ActiveCallProps extends Omit<
> {
e2eeSystem: EncryptionSystem;
/**
* The crate's participation in the session when the Rust implementation
* carries this call (see `CallViewModelImplementation`); null when
* The crate's participation manager for the session when the Rust
* implementation carries this call (see `CallViewModelImplementation`); null when
* matrix-js-sdk's `rtcSession` does.
*/
participation: CallParticipation | null;
rtcParticipationManager: RtcParticipationManager | null;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
@@ -141,15 +141,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
// The drivers, where a host provided them; required with a participation.
// The drivers, where a host provided them; required with a participation manager.
const drivers = useOptionalMatrixDrivers();
const { participation, rtcSession, client, roomId } = props;
if (participation !== null && drivers === null)
const { rtcParticipationManager, rtcSession, client, roomId } = props;
if (rtcParticipationManager !== null && drivers === null)
throw new Error(
"A call over the matrix-rtc crate needs the Matrix drivers to be provided",
);
if (
participation === null &&
rtcParticipationManager === null &&
(rtcSession === undefined || client === undefined)
)
throw new Error(
@@ -173,18 +173,18 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
let vm: CallViewModel;
if (participation !== null && drivers !== null) {
if (rtcParticipationManager !== null && drivers !== null) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
);
const reactionsReader = new ParticipationReactionsReader(
scope,
participation,
rtcParticipationManager,
drivers.clientDriver,
);
vm = createCallViewModel$(
scope,
participation,
rtcParticipationManager,
drivers.clientDriver,
mediaDevices,
props.muteStates,
@@ -232,7 +232,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
trackProcessorState$,
rootElement,
participation,
rtcParticipationManager,
drivers,
]);
@@ -246,10 +246,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// Reactions relate to our current membership event, wherever that lives.
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
const ownParticipationMembership = useBehavior(
participation?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
);
const ownMembershipEventId =
participation !== null
rtcParticipationManager !== null
? ownParticipationMembership?.member.eventId
: jsSdkMemberships.find(
(m) =>
@@ -258,10 +258,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
)?.eventId;
const reactionsTimeline = useMemo(
() =>
participation === null && client !== undefined
rtcParticipationManager === null && client !== undefined
? jsSdkReactionsTimeline(client, roomId)
: drivers!.clientDriver,
[participation, drivers, client, roomId],
[rtcParticipationManager, drivers, client, roomId],
);
useEffect(() => {
+1 -1
View File
@@ -64,7 +64,7 @@ export const KnockLobbyView: FC<Props> = ({
return (
<LobbyView
client={client}
developerSettingsClient={client}
matrixInfo={{
userId: client.getUserId() ?? "",
displayName: profile.displayName,
+1 -1
View File
@@ -84,7 +84,7 @@ function renderLobbyView(
const hideHeader = withAppBar ? true : false;
const lobbyView = (
<LobbyView
client={mockClient}
developerSettingsClient={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
+3 -3
View File
@@ -54,7 +54,7 @@ import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
/** The matrix-js-sdk client, for what the developer settings still read from it. */
client?: MatrixClient;
developerSettingsClient?: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
@@ -67,7 +67,7 @@ interface Props {
}
export const LobbyView: FC<Props> = ({
client,
developerSettingsClient,
matrixInfo,
muteStates,
onEnter,
@@ -260,7 +260,7 @@ export const LobbyView: FC<Props> = ({
)}
</div>
<SettingsModal
client={client}
client={developerSettingsClient}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
+2 -2
View File
@@ -146,8 +146,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
)
: drivers !== null
? drivers.clientDriver
.getCapabilities()
.then((capabilities) => capabilities.stickyEvents)
.getMatrixClientFeatures()
.then((features) => features.stickyEvents)
: Promise.resolve(false);
probe
.then((result) => {
+7 -3
View File
@@ -221,9 +221,13 @@ export function useSubmitRageshake(
logger.warn("Could not collect the driver's diagnostics", e);
}
}
const participation = window.matrixRtc?.participation;
if (participation)
body.append("matrix_rtc_snapshot", participation.debugSnapshot());
const rtcParticipationManager =
window.matrixRtc?.rtcParticipationManager;
if (rtcParticipationManager)
body.append(
"matrix_rtc_snapshot",
rtcParticipationManager.debugSnapshot(),
);
body.append("hostname", window.location.hostname);
if (client) {
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
/**
* `createCallViewModel$` over a real `CallParticipation` (the crate, through
* `createCallViewModel$` over a real `RtcParticipationManager` (the crate, through
* the mock drivers) and mocked LiveKit connections: the Matrix side end to
* end, from the user's join to the roster and back out.
*
@@ -40,7 +40,7 @@ import {
} from "../../utils/test";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { constant } from "../Behavior";
import { CallParticipation } from "../rtc/CallParticipation";
import { RtcParticipationManager } from "../rtc/RtcParticipationManager";
import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams";
import { type CallViewModel, createCallViewModel$ } from "./CallViewModel";
@@ -61,7 +61,7 @@ const peer = {
function createEnvironment(driver: MockRtcMatrixDriver): {
vm: CallViewModel;
participation: CallParticipation;
rtcParticipationManager: RtcParticipationManager;
clientDriver: MockElementCallMatrixClientDriver;
} {
const scope = testScope();
@@ -84,7 +84,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
},
],
});
const participation = new CallParticipation(
const rtcParticipationManager = new RtcParticipationManager(
scope,
driver,
driver.roomId,
@@ -107,7 +107,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
});
const vm = createCallViewModel$(
scope,
participation,
rtcParticipationManager,
clientDriver,
mockMediaDevices({}),
mockMuteStates(),
@@ -141,10 +141,10 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
new BehaviorSubject<Record<string, ReactionInfo>>({}),
constant({ processor: undefined, supported: false }),
);
return { vm, participation, clientDriver };
return { vm, rtcParticipationManager, clientDriver };
}
describe("createCallViewModel$ over a CallParticipation", () => {
describe("createCallViewModel$ over a RtcParticipationManager", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
@@ -154,13 +154,13 @@ describe("createCallViewModel$ over a CallParticipation", () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const { vm, participation } = createEnvironment(driver);
const { vm, rtcParticipationManager } = createEnvironment(driver);
expect(vm.participantCount$.value).toBe(0);
expect(vm.connected$.value).toBe(false);
vm.join();
await waitFor("the crate to be connected", () =>
FfiStatus.Connected.instanceOf(participation.status$.value),
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value),
);
// The crate discovered the transport and minted our token; the view
// model holds a connection to it.
@@ -201,7 +201,7 @@ describe("createCallViewModel$ over a CallParticipation", () => {
vm.leave();
await waitFor("the crate to be disconnected", () =>
FfiStatus.Disconnected.instanceOf(participation.status$.value),
FfiStatus.Disconnected.instanceOf(rtcParticipationManager.status$.value),
);
await waitFor(
"our tile to go",
+17 -17
View File
@@ -168,7 +168,7 @@ import {
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { mapEpoch } from "../ObservableScope.ts";
import { type CallParticipation } from "../rtc/CallParticipation.ts";
import { type RtcParticipationManager } from "../rtc/RtcParticipationManager.ts";
import { joinParamsFromConfig } from "../rtc/joinParams.ts";
import { type FfiJoinParams } from "../../matrix-rtc-sdk";
import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts";
@@ -801,7 +801,7 @@ export function createJsClientCallViewModel$(
*
* {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's
* `MatrixRTCSession`; {@link createCallViewModel$} from a
* {@link CallParticipation} over the drivers.
* {@link RtcParticipationManager} over the drivers.
*/
export interface CallViewModelCore {
localMembership: LocalMembership;
@@ -2025,7 +2025,7 @@ function assembleCallViewModel(
}
/**
* The call view model over the host's drivers: a {@link CallParticipation}
* The call view model over the host's drivers: a {@link RtcParticipationManager}
* (the crate: memberships, connections and their tokens, media keys, our own
* membership) and an {@link ElementCallMatrixClientDriver} (the room's
* members and metadata, the timeline for notifications).
@@ -2035,7 +2035,7 @@ function assembleCallViewModel(
*/
export function createCallViewModel$(
scope: ObservableScope,
participation: CallParticipation,
rtcParticipationManager: RtcParticipationManager,
clientDriver: ElementCallMatrixClientDriver,
mediaDevices: MediaDevices,
muteStates: MuteStates,
@@ -2056,7 +2056,7 @@ export function createCallViewModel$(
const livekitKeyProvider = getParticipationKeyProvider(
options.encryptionSystem,
scope,
participation,
rtcParticipationManager,
logger,
);
@@ -2078,7 +2078,7 @@ export function createCallViewModel$(
const connectionManager = createParticipationConnectionManager$({
scope,
participation,
rtcParticipationManager,
connectionFactory,
ownIdentity: { userId, deviceId },
logger,
@@ -2086,7 +2086,7 @@ export function createCallViewModel$(
const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({
scope,
participation,
rtcParticipationManager,
connectionManager,
});
@@ -2101,16 +2101,16 @@ export function createCallViewModel$(
// Whether the homeserver takes sticky events decides how a failed first
// send reads; assume it does until the driver says otherwise.
let stickyEventsSupported = true;
clientDriver.getCapabilities().then(
(capabilities) => {
stickyEventsSupported = capabilities.stickyEvents;
clientDriver.getMatrixClientFeatures().then(
(features) => {
stickyEventsSupported = features.stickyEvents;
},
(e) => logger.warn("Could not read the driver's capabilities", e),
(e) => logger.warn("Could not read the driver's Matrix client features", e),
);
const localMembership = createParticipationLocalMembership$({
scope,
participation,
rtcParticipationManager,
connectionManager,
createPublisherFactory: (connection: Connection) =>
new Publisher(
@@ -2151,7 +2151,7 @@ export function createCallViewModel$(
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
scope.behavior(
participation.ownMembership$.pipe(
rtcParticipationManager.ownMembership$.pipe(
map((membership) =>
membership === null ? null : callMemberOf(membership),
),
@@ -2186,7 +2186,7 @@ export function createCallViewModel$(
clientDriver,
);
const callMemberUserIds$ = scope.behavior(
participation.memberships$.pipe(
rtcParticipationManager.memberships$.pipe(
mapEpoch((memberships) =>
memberships.map((m) => ({ userId: m.member.userId })),
),
@@ -2207,7 +2207,7 @@ export function createCallViewModel$(
matrixRoomMembers$,
sentCallNotification$: createParticipationSentCallNotification$({
scope,
participation,
rtcParticipationManager,
timeline: clientDriver,
options,
logger,
@@ -2242,14 +2242,14 @@ export function createCallViewModel$(
function getParticipationKeyProvider(
e2eeSystem: EncryptionSystem,
scope: ObservableScope,
participation: CallParticipation,
rtcParticipationManager: RtcParticipationManager,
logger: Logger,
): BaseKeyProvider | undefined {
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
const keyProvider = new ParticipationKeyProvider();
keyProvider.attach(scope, participation);
keyProvider.attach(scope, rtcParticipationManager);
return keyProvider;
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
const keyProvider = new ExternalE2EEKeyProvider();
@@ -30,11 +30,11 @@ const peer = fakeMembership({ member: { memberId: "m-peer" } });
describe("createParticipationSentCallNotification$", () => {
it("rings once our membership echoes back, if we were first, and again after a rejoin", async () => {
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({
scope: testScope(),
participation,
rtcParticipationManager,
timeline,
options: { sendNotificationType: "ring", callIntent: "video" },
logger,
@@ -42,8 +42,8 @@ describe("createParticipationSentCallNotification$", () => {
expect(sent$.value).toBeNull();
// Our echo arrives; the roster has only us.
participation.setMemberships([own]);
participation.ownMembership$.next(own);
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await waitFor("notification sent", () => sent$.value !== null);
const [call] = timeline.calls("sendRoomEvent");
expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE);
@@ -60,15 +60,15 @@ describe("createParticipationSentCallNotification$", () => {
});
// A refresh of our membership is not a join.
participation.ownMembership$.next({ ...own });
rtcParticipationManager.ownMembership$.next({ ...own });
expect(timeline.calls("sendRoomEvent")).toHaveLength(1);
// We leave and come back alone: the room rings again.
participation.ownMembership$.next(null);
participation.setMemberships([]);
rtcParticipationManager.ownMembership$.next(null);
rtcParticipationManager.setMemberships([]);
expect(sent$.value).toBeNull();
participation.setMemberships([own]);
participation.ownMembership$.next(own);
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await waitFor(
"second notification",
() => timeline.calls("sendRoomEvent").length === 2,
@@ -76,34 +76,34 @@ describe("createParticipationSentCallNotification$", () => {
});
it("does not ring when somebody was in the session before us", async () => {
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({
scope: testScope(),
participation,
rtcParticipationManager,
timeline,
options: { sendNotificationType: "ring" },
logger,
});
participation.setMemberships([peer, own]);
participation.ownMembership$.next(own);
rtcParticipationManager.setMemberships([peer, own]);
rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]);
expect(sent$.value).toBeNull();
});
it("does nothing without a notification type", async () => {
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
createParticipationSentCallNotification$({
scope: testScope(),
participation,
rtcParticipationManager,
timeline,
options: {},
logger,
});
participation.setMemberships([own]);
participation.ownMembership$.next(own);
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]);
});
@@ -35,7 +35,7 @@ export const RTC_DECLINE_EVENT_TYPE = "org.matrix.msc4310.rtc.decline";
/** How long a ring is offered for, as matrix-js-sdk has it. */
export const NOTIFICATION_LIFETIME_MS = 90_000;
/** What sending the notification needs from a {@link CallParticipation}. */
/** What sending the notification needs from a {@link RtcParticipationManager}. */
export interface ParticipationNotificationSource {
ownMembership$: Behavior<FfiMembership | null>;
memberships$: Behavior<Epoch<FfiMembership[]>>;
@@ -43,7 +43,7 @@ export interface ParticipationNotificationSource {
interface Props {
scope: ObservableScope;
participation: ParticipationNotificationSource;
rtcParticipationManager: ParticipationNotificationSource;
timeline: TimelineDriver;
options: {
/** Whether and what kind of notification to send when joining the call. */
@@ -64,7 +64,7 @@ interface Props {
*/
export function createParticipationSentCallNotification$({
scope,
participation,
rtcParticipationManager,
timeline,
options: { sendNotificationType, callIntent },
logger: parentLogger,
@@ -73,11 +73,11 @@ export function createParticipationSentCallNotification$({
const sent$ = new BehaviorSubject<CallNotificationWrapper | null>(null);
if (sendNotificationType === undefined) return scope.behavior(sent$);
participation.ownMembership$
rtcParticipationManager.ownMembership$
.pipe(
startWith(null),
pairwise(),
withLatestFrom(participation.memberships$),
withLatestFrom(rtcParticipationManager.memberships$),
scope.bind(),
)
.subscribe(([[previous, own], memberships]) => {
@@ -76,7 +76,7 @@ export {
/**
* The crate's view of our membership, for `LocalMemberState.matrix` when the
* call runs over a `CallParticipation` (matrix-js-sdk reports its own
* call runs over a `RtcParticipationManager` (matrix-js-sdk reports its own
* `RTCSessionStatus` there).
*/
export enum MatrixConnectionStatus {
@@ -34,7 +34,7 @@ import {
type FfiStatus as FfiStatusType,
type FfiTransportIntent,
} from "../../../matrix-rtc-sdk";
import { type SlotPolicy } from "../../rtc/CallParticipation.ts";
import { type SlotPolicy } from "../../rtc/RtcParticipationManager.ts";
import { type DisconnectContext, errorForStatus } from "../../rtc/errors.ts";
import { publishOnLivekit } from "../../rtc/transportIntent.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
@@ -52,7 +52,7 @@ import {
} from "./LocalMember.ts";
import { type HomeserverDisconnectReason } from "./HomeserverConnected.ts";
/** What our own membership needs from a {@link CallParticipation}. */
/** What our own membership needs from a {@link RtcParticipationManager}. */
export interface ParticipationLocalMemberSource {
status$: Behavior<FfiStatusType>;
connections$: Behavior<FfiConnectionWithMembers[]>;
@@ -68,7 +68,7 @@ export interface ParticipationLocalMemberSource {
interface Props {
scope: ObservableScope;
participation: ParticipationLocalMemberSource;
rtcParticipationManager: ParticipationLocalMemberSource;
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
muteStates: MuteStates;
@@ -143,7 +143,7 @@ function describeStatus(status: FfiStatusType): MatrixConnection {
}
/**
* Our own membership over a {@link CallParticipation}: the crate publishes
* Our own membership over a {@link RtcParticipationManager}: the crate publishes
* and keeps alive the membership, discovers the transport and mints its
* token; this joins and leaves when the user asks, publishes our media on
* the connection the crate gave us, and projects the crate's status onto the
@@ -151,7 +151,7 @@ function describeStatus(status: FfiStatusType): MatrixConnection {
*/
export const createParticipationLocalMembership$ = ({
scope,
participation,
rtcParticipationManager,
connectionManager,
createPublisherFactory,
muteStates,
@@ -170,8 +170,8 @@ export const createParticipationLocalMembership$ = ({
// The connection we publish on: the one the crate lists our own member on.
const ownServiceUrl$ = scope.behavior(
combineLatest([
participation.connections$,
participation.ownMemberId$,
rtcParticipationManager.connections$,
rtcParticipationManager.ownMemberId$,
]).pipe(
map(
([connections, ownMemberId]) =>
@@ -205,7 +205,7 @@ export const createParticipationLocalMembership$ = ({
);
const matrixConnection$ = scope.behavior(
participation.status$.pipe(
rtcParticipationManager.status$.pipe(
map(describeStatus),
distinctUntilChanged(
(a, b) =>
@@ -266,7 +266,7 @@ export const createParticipationLocalMembership$ = ({
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(roomId);
try {
await participation.join(
await rtcParticipationManager.join(
publishOnLivekit(customLivekitUrl || undefined),
joinParams,
slotPolicy$.value,
@@ -277,7 +277,7 @@ export const createParticipationLocalMembership$ = ({
error instanceof ElementCallError
? error
: (errorForStatus(
participation.status$.value,
rtcParticipationManager.status$.value,
disconnectContext(),
) ??
new MembershipManagerError(
@@ -288,7 +288,7 @@ export const createParticipationLocalMembership$ = ({
return Promise.resolve(async (): Promise<void> => {
try {
await participation.leave();
await rtcParticipationManager.leave();
} catch (e) {
logger.error("Error leaving the session", e);
}
@@ -299,7 +299,7 @@ export const createParticipationLocalMembership$ = ({
// The crate can end the participation on its own (the slot closed, the
// manager stopped): while the user still wants to be in the call, that is
// an error to show.
combineLatest([participation.status$, joinAndPublishRequested$])
combineLatest([rtcParticipationManager.status$, joinAndPublishRequested$])
.pipe(scope.bind())
.subscribe(([status, shouldConnect]) => {
if (!shouldConnect) return;
@@ -398,7 +398,7 @@ export const createParticipationLocalMembership$ = ({
// The call intent follows the camera (C11). Before the join the crate
// refuses, which is expected.
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
participation
rtcParticipationManager
.updateApplication(videoEnabled ? "video" : "audio")
.catch((e) => {
logger.debug(
@@ -63,11 +63,11 @@ function recordingFactory(): {
describe("createParticipationConnectionManager$", () => {
it("opens one connection per service with the crate's token and keeps it across a refresh", () => {
const scope = testScope();
const participation = new FakeParticipation();
const rtcParticipationManager = new FakeParticipation();
const { factory, created } = recordingFactory();
const manager = createParticipationConnectionManager$({
scope,
participation,
rtcParticipationManager,
connectionFactory: factory,
ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" },
logger,
@@ -76,7 +76,7 @@ describe("createParticipationConnectionManager$", () => {
[],
);
participation.connections$.next([
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }),
]);
expect(created).toEqual([
@@ -92,13 +92,13 @@ describe("createParticipationConnectionManager$", () => {
).toBe("https://a");
// The crate refreshed the token: the same connection stays up.
participation.connections$.next([
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
]);
expect(created).toHaveLength(1);
// A second service appears; the first is untouched.
participation.connections$.next([
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]);
@@ -111,7 +111,7 @@ describe("createParticipationConnectionManager$", () => {
).toHaveLength(2);
// Everybody left the first service: its connection goes away.
participation.connections$.next([
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]);
expect(
@@ -18,7 +18,7 @@ import {
type IConnectionManager,
} from "./ConnectionManager";
/** What this module needs from a {@link CallParticipation}. */
/** What this module needs from a {@link RtcParticipationManager}. */
export interface ParticipationConnectionsSource {
/** The LiveKit rooms to hold, with a token for each, keyed by service URL. */
connections$: Behavior<FfiConnectionWithMembers[]>;
@@ -26,7 +26,7 @@ export interface ParticipationConnectionsSource {
interface Props {
scope: ObservableScope;
participation: ParticipationConnectionsSource;
rtcParticipationManager: ParticipationConnectionsSource;
connectionFactory: ConnectionFactory;
/** Who we publish as. Connections only log it; the tokens come minted. */
ownIdentity: { userId: string; deviceId: string };
@@ -42,7 +42,7 @@ interface Props {
*/
export function createParticipationConnectionManager$({
scope,
participation,
rtcParticipationManager,
connectionFactory,
ownIdentity,
logger: parentLogger,
@@ -50,7 +50,7 @@ export function createParticipationConnectionManager$({
const logger = parentLogger.getChild("[ParticipationConnections]");
const connections$ = scope.behavior(
participation.connections$.pipe(
rtcParticipationManager.connections$.pipe(
trackEpoch(),
generateItemsWithEpoch(
"ParticipationConnections connections$",
@@ -56,8 +56,8 @@ describe("callMemberOf", () => {
describe("createParticipationRemoteMembers$", () => {
it("lists everyone but us, with their connection and participant", () => {
const scope = testScope();
const participation = new FakeParticipation();
participation.ownMemberId$.next("m-me");
const rtcParticipationManager = new FakeParticipation();
rtcParticipationManager.ownMemberId$.next("m-me");
const connection = new MockConnection(
{
@@ -84,14 +84,14 @@ describe("createParticipationRemoteMembers$", () => {
const members$ = createParticipationRemoteMembers$({
scope,
participation,
rtcParticipationManager,
connectionManager: {
connectionManagerData$: constant(new Epoch(data, 1)),
},
});
expect(members$.value.value).toEqual([]);
participation.setMemberships([
rtcParticipationManager.setMemberships([
fakeMembership({ member: { memberId: "m-me", userId: "@me:x" } }),
fakeMembership({
member: { memberId: "m-peer", userId: "@peer:x" },
@@ -18,7 +18,7 @@ import {
type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers";
/** What this module needs from a {@link CallParticipation}. */
/** What this module needs from a {@link RtcParticipationManager}. */
export interface ParticipationRoster {
memberships$: Behavior<Epoch<FfiMembership[]>>;
ownMemberId$: Behavior<string | null>;
@@ -41,7 +41,7 @@ export function callMemberOf(membership: FfiMembership): CallMember {
interface Props {
scope: ObservableScope;
participation: ParticipationRoster;
rtcParticipationManager: ParticipationRoster;
connectionManager: IConnectionManager;
}
@@ -52,13 +52,13 @@ interface Props {
*/
export function createParticipationRemoteMembers$({
scope,
participation,
rtcParticipationManager,
connectionManager,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
return scope.behavior(
combineLatest([
participation.memberships$,
participation.ownMemberId$,
rtcParticipationManager.memberships$,
rtcParticipationManager.ownMemberId$,
connectionManager.connectionManagerData$,
]).pipe(
map(
@@ -1,517 +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.
*/
/**
* Full integration against the dev backend (`pnpm backend`: Synapse develop,
* lk-jwt-service and LiveKit behind nginx with the dev TLS certificate)
* through the js-sdk drivers: two real users in one encrypted room, two
* participations, and every wire feature the crate relies on — transport
* discovery, the slot state event, sticky member events, delayed events and
* their delegation, the token exchange, Olm-encrypted media keys and
* homeserver connectivity.
*
* Opt-in, because it needs the backend and takes a minute:
*
* MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 \
* pnpm vitest run --project unit src/state/rtc/CallParticipation.backend.test.ts
*
* `HOMESERVER_URL` overrides the homeserver (default: the dev backend).
*/
// The global `process` is vite-plugin-node-polyfills' browser shim, whose
// `env` is empty; the real one comes from the module.
import { env } from "node:process";
import { beforeAll, describe, expect, it } from "vitest";
import {
ClientEvent,
createClient,
type MatrixClient,
Method,
Preset,
type Room,
SyncState,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import { JsSdkElementCallMatrixClientDriver } from "../../driver/jsSdk/JsSdkElementCallMatrixClientDriver";
import { JsSdkRtcMatrixDriver } from "../../driver/jsSdk/JsSdkRtcMatrixDriver";
import { waitFor } from "../../driver/MockRtcMatrixDriver";
import {
FfiDelegationRoute,
FfiImpairment,
FfiKeepAlive,
FfiStatus,
} from "../../matrix-rtc-sdk";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { testScope } from "../../utils/test";
import { CallParticipation } from "./CallParticipation";
import { joinParamsFromConfig, participationConfig } from "./joinParams";
import { ELEMENT_CALL_SLOT_EVENT_TYPE, ELEMENT_CALL_SLOT_ID } from "./slot";
import { publishOnLivekit } from "./transportIntent";
const enabled = env.MATRIX_RTC_BACKEND === "1";
const HOMESERVER_URL = (
env.HOMESERVER_URL ?? "https://synapse.m.localhost"
).replace(/\/$/, "");
/** The transport `backend/dev_homeserver.yaml` advertises. */
const DEV_LIVEKIT_SERVICE_URL = "https://matrix-rtc.m.localhost/livekit/jwt";
const DELAYED_EVENTS_PREFIX = "/_matrix/client/unstable/org.matrix.msc4140";
const session = {
delayed_leave: { delay_ms: 18_000 },
delegated_delayed_leave: { delay_ms: 3_600_000 },
network_error_retry_ms: 1000,
wait_for_key_rotation_ms: 50,
};
interface TestUser {
name: string;
client: MatrixClient;
room: Room;
userId: string;
deviceId: string;
rtcDriver: JsSdkRtcMatrixDriver;
clientDriver: JsSdkElementCallMatrixClientDriver;
stop: () => void;
}
interface DelayedEvent {
delay_id: string;
room_id: string;
type: string;
state_key?: string;
delay: number;
running_since: number;
content: Record<string, unknown>;
}
const log = (who: string, line: string): void =>
// Progress of an opt-in integration run, meant to be read.
// eslint-disable-next-line no-console
console.log(`[backend ${who}] ${line}`);
/**
* Register a throwaway user, boot rust crypto, sync, and be in the room.
* `legacyMembers` gives every member the power to send the MSC3401 member
* *state* event, as Element Call's own rooms do (`state_default: 0`); a
* plain room keeps the default 50, which also keeps Bob from opening a slot.
*/
async function createUser(
name: string,
roomId?: string,
{ encrypted = true, legacyMembers = false } = {},
): Promise<TestUser> {
const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor(
Math.random() * 0xffff,
).toString(16)}`;
const register = async (): Promise<Response> =>
fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: localpart,
password: `test-${localpart}`,
auth: { type: "m.login.dummy" },
}),
});
let response = await register();
// Synapse rate-limits registrations; the second case of this file runs
// straight into that.
while (response.status === 429) {
const { retry_after_ms: retryAfterMs = 1000 } = (await response.json()) as {
retry_after_ms?: number;
};
await new Promise((resolve) => setTimeout(resolve, retryAfterMs + 100));
response = await register();
}
if (!response.ok)
throw new Error(
`registration failed: ${response.status} ${await response.text()}`,
);
const {
user_id: userId,
device_id: deviceId,
access_token: accessToken,
} = (await response.json()) as {
user_id: string;
device_id: string;
access_token: string;
};
const client = createClient({
baseUrl: HOMESERVER_URL,
accessToken,
userId,
deviceId,
logger: logger.getChild(`[${name}]`),
});
// In memory on purpose: every run is a fresh device.
await client.initRustCrypto({ useIndexedDB: false });
await client.setDisplayName(name);
void client.startClient();
await new Promise<void>((resolve, reject) => {
client.once(ClientEvent.Sync, (state) =>
state === SyncState.Prepared
? resolve()
: reject(new Error(`sync failed: ${state}`)),
);
});
if (roomId === undefined) {
const created = await client.createRoom({
preset: Preset.PublicChat,
name: `Element Call backend check ${new Date().toISOString()}`,
power_level_content_override: legacyMembers
? { events: { "org.matrix.msc3401.call.member": 0 } }
: undefined,
initial_state: encrypted
? [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
]
: [],
});
roomId = created.room_id;
} else {
await client.joinRoom(roomId);
}
await waitFor(
`${name}'s room to appear in sync`,
() => client.getRoom(roomId) !== null,
10_000,
);
const room = client.getRoom(roomId)!;
log(name, `ready as ${userId} (${deviceId}) in ${roomId}`);
const rtcDriver = new JsSdkRtcMatrixDriver(client, room);
const clientDriver = new JsSdkElementCallMatrixClientDriver(client, room);
return {
name,
client,
room,
userId,
deviceId,
rtcDriver,
clientDriver,
stop: () => {
rtcDriver.detach();
client.stopClient();
},
};
}
function participate(
user: TestUser,
mode: MatrixRTCMode,
manageMediaKeys: boolean,
): CallParticipation {
return new CallParticipation(
testScope(),
user.rtcDriver,
user.room.roomId,
user.userId,
user.deviceId,
{
config: participationConfig({ mode, manageMediaKeys, session }),
logger: logger.getChild(`[${user.name}]`),
},
);
}
async function listDelayedEvents(
client: MatrixClient,
): Promise<DelayedEvent[]> {
const response = await client.http.authedRequest<{
delayed_events: DelayedEvent[];
}>(Method.Get, "/delayed_events", undefined, undefined, {
prefix: DELAYED_EVENTS_PREFIX,
});
return response.delayed_events;
}
async function fetchRawEvent(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<Record<string, unknown>> {
return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record<
string,
unknown
>;
}
function connected(
participation: CallParticipation,
): InstanceType<typeof FfiStatus.Connected>["inner"] {
const status = participation.status$.value;
if (!FfiStatus.Connected.instanceOf(status))
throw new Error(`Expected Connected, got ${status.tag}`);
return status.inner;
}
describe.skipIf(!enabled)("CallParticipation against the dev backend", () => {
beforeAll(async () => {
// The two clients' crypto debug output would drown everything else.
(logger as unknown as { setLevel(level: string): void }).setLevel("warn");
await initMatrixRtcSdkForTests();
});
it.each([
{
mode: MatrixRTCMode.Matrix_2_0,
memberEventType: "org.matrix.msc4143.rtc.member",
sticky: true,
},
{
mode: MatrixRTCMode.Compatibility,
memberEventType: "org.matrix.msc3401.call.member",
sticky: false,
},
])(
"two users call each other in $mode mode",
async ({ mode, memberEventType, sticky }) => {
const alice = await createUser("Alice", undefined, {
legacyMembers: !sticky,
});
const bob = await createUser("Bob", alice.room.roomId);
const roomId = alice.room.roomId;
const a = participate(alice, mode, true);
const b = participate(bob, mode, true);
const joinParams = joinParamsFromConfig({
session,
delegateDelayedLeave: true,
});
const dump = (): void => {
log("alice", `snapshot: ${a.debugSnapshot()}`);
log("bob", `snapshot: ${b.debugSnapshot()}`);
};
try {
// --- connectivity, before anything else ------------------------------
expect(alice.rtcDriver.isHomeserverConnected()).toBe(true);
// --- the slot ----------------------------------------------------------
// A fresh room has no slot. Alice created it, so she may open one; Bob
// (power level 0, state_default 50) may not.
await waitFor("the seed", () => a.session$.value.seeded, 15_000);
expect(a.session$.value.slotOpen).not.toBe(true);
expect(
alice.room.currentState.getStateEvents(ELEMENT_CALL_SLOT_EVENT_TYPE),
).toEqual([]);
const aliceRoom = alice.clientDriver.getRoomInfo();
const bobRoom = bob.clientDriver.getRoomInfo();
expect(aliceRoom.encrypted).toBe(true);
expect(aliceRoom.canOpenSlot).toBe(true);
expect(bobRoom.canOpenSlot).toBe(false);
// --- Alice joins: opens the slot, discovers the transport, publishes --
await a.join(publishOnLivekit(), joinParams, {
encrypted: aliceRoom.encrypted,
canOpen: aliceRoom.canOpenSlot,
});
const aliceStatus = connected(a);
log("alice", `joined: keepAlive=${aliceStatus.keepAlive.tag}`);
const slot = alice.room.currentState.getStateEvents(
ELEMENT_CALL_SLOT_EVENT_TYPE,
ELEMENT_CALL_SLOT_ID,
);
if (sticky) {
expect(slot?.getContent()).toMatchObject({
status: "open",
application: { type: "m.call" },
encryption: { type: "m.per_member" },
});
await waitFor(
"bob to see the slot",
() => b.session$.value.slotOpen === true,
15_000,
);
} else {
// The pre-slot generation: nothing opened, nothing to wait for.
expect(slot).toBeNull();
}
// The transport came from the homeserver's /rtc/transports.
const [connection] = a.connections$.value;
expect(connection.connection.serviceUrl).toBe(DEV_LIVEKIT_SERVICE_URL);
expect(connection.connection.jwtToken.split(".")).toHaveLength(3);
expect(connection.connection.wsUrl).toMatch(/^wss:\/\//);
// --- the member event on the wire -------------------------------------
await waitFor(
"alice to see her own membership",
() => a.ownMembership$.value !== null,
15_000,
);
const ownEventId = a.ownMembership$.value!.member.eventId;
expect(ownEventId).toBeDefined();
const raw = await fetchRawEvent(alice.client, roomId, ownEventId!);
log("alice", `own member event: ${JSON.stringify(raw)}`);
if (sticky) {
// A sticky event is a timeline event: in an encrypted room matrix-js-sdk
// Megolm-encrypts it like any other (its own MatrixRTC code decrypts
// them on the way in, as our driver does). The sticky marker is in
// the clear.
expect(raw.type).toBe("m.room.encrypted");
expect(raw.state_key).toBeUndefined();
expect(raw).toHaveProperty("msc4354_sticky");
const decrypted = [...alice.room._unstable_getStickyEvents()].find(
(e) => e.getId() === ownEventId,
);
expect(decrypted?.getType()).toBe(memberEventType);
} else {
expect(raw.type).toBe(memberEventType);
expect(raw.state_key).toBe(
`_${alice.userId}_${alice.deviceId}_m.call`,
);
}
// --- delayed events and their delegation -------------------------------
const delayed = await listDelayedEvents(alice.client);
log("alice", `delayed events: ${JSON.stringify(delayed)}`);
expect(delayed).toHaveLength(1);
expect(delayed[0].room_id).toBe(roomId);
expect(delayed[0].type).toBe(memberEventType);
const keepAlive = aliceStatus.keepAlive;
if (sticky) {
// The dev Synapse proxies `rtc/livekit/*` to lk-jwt-service
// (MSC4512), so the homeserver route takes the long leave over.
expect(FfiKeepAlive.Delegated.instanceOf(keepAlive)).toBe(true);
expect(
(keepAlive as InstanceType<typeof FfiKeepAlive.Delegated>).inner
.via,
).toBe(FfiDelegationRoute.Homeserver);
expect(delayed[0].delay).toBe(
session.delegated_delayed_leave.delay_ms,
);
} else {
// MSC4195 is not spoken for the pre-slot generation: our own leave.
expect(FfiKeepAlive.Armed.instanceOf(keepAlive)).toBe(true);
expect(delayed[0].delay).toBe(session.delayed_leave.delay_ms);
}
// --- Bob joins: roster, profiles, one connection with two members -----
await b.join(publishOnLivekit(), joinParams, {
encrypted: bobRoom.encrypted,
canOpen: bobRoom.canOpenSlot,
});
connected(b);
await waitFor(
"alice to see bob",
() =>
a.memberships$.value.value.some(
(m) => m.member.userId === bob.userId,
),
20_000,
);
await waitFor(
"bob to see alice",
() =>
b.memberships$.value.value.some(
(m) => m.member.userId === alice.userId,
),
20_000,
);
const bobSeenByAlice = a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)!;
expect(bobSeenByAlice.member.displayName).toBe("Bob");
expect(bobSeenByAlice.member.deviceId).toBe(bob.deviceId);
expect(bobSeenByAlice.connections).toEqual([DEV_LIVEKIT_SERVICE_URL]);
await waitFor(
"two members on alice's connection",
() => a.connections$.value[0]?.members.length === 2,
20_000,
);
// --- media keys, Olm-encrypted to-device both ways ---------------------
await waitFor(
"bob to hold alice's key",
() =>
b.keyMap$.value.some((k) => k.memberId === a.ownMemberId$.value),
30_000,
);
await waitFor(
"alice to hold bob's key",
() =>
a.keyMap$.value.some((k) => k.memberId === b.ownMemberId$.value),
30_000,
);
await waitFor(
"alice to know bob holds her key",
() =>
a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)?.mediaKey?.holdsOurKey === true,
30_000,
);
const bobKeyState = a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)!.mediaKey!;
log("alice", `bob's key state: ${JSON.stringify(bobKeyState)}`);
expect(bobKeyState.haveTheirKey).toBe(true);
expect(bobKeyState.rejection).toBeUndefined();
// MSC4153 verdict travels with the key (C10): nobody here is
// cross-signed, so the answer is "no", not "unknown".
expect(bobKeyState.senderCrossSigned).toBe(false);
// --- Alice leaves: delayed events cancelled, Bob sees her go -----------
await a.leave("m.user_hangup");
expect(FfiStatus.Disconnected.instanceOf(a.status$.value)).toBe(true);
expect(await listDelayedEvents(alice.client)).toEqual([]);
await waitFor(
"bob to see alice gone",
() =>
!b.memberships$.value.value.some(
(m) => m.member.userId === alice.userId,
),
20_000,
);
// --- losing the homeserver is a critical impairment (C12) -------------
// Bob's network goes away: every request fails and the long-poll in
// flight is cut, so matrix-js-sdk's sync loop leaves `Syncing`.
const { opts } = bob.client.http;
opts.fetchFn = async () => {
return Promise.reject(new TypeError("network down"));
};
bob.client.http.abort();
const unreachable = (): boolean =>
connected(b).impairments.some((i) =>
FfiImpairment.HomeserverUnreachable.instanceOf(i),
);
await waitFor(
"bob's participation to notice the homeserver is gone",
unreachable,
15_000,
);
// ...and comes back: the sync loop recovers and the impairment clears.
delete opts.fetchFn;
await waitFor(
"bob's participation to see the homeserver again",
() => !unreachable(),
30_000,
);
await b.leave();
expect(FfiStatus.Disconnected.instanceOf(b.status$.value)).toBe(true);
} catch (e) {
dump();
throw e;
} finally {
// Leave before the clients stop: a leave needs the homeserver.
await a.leave();
await b.leave();
alice.stop();
bob.stop();
}
},
180_000,
);
});
@@ -0,0 +1,524 @@
/*
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.
*/
/**
* Full integration against the dev backend (`pnpm backend`: Synapse develop,
* lk-jwt-service and LiveKit behind nginx with the dev TLS certificate)
* through the js-sdk drivers: two real users in one encrypted room, two
* participations, and every wire feature the crate relies on — transport
* discovery, the slot state event, sticky member events, delayed events and
* their delegation, the token exchange, Olm-encrypted media keys and
* homeserver connectivity.
*
* Opt-in, because it needs the backend and takes a minute:
*
* MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 \
* pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts
*
* `HOMESERVER_URL` overrides the homeserver (default: the dev backend).
*/
// The global `process` is vite-plugin-node-polyfills' browser shim, whose
// `env` is empty; the real one comes from the module.
import { env } from "node:process";
import { beforeAll, describe, expect, it } from "vitest";
import {
ClientEvent,
createClient,
type MatrixClient,
Method,
Preset,
type Room,
SyncState,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import { JsSdkElementCallMatrixClientDriver } from "../../driver/jsSdk/JsSdkElementCallMatrixClientDriver";
import { JsSdkRtcMatrixDriver } from "../../driver/jsSdk/JsSdkRtcMatrixDriver";
import { waitFor } from "../../driver/MockRtcMatrixDriver";
import {
FfiDelegationRoute,
FfiImpairment,
FfiKeepAlive,
FfiStatus,
} from "../../matrix-rtc-sdk";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { testScope } from "../../utils/test";
import { RtcParticipationManager } from "./RtcParticipationManager";
import { joinParamsFromConfig, participationConfig } from "./joinParams";
import { ELEMENT_CALL_SLOT_EVENT_TYPE, ELEMENT_CALL_SLOT_ID } from "./slot";
import { publishOnLivekit } from "./transportIntent";
const enabled = env.MATRIX_RTC_BACKEND === "1";
const HOMESERVER_URL = (
env.HOMESERVER_URL ?? "https://synapse.m.localhost"
).replace(/\/$/, "");
/** The transport `backend/dev_homeserver.yaml` advertises. */
const DEV_LIVEKIT_SERVICE_URL = "https://matrix-rtc.m.localhost/livekit/jwt";
const DELAYED_EVENTS_PREFIX = "/_matrix/client/unstable/org.matrix.msc4140";
const session = {
delayed_leave: { delay_ms: 18_000 },
delegated_delayed_leave: { delay_ms: 3_600_000 },
network_error_retry_ms: 1000,
wait_for_key_rotation_ms: 50,
};
interface TestUser {
name: string;
client: MatrixClient;
room: Room;
userId: string;
deviceId: string;
rtcDriver: JsSdkRtcMatrixDriver;
clientDriver: JsSdkElementCallMatrixClientDriver;
stop: () => void;
}
interface DelayedEvent {
delay_id: string;
room_id: string;
type: string;
state_key?: string;
delay: number;
running_since: number;
content: Record<string, unknown>;
}
const log = (who: string, line: string): void =>
// Progress of an opt-in integration run, meant to be read.
// eslint-disable-next-line no-console
console.log(`[backend ${who}] ${line}`);
/**
* Register a throwaway user, boot rust crypto, sync, and be in the room.
* `legacyMembers` gives every member the power to send the MSC3401 member
* *state* event, as Element Call's own rooms do (`state_default: 0`); a
* plain room keeps the default 50, which also keeps Bob from opening a slot.
*/
async function createUser(
name: string,
roomId?: string,
{ encrypted = true, legacyMembers = false } = {},
): Promise<TestUser> {
const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor(
Math.random() * 0xffff,
).toString(16)}`;
const register = async (): Promise<Response> =>
fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: localpart,
password: `test-${localpart}`,
auth: { type: "m.login.dummy" },
}),
});
let response = await register();
// Synapse rate-limits registrations; the second case of this file runs
// straight into that.
while (response.status === 429) {
const { retry_after_ms: retryAfterMs = 1000 } = (await response.json()) as {
retry_after_ms?: number;
};
await new Promise((resolve) => setTimeout(resolve, retryAfterMs + 100));
response = await register();
}
if (!response.ok)
throw new Error(
`registration failed: ${response.status} ${await response.text()}`,
);
const {
user_id: userId,
device_id: deviceId,
access_token: accessToken,
} = (await response.json()) as {
user_id: string;
device_id: string;
access_token: string;
};
const client = createClient({
baseUrl: HOMESERVER_URL,
accessToken,
userId,
deviceId,
logger: logger.getChild(`[${name}]`),
});
// In memory on purpose: every run is a fresh device.
await client.initRustCrypto({ useIndexedDB: false });
await client.setDisplayName(name);
void client.startClient();
await new Promise<void>((resolve, reject) => {
client.once(ClientEvent.Sync, (state) =>
state === SyncState.Prepared
? resolve()
: reject(new Error(`sync failed: ${state}`)),
);
});
if (roomId === undefined) {
const created = await client.createRoom({
preset: Preset.PublicChat,
name: `Element Call backend check ${new Date().toISOString()}`,
power_level_content_override: legacyMembers
? { events: { "org.matrix.msc3401.call.member": 0 } }
: undefined,
initial_state: encrypted
? [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
]
: [],
});
roomId = created.room_id;
} else {
await client.joinRoom(roomId);
}
await waitFor(
`${name}'s room to appear in sync`,
() => client.getRoom(roomId) !== null,
10_000,
);
const room = client.getRoom(roomId)!;
log(name, `ready as ${userId} (${deviceId}) in ${roomId}`);
const rtcDriver = new JsSdkRtcMatrixDriver(client, room);
const clientDriver = new JsSdkElementCallMatrixClientDriver(client, room);
return {
name,
client,
room,
userId,
deviceId,
rtcDriver,
clientDriver,
stop: () => {
rtcDriver.detach();
client.stopClient();
},
};
}
function participate(
user: TestUser,
mode: MatrixRTCMode,
manageMediaKeys: boolean,
): RtcParticipationManager {
return new RtcParticipationManager(
testScope(),
user.rtcDriver,
user.room.roomId,
user.userId,
user.deviceId,
{
config: participationConfig({ mode, manageMediaKeys, session }),
logger: logger.getChild(`[${user.name}]`),
},
);
}
async function listDelayedEvents(
client: MatrixClient,
): Promise<DelayedEvent[]> {
const response = await client.http.authedRequest<{
delayed_events: DelayedEvent[];
}>(Method.Get, "/delayed_events", undefined, undefined, {
prefix: DELAYED_EVENTS_PREFIX,
});
return response.delayed_events;
}
async function fetchRawEvent(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<Record<string, unknown>> {
return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record<
string,
unknown
>;
}
function connected(
rtcParticipationManager: RtcParticipationManager,
): InstanceType<typeof FfiStatus.Connected>["inner"] {
const status = rtcParticipationManager.status$.value;
if (!FfiStatus.Connected.instanceOf(status))
throw new Error(`Expected Connected, got ${status.tag}`);
return status.inner;
}
describe.skipIf(!enabled)(
"RtcParticipationManager against the dev backend",
() => {
beforeAll(async () => {
// The two clients' crypto debug output would drown everything else.
(logger as unknown as { setLevel(level: string): void }).setLevel("warn");
await initMatrixRtcSdkForTests();
});
it.each([
{
mode: MatrixRTCMode.Matrix_2_0,
memberEventType: "org.matrix.msc4143.rtc.member",
sticky: true,
},
{
mode: MatrixRTCMode.Compatibility,
memberEventType: "org.matrix.msc3401.call.member",
sticky: false,
},
])(
"two users call each other in $mode mode",
async ({ mode, memberEventType, sticky }) => {
const alice = await createUser("Alice", undefined, {
legacyMembers: !sticky,
});
const bob = await createUser("Bob", alice.room.roomId);
const roomId = alice.room.roomId;
const a = participate(alice, mode, true);
const b = participate(bob, mode, true);
const joinParams = joinParamsFromConfig({
session,
delegateDelayedLeave: true,
});
const dump = (): void => {
log("alice", `snapshot: ${a.debugSnapshot()}`);
log("bob", `snapshot: ${b.debugSnapshot()}`);
};
try {
// --- connectivity, before anything else ------------------------------
expect(alice.rtcDriver.isHomeserverConnected()).toBe(true);
// --- the slot ----------------------------------------------------------
// A fresh room has no slot. Alice created it, so she may open one; Bob
// (power level 0, state_default 50) may not.
await waitFor("the seed", () => a.session$.value.seeded, 15_000);
expect(a.session$.value.slotOpen).not.toBe(true);
expect(
alice.room.currentState.getStateEvents(
ELEMENT_CALL_SLOT_EVENT_TYPE,
),
).toEqual([]);
const aliceRoom = alice.clientDriver.getRoomInfo();
const bobRoom = bob.clientDriver.getRoomInfo();
expect(aliceRoom.encrypted).toBe(true);
expect(aliceRoom.canOpenSlot).toBe(true);
expect(bobRoom.canOpenSlot).toBe(false);
// --- Alice joins: opens the slot, discovers the transport, publishes --
await a.join(publishOnLivekit(), joinParams, {
encrypted: aliceRoom.encrypted,
canOpen: aliceRoom.canOpenSlot,
});
const aliceStatus = connected(a);
log("alice", `joined: keepAlive=${aliceStatus.keepAlive.tag}`);
const slot = alice.room.currentState.getStateEvents(
ELEMENT_CALL_SLOT_EVENT_TYPE,
ELEMENT_CALL_SLOT_ID,
);
if (sticky) {
expect(slot?.getContent()).toMatchObject({
status: "open",
application: { type: "m.call" },
encryption: { type: "m.per_member" },
});
await waitFor(
"bob to see the slot",
() => b.session$.value.slotOpen === true,
15_000,
);
} else {
// The pre-slot generation: nothing opened, nothing to wait for.
expect(slot).toBeNull();
}
// The transport came from the homeserver's /rtc/transports.
const [connection] = a.connections$.value;
expect(connection.connection.serviceUrl).toBe(
DEV_LIVEKIT_SERVICE_URL,
);
expect(connection.connection.jwtToken.split(".")).toHaveLength(3);
expect(connection.connection.wsUrl).toMatch(/^wss:\/\//);
// --- the member event on the wire -------------------------------------
await waitFor(
"alice to see her own membership",
() => a.ownMembership$.value !== null,
15_000,
);
const ownEventId = a.ownMembership$.value!.member.eventId;
expect(ownEventId).toBeDefined();
const raw = await fetchRawEvent(alice.client, roomId, ownEventId!);
log("alice", `own member event: ${JSON.stringify(raw)}`);
if (sticky) {
// A sticky event is a timeline event: in an encrypted room matrix-js-sdk
// Megolm-encrypts it like any other (its own MatrixRTC code decrypts
// them on the way in, as our driver does). The sticky marker is in
// the clear.
expect(raw.type).toBe("m.room.encrypted");
expect(raw.state_key).toBeUndefined();
expect(raw).toHaveProperty("msc4354_sticky");
const decrypted = [...alice.room._unstable_getStickyEvents()].find(
(e) => e.getId() === ownEventId,
);
expect(decrypted?.getType()).toBe(memberEventType);
} else {
expect(raw.type).toBe(memberEventType);
expect(raw.state_key).toBe(
`_${alice.userId}_${alice.deviceId}_m.call`,
);
}
// --- delayed events and their delegation -------------------------------
const delayed = await listDelayedEvents(alice.client);
log("alice", `delayed events: ${JSON.stringify(delayed)}`);
expect(delayed).toHaveLength(1);
expect(delayed[0].room_id).toBe(roomId);
expect(delayed[0].type).toBe(memberEventType);
const keepAlive = aliceStatus.keepAlive;
if (sticky) {
// The dev Synapse proxies `rtc/livekit/*` to lk-jwt-service
// (MSC4512), so the homeserver route takes the long leave over.
expect(FfiKeepAlive.Delegated.instanceOf(keepAlive)).toBe(true);
expect(
(keepAlive as InstanceType<typeof FfiKeepAlive.Delegated>).inner
.via,
).toBe(FfiDelegationRoute.Homeserver);
expect(delayed[0].delay).toBe(
session.delegated_delayed_leave.delay_ms,
);
} else {
// MSC4195 is not spoken for the pre-slot generation: our own leave.
expect(FfiKeepAlive.Armed.instanceOf(keepAlive)).toBe(true);
expect(delayed[0].delay).toBe(session.delayed_leave.delay_ms);
}
// --- Bob joins: roster, profiles, one connection with two members -----
await b.join(publishOnLivekit(), joinParams, {
encrypted: bobRoom.encrypted,
canOpen: bobRoom.canOpenSlot,
});
connected(b);
await waitFor(
"alice to see bob",
() =>
a.memberships$.value.value.some(
(m) => m.member.userId === bob.userId,
),
20_000,
);
await waitFor(
"bob to see alice",
() =>
b.memberships$.value.value.some(
(m) => m.member.userId === alice.userId,
),
20_000,
);
const bobSeenByAlice = a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)!;
expect(bobSeenByAlice.member.displayName).toBe("Bob");
expect(bobSeenByAlice.member.deviceId).toBe(bob.deviceId);
expect(bobSeenByAlice.connections).toEqual([DEV_LIVEKIT_SERVICE_URL]);
await waitFor(
"two members on alice's connection",
() => a.connections$.value[0]?.members.length === 2,
20_000,
);
// --- media keys, Olm-encrypted to-device both ways ---------------------
await waitFor(
"bob to hold alice's key",
() =>
b.keyMap$.value.some((k) => k.memberId === a.ownMemberId$.value),
30_000,
);
await waitFor(
"alice to hold bob's key",
() =>
a.keyMap$.value.some((k) => k.memberId === b.ownMemberId$.value),
30_000,
);
await waitFor(
"alice to know bob holds her key",
() =>
a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)?.mediaKey?.holdsOurKey === true,
30_000,
);
const bobKeyState = a.memberships$.value.value.find(
(m) => m.member.userId === bob.userId,
)!.mediaKey!;
log("alice", `bob's key state: ${JSON.stringify(bobKeyState)}`);
expect(bobKeyState.haveTheirKey).toBe(true);
expect(bobKeyState.rejection).toBeUndefined();
// MSC4153 verdict travels with the key (C10): nobody here is
// cross-signed, so the answer is "no", not "unknown".
expect(bobKeyState.senderCrossSigned).toBe(false);
// --- Alice leaves: delayed events cancelled, Bob sees her go -----------
await a.leave("m.user_hangup");
expect(FfiStatus.Disconnected.instanceOf(a.status$.value)).toBe(true);
expect(await listDelayedEvents(alice.client)).toEqual([]);
await waitFor(
"bob to see alice gone",
() =>
!b.memberships$.value.value.some(
(m) => m.member.userId === alice.userId,
),
20_000,
);
// --- losing the homeserver is a critical impairment (C12) -------------
// Bob's network goes away: every request fails and the long-poll in
// flight is cut, so matrix-js-sdk's sync loop leaves `Syncing`.
const { opts } = bob.client.http;
opts.fetchFn = async () => {
return Promise.reject(new TypeError("network down"));
};
bob.client.http.abort();
const unreachable = (): boolean =>
connected(b).impairments.some((i) =>
FfiImpairment.HomeserverUnreachable.instanceOf(i),
);
await waitFor(
"bob's participation to notice the homeserver is gone",
unreachable,
15_000,
);
// ...and comes back: the sync loop recovers and the impairment clears.
delete opts.fetchFn;
await waitFor(
"bob's participation to see the homeserver again",
() => !unreachable(),
30_000,
);
await b.leave();
expect(FfiStatus.Disconnected.instanceOf(b.status$.value)).toBe(true);
} catch (e) {
dump();
throw e;
} finally {
// Leave before the clients stop: a leave needs the homeserver.
await a.leave();
await b.leave();
alice.stop();
bob.stop();
}
},
180_000,
);
},
);
@@ -25,7 +25,7 @@ import {
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { testScope } from "../../utils/test";
import { ObservableScope } from "../ObservableScope";
import { CallParticipation } from "./CallParticipation";
import { RtcParticipationManager } from "./RtcParticipationManager";
import { errorForStatus } from "./errors";
import {
compatForMode,
@@ -55,8 +55,8 @@ function create(
driver: MockRtcMatrixDriver,
overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {},
scope = testScope(),
): CallParticipation {
return new CallParticipation(
): RtcParticipationManager {
return new RtcParticipationManager(
scope,
driver,
driver.roomId,
@@ -82,7 +82,7 @@ const peer = {
memberId: "m-peer",
};
describe("CallParticipation", () => {
describe("RtcParticipationManager", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
@@ -90,7 +90,7 @@ describe("CallParticipation", () => {
it("in compatibility mode joins with a legacy state event and no slot", async () => {
// a room that never had a slot, as every pre-slot room is
const driver = new MockRtcMatrixDriver();
const callParticipation = new CallParticipation(
const callParticipation = new RtcParticipationManager(
testScope(),
driver,
driver.roomId,
@@ -56,7 +56,7 @@ export interface SlotPolicy {
/** How long to wait for the seed, and for our own slot event to echo back. */
const SLOT_WAIT_MS = 15_000;
/** Media keys sent and received over a participation; see {@link CallParticipation.mediaKeyStatistics}. */
/** Media keys sent and received over a participation; see {@link RtcParticipationManager.mediaKeyStatistics}. */
export interface MediaKeyStatistics {
sent: number;
received: number;
@@ -64,7 +64,7 @@ export interface MediaKeyStatistics {
receivedTotalAge: number;
}
export interface CallParticipationOptions {
export interface RtcParticipationManagerOptions {
/**
* One manager per `(room, slot)`; Element Call has one slot per room.
* Defaults to the slot for the config's dialect ({@link slotIdForCompat}).
@@ -82,18 +82,17 @@ export interface CallParticipationOptions {
/**
* Element Call's view of one participation in a MatrixRTC session: the
* crate's `FfiParticipationManager` as behaviors. "Participation" is the
* crate's word for the FFI side; this is the RxJS wrapper a call is built on.
* crate's `FfiParticipationManager` as behaviors.
* This is the RxJS wrapper a call is built on.
*
* The crate does everything Matrix: it projects the session from the
* The crates ParticipationManager does everything Matrix: it projects the session from the
* driver's events, publishes and keeps alive our own membership, mints
* transport tokens and exchanges media keys. This class owns the manager
* for the scope's lifetime, seeds each behavior from the manager's getter and
* keeps it current from the manager's listener, and ends the participation
* (leaving if still joined) when the scope ends.
*/
// TODO-RENAME: the call participationmanager wrapper represents the RtcParticipationManager
export class CallParticipation {
export class RtcParticipationManager {
private readonly logger: Logger;
private readonly matrixDriver: FfiMatrixDriver;
private readonly manager: FfiParticipationManager;
@@ -140,10 +139,10 @@ export class CallParticipation {
roomId: string,
userId: string,
deviceId: string,
options: CallParticipationOptions,
options: RtcParticipationManagerOptions,
) {
this.logger = (options.logger ?? rootLogger).getChild(
"[CallParticipation]",
"[RtcParticipationManager]",
);
const rtcDriver =
options.transportFallbackUrl === undefined
@@ -14,7 +14,7 @@ import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallM
import { MockRtcMatrixDriver } from "../../driver/MockRtcMatrixDriver";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { participationConfig } from "./joinParams";
import { useCallParticipation } from "./useCallParticipation";
import { useRtcParticipationManager } from "./useRtcParticipationManager";
const config = participationConfig({
mode: MatrixRTCMode.Matrix_2_0,
@@ -26,7 +26,7 @@ const config = participationConfig({
},
});
describe("useCallParticipation", () => {
describe("useRtcParticipationManager", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
@@ -37,7 +37,7 @@ describe("useCallParticipation", () => {
clientDriver: new MockElementCallMatrixClientDriver(),
};
const { result, rerender, unmount } = renderHook(
({ drivers }) => useCallParticipation(drivers, config),
({ drivers }) => useRtcParticipationManager(drivers, config),
{ initialProps: { drivers: null as MatrixDrivers | null } },
);
// Nothing without drivers (matrix-js-sdk carries the call).
@@ -45,12 +45,14 @@ describe("useCallParticipation", () => {
rerender({ drivers });
await waitFor(() => expect(result.current).not.toBeNull());
const participation = result.current!;
const rtcParticipationManager = result.current!;
// It seeds the session from the driver right away.
await waitFor(() => expect(participation.session$.value.seeded).toBe(true));
await waitFor(() =>
expect(rtcParticipationManager.session$.value.seeded).toBe(true),
);
unmount();
// Ended: the manager is gone, its diagnostics say nothing.
expect(participation.debugSnapshot()).toBe("{}");
expect(rtcParticipationManager.debugSnapshot()).toBe("{}");
});
});
@@ -15,12 +15,12 @@ import {
} from "../../matrix-rtc-sdk";
import { ObservableScope } from "../ObservableScope";
import {
CallParticipation,
type CallParticipationOptions,
} from "./CallParticipation";
RtcParticipationManager,
type RtcParticipationManagerOptions,
} from "./RtcParticipationManager";
/**
* A {@link CallParticipation} for the mounted call view: created when the
* A {@link RtcParticipationManager} for the mounted call view: created when the
* drivers or the configuration change, ended (leaving the session if still
* joined) when the view unmounts. Null for the first render, like the other
* scoped objects the views own.
@@ -35,14 +35,13 @@ import {
* nearest error boundary shows it instead of the call silently never
* starting.
*/
export function useCallParticipation(
export function useRtcParticipationManager(
drivers: MatrixDrivers | null,
config: FfiParticipationConfig | null,
options: Omit<CallParticipationOptions, "config"> = {},
): CallParticipation | null {
const [participation, setParticipation] = useState<CallParticipation | null>(
null,
);
options: Omit<RtcParticipationManagerOptions, "config"> = {},
): RtcParticipationManager | null {
const [rtcParticipationManager, setParticipation] =
useState<RtcParticipationManager | null>(null);
const [loadError, setLoadError] = useState<unknown>(null);
const { transportFallbackUrl, slotId } = options;
useEffect(() => {
@@ -58,7 +57,7 @@ export function useCallParticipation(
logger.info(
`[Lifecycle] Creating the call participation for ${clientDriver.roomId} (compat ${config.compat})`,
);
const participation = new CallParticipation(
const rtcParticipationManager = new RtcParticipationManager(
scope,
rtcDriver,
clientDriver.roomId,
@@ -66,7 +65,7 @@ export function useCallParticipation(
clientDriver.deviceId,
{ config, transportFallbackUrl, slotId },
);
setParticipation(participation);
setParticipation(rtcParticipationManager);
},
(e: unknown) => {
if (ended) return;
@@ -82,5 +81,5 @@ export function useCallParticipation(
};
}, [drivers, config, transportFallbackUrl, slotId]);
if (loadError !== null) throw loadError;
return participation;
return rtcParticipationManager;
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { Epoch } from "../state/ObservableScope";
/**
* Hand-made values of the crate's records, for the modules that consume a
* `CallParticipation`'s behaviors without needing the crate itself.
* `RtcParticipationManager`'s behaviors without needing the crate itself.
*/
export function fakeMember(overrides: Partial<FfiMember> = {}): FfiMember {
@@ -81,7 +81,7 @@ export function fakeMediaKey(
}
/**
* The behaviors of a `CallParticipation`, as subjects a test drives by hand.
* The behaviors of a `RtcParticipationManager`, as subjects a test drives by hand.
* Modules take structural slices of the participation, so this stands in for
* it wherever the crate is not what is under test.
*/