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 RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
export { export {
type ElementCallMatrixClientDriver, type ElementCallMatrixClientDriver,
type DriverCapabilities, type MatrixClientFeatures,
type RoomInfo, type RoomInfo,
type RoomMemberProfile, type RoomMemberProfile,
type TimelineEvent, type TimelineEvent,
+33 -33
View File
@@ -157,13 +157,13 @@ membership, roster, encryption, impairments} | Leaving`.
| Own profile read/write | not RTC | `ProfileDriver` | | Own profile read/write | not RTC | `ProfileDriver` |
| `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` | | `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 | | 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:** **Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change | | # | 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`). | | 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. | | 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`. | | 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. | | 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. | | 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. | | 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). | | 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. 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 …/> ▼ component/index.tsx <ElementCall rtcDriver clientDriver …/>
┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐ ┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐
│ CallView owns one CallParticipation for lobby → call → ended │ │ CallView owns one RtcParticipationManager for lobby → call → ended │
│ CallParticipation (src/state/rtc/CallParticipation.ts) │ │ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│ │ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │ │ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │ │ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
@@ -231,11 +231,11 @@ export interface ElementCallMatrixClientDriver
readonly deviceId: string; readonly deviceId: string;
/** The room this driver is bound to (one driver per room, as in the crate). */ /** The room this driver is bound to (one driver per room, as in the crate). */
readonly roomId: string; readonly roomId: string;
getCapabilities(): Promise<DriverCapabilities>; getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */ /** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>; getDiagnostics?(): Promise<Record<string, string>>;
} }
export interface DriverCapabilities { export interface MatrixClientFeatures {
stickyEvents: boolean; stickyEvents: boolean;
/** The host's events carry decryption metadata (false on a widget client). */ /** The host's events carry decryption metadata (false on a widget client). */
verifiedEventOrigins: boolean; 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 with a crate-side adapter (matrix-rust-sdk) implements nothing extra for
MatrixRTC. 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`, 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. 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 ```ts
new CallParticipation(scope, driver, { new RtcParticipationManager(scope, driver, {
slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender, slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender,
useKeyDelayMs, transportFallbackUrl?, logger }) useKeyDelayMs, transportFallbackUrl?, logger })
memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1) memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1)
@@ -384,7 +384,7 @@ createCallViewModel$(
| `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` | | `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 | | `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()` | | `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) | | `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 | | `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` | | `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 drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every
`useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell. `useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell.
- `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`. - `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 `LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`. `driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`. - `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`. - `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`.
- `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`; - `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`;
rageshake requests via `subscribeTimeline`. rageshake requests via `subscribeTimeline`.
- `DeveloperSettingsTab`: sticky probe → `getCapabilities()`; custom LiveKit - `DeveloperSettingsTab`: sticky probe → `getMatrixClientFeatures()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`. URL validation → `driver.getLivekitToken(...)`.
- `window.rtcSession` debug handle → `window.matrixRtc = { participation }`. - `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` `org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key`
(alongside `io.element.call.encryption_keys`); events `m.rtc.decline`. (alongside `io.element.call.encryption_keys`); events `m.rtc.decline`.
- **`sdk/main.ts`**: builds the driver from the widget client, a - **`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. - **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each ### 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 reports `Encrypted{ senderDeviceId: content.member.device_id }` for member
events and `Encrypted{ senderDeviceId: content.device_id }` for key events and `Encrypted{ senderDeviceId: content.device_id }` for key
events, i.e. the _claimed_ trust level js-sdk applies today 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. says which.
- cross-signing verdict: `undefined` on a widget client - cross-signing verdict: `undefined` on a widget client
(`crossSigningVerdicts: false`); Element Call then forces (`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 the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })` `./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app 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 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 `Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in 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 13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
the session from the MSC3401 state events alone and requires the legacy the session from the MSC3401 state events alone and requires the legacy
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`, 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 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 `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 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 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, both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore 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 created only when the crate path is selected, so neither path pays for
the other. 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, `encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients,
sink emission and origin synthesis, room-info/member updates. 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`). `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 → memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a `Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave → 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()` - **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its (`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`; 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 when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute 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 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 `matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the 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 `"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a 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 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 Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path. later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns - **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`), `VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`, `InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`, `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 (`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`, 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. switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the - **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads 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`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the 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 `getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request `rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported 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 read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it 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 and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall` ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits; 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 | | ● | | | | | ● | ● | | four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● | | Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | | | 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 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 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 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 timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for 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 S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper. Playwright delegation helper.
**Real-backend check (2026-09-15), `pnpm backend` + `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/CallParticipation.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 two registered users with rust crypto in an encrypted room, `matrix_2_0` and
`compatibility`): passes end to end — transport discovery from `compatibility`): passes end to end — transport discovery from
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`, `/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 **S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a 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 the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component` lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
+5 -1
View File
@@ -37,7 +37,11 @@ export const Header: FC<HeaderProps> = ({
...rest ...rest
}) => { }) => {
return ( return (
<header ref={ref} className={classNames(styles.header, className)} {...rest}> <header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children} {children}
</header> </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. */ /** Removes the listener it was returned for. */
export type Unsubscribe = () => void; 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). */ /** The homeserver accepts sticky events (MSC4354). */
stickyEvents: boolean; stickyEvents: boolean;
/** /**
@@ -145,7 +150,7 @@ export interface MediaDriver {
/** /**
* Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to * 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 * slices its own driver, so a piece of Element Call can ask for no more than
* it needs. * it needs.
*/ */
@@ -156,7 +161,8 @@ export interface ElementCallMatrixClientDriver
readonly deviceId: string; readonly deviceId: string;
/** The room this driver is bound to. */ /** The room this driver is bound to. */
readonly roomId: string; 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. */ /** Free-form facts for a rageshake: crypto version, sync state, and so on. */
getDiagnostics?(): Promise<Record<string, string>>; getDiagnostics?(): Promise<Record<string, string>>;
} }
@@ -82,7 +82,7 @@ describe("MockElementCallMatrixClientDriver", () => {
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"), driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
).resolves.toBeNull(); ).resolves.toBeNull();
expect(driver.roomId).toBe(MOCK_ROOM_ID); expect(driver.roomId).toBe(MOCK_ROOM_ID);
await expect(driver.getCapabilities()).resolves.toMatchObject({ await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
stickyEvents: true, stickyEvents: true,
}); });
}); });
+12 -10
View File
@@ -12,7 +12,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
type DriverCapabilities, type MatrixClientFeatures,
type ElementCallMatrixClientDriver, type ElementCallMatrixClientDriver,
type OwnProfile, type OwnProfile,
type RoomInfo, type RoomInfo,
@@ -42,7 +42,7 @@ export interface MockElementCallMatrixClientDriverOptions {
roomInfo?: Partial<RoomInfo>; roomInfo?: Partial<RoomInfo>;
members?: RoomMemberProfile[]; members?: RoomMemberProfile[];
ownProfile?: Partial<OwnProfile>; ownProfile?: Partial<OwnProfile>;
capabilities?: Partial<DriverCapabilities>; features?: Partial<MatrixClientFeatures>;
} }
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver { export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
@@ -51,7 +51,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
public readonly roomId: string; public readonly roomId: string;
public readonly outbound: ClientCall[] = []; public readonly outbound: ClientCall[] = [];
private capabilities: DriverCapabilities; private features: MatrixClientFeatures;
private roomInfo: RoomInfo; private roomInfo: RoomInfo;
private members: RoomMemberProfile[]; private members: RoomMemberProfile[];
private ownProfile: OwnProfile; private ownProfile: OwnProfile;
@@ -71,11 +71,11 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
this.userId = options.userId ?? MOCK_OWN_USER_ID; this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID; this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID; this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.capabilities = { this.features = {
stickyEvents: true, stickyEvents: true,
verifiedEventOrigins: true, verifiedEventOrigins: true,
crossSigningVerdicts: true, crossSigningVerdicts: true,
...options.capabilities, ...options.features,
}; };
this.roomInfo = { this.roomInfo = {
name: "Test room", name: "Test room",
@@ -194,7 +194,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
}); });
} }
// --- profile, media, capabilities -------------------------------------------- // --- profile, media, features --------------------------------------------
public getOwnProfile(): OwnProfile { public getOwnProfile(): OwnProfile {
return this.ownProfile; return this.ownProfile;
@@ -229,12 +229,14 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien
); );
} }
public async getCapabilities(): Promise<DriverCapabilities> { public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
return Promise.resolve(this.capabilities); return Promise.resolve(this.features);
} }
public setCapabilities(capabilities: Partial<DriverCapabilities>): void { public setMatrixClientFeatures(
this.capabilities = { ...this.capabilities, ...capabilities }; features: Partial<MatrixClientFeatures>,
): void {
this.features = { ...this.features, ...features };
} }
} }
@@ -95,7 +95,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => {
expect.objectContaining({ displayName: "Moi" }), expect.objectContaining({ displayName: "Moi" }),
); );
await expect(driver.getCapabilities()).resolves.toEqual({ await expect(driver.getMatrixClientFeatures()).resolves.toEqual({
stickyEvents: true, stickyEvents: true,
verifiedEventOrigins: true, verifiedEventOrigins: true,
crossSigningVerdicts: true, crossSigningVerdicts: true,
@@ -111,7 +111,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => {
asClient(fakeClient(true)), asClient(fakeClient(true)),
asRoom(fakeRoom()), asRoom(fakeRoom()),
); );
await expect(driver.getCapabilities()).resolves.toMatchObject({ await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
verifiedEventOrigins: false, verifiedEventOrigins: false,
crossSigningVerdicts: 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 * An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
* metadata and members, the room's timeline for reactions and notifications, * 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 * Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
* they differ are marked "widget". * 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 { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
import { import {
type DriverCapabilities, type MatrixClientFeatures,
type ElementCallMatrixClientDriver, type ElementCallMatrixClientDriver,
type OwnProfile, type OwnProfile,
type RoomInfo, type RoomInfo,
@@ -61,7 +61,7 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
private readonly logger: Logger; private readonly logger: Logger;
/** Widget: no crypto backend, no access token, events without metadata. */ /** Widget: no crypto backend, no access token, events without metadata. */
private readonly widget: boolean; private readonly widget: boolean;
private capabilities: Promise<DriverCapabilities> | null = null; private features: Promise<MatrixClientFeatures> | null = null;
public constructor( public constructor(
private readonly client: MatrixClient, private readonly client: MatrixClient,
@@ -302,14 +302,14 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
return URL.createObjectURL(await response.blob()); return URL.createObjectURL(await response.blob());
} }
// --- capabilities and diagnostics ------------------------------------------------ // --- features and diagnostics ------------------------------------------------
public async getCapabilities(): Promise<DriverCapabilities> { public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
this.capabilities ??= this.probeCapabilities(); this.features ??= this.probeMatrixClientFeatures();
return this.capabilities; return this.features;
} }
private async probeCapabilities(): Promise<DriverCapabilities> { private async probeMatrixClientFeatures(): Promise<MatrixClientFeatures> {
const stickyEvents = await this.client const stickyEvents = await this.client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS) .doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
.catch((e: unknown) => { .catch((e: unknown) => {
+14 -12
View File
@@ -23,39 +23,41 @@ interface SetKey {
} }
function attached(): { function attached(): {
participation: FakeParticipation; rtcParticipationManager: FakeParticipation;
setKeys: SetKey[]; setKeys: SetKey[];
} { } {
const participation = new FakeParticipation(); const rtcParticipationManager = new FakeParticipation();
const provider = new ParticipationKeyProvider(); const provider = new ParticipationKeyProvider();
const setKeys: SetKey[] = []; const setKeys: SetKey[] = [];
provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) => provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) =>
setKeys.push({ participantIdentity, keyIndex }), setKeys.push({ participantIdentity, keyIndex }),
); );
provider.attach(testScope(), participation); provider.attach(testScope(), rtcParticipationManager);
return { participation, setKeys }; return { rtcParticipationManager, setKeys };
} }
describe("ParticipationKeyProvider", () => { describe("ParticipationKeyProvider", () => {
it("hands our own key to LiveKit under our transport identity", async () => { it("hands our own key to LiveKit under our transport identity", async () => {
const { participation, setKeys } = attached(); const { rtcParticipationManager, setKeys } = attached();
participation.ownMemberId$.next("m-me"); rtcParticipationManager.ownMemberId$.next("m-me");
participation.ownTransportIdentity$.next("lk-me"); rtcParticipationManager.ownTransportIdentity$.next("lk-me");
participation.keyMap$.next([fakeMediaKey({ memberId: "m-me", index: 0 })]); rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-me", index: 0 }),
]);
await waitFor("own key set", () => setKeys.length === 1); await waitFor("own key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]); expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]);
}); });
it("waits for a peer's transport identity and sets each key once", async () => { 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. // The key arrives before the roster knows the member's identity.
participation.keyMap$.next([ rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }), fakeMediaKey({ memberId: "m-peer", index: 2 }),
]); ]);
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
expect(setKeys).toEqual([]); expect(setKeys).toEqual([]);
participation.setMemberships([ rtcParticipationManager.setMemberships([
fakeMembership({ fakeMembership({
member: { memberId: "m-peer" }, member: { memberId: "m-peer" },
transportIdentity: "lk-peer", transportIdentity: "lk-peer",
@@ -65,7 +67,7 @@ describe("ParticipationKeyProvider", () => {
expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]); expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]);
// The map is re-emitted (a rotation elsewhere): no second delivery. // 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: 2 }),
fakeMediaKey({ memberId: "m-peer", index: 3 }), 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 Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk"; 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 { export interface ParticipationKeys {
/** Every media key in use, ours and theirs, one per (member, index). */ /** Every media key in use, ours and theirs, one per (member, index). */
keyMap$: Behavior<FfiMediaKey[]>; keyMap$: Behavior<FfiMediaKey[]>;
@@ -43,13 +43,13 @@ export class ParticipationKeyProvider extends BaseKeyProvider {
/** Follow the participation's keys for as long as `scope` lives. */ /** Follow the participation's keys for as long as `scope` lives. */
public attach( public attach(
scope: ObservableScope, scope: ObservableScope,
participation: ParticipationKeys, rtcParticipationManager: ParticipationKeys,
): void { ): void {
combineLatest([ combineLatest([
participation.keyMap$, rtcParticipationManager.keyMap$,
participation.memberships$, rtcParticipationManager.memberships$,
participation.ownMemberId$, rtcParticipationManager.ownMemberId$,
participation.ownTransportIdentity$, rtcParticipationManager.ownTransportIdentity$,
]) ])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([keys, memberships, ownMemberId, ownIdentity]) => { .subscribe(([keys, memberships, ownMemberId, ownIdentity]) => {
@@ -44,16 +44,16 @@ function raisedHand(
} }
function setUp(): { function setUp(): {
participation: FakeParticipation; rtcParticipationManager: FakeParticipation;
timeline: MockElementCallMatrixClientDriver; timeline: MockElementCallMatrixClientDriver;
hands: () => Record<string, RaisedHandInfo>; hands: () => Record<string, RaisedHandInfo>;
reactions: () => string[]; reactions: () => string[];
} { } {
const participation = new FakeParticipation(); const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver(); const timeline = new MockElementCallMatrixClientDriver();
const reader = new ParticipationReactionsReader( const reader = new ParticipationReactionsReader(
testScope(), testScope(),
participation, rtcParticipationManager,
timeline, timeline,
); );
let hands: Record<string, RaisedHandInfo> = {}; let hands: Record<string, RaisedHandInfo> = {};
@@ -63,7 +63,7 @@ function setUp(): {
(r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)), (r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)),
); );
return { return {
participation, rtcParticipationManager,
timeline, timeline,
hands: () => hands, hands: () => hands,
reactions: () => reactions, reactions: () => reactions,
@@ -72,8 +72,8 @@ function setUp(): {
describe("ParticipationReactionsReader", () => { describe("ParticipationReactionsReader", () => {
it("raises and lowers a hand with the member's reaction and its redaction", () => { it("raises and lowers a hand with the member's reaction and its redaction", () => {
const { participation, timeline, hands } = setUp(); const { rtcParticipationManager, timeline, hands } = setUp();
participation.setMemberships([alice]); rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline); raisedHand(timeline);
expect(hands()).toEqual({ expect(hands()).toEqual({
[aliceId]: { [aliceId]: {
@@ -94,8 +94,8 @@ describe("ParticipationReactionsReader", () => {
}); });
it("ignores a reaction that does not relate to the sender's own membership", () => { it("ignores a reaction that does not relate to the sender's own membership", () => {
const { participation, timeline, hands } = setUp(); const { rtcParticipationManager, timeline, hands } = setUp();
participation.setMemberships([alice]); rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({ timeline.emitTimelineEvent({
eventId: "$forged", eventId: "$forged",
type: "m.reaction", 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", () => { 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. // The reaction is already in the room when the roster arrives.
raisedHand(timeline); raisedHand(timeline);
expect(hands()).toEqual({}); expect(hands()).toEqual({});
participation.setMemberships([alice]); rtcParticipationManager.setMemberships([alice]);
expect(Object.keys(hands())).toEqual([aliceId]); expect(Object.keys(hands())).toEqual([aliceId]);
participation.setMemberships([]); rtcParticipationManager.setMemberships([]);
expect(hands()).toEqual({}); expect(hands()).toEqual({});
}); });
it("re-resolves a hand when the member re-sends their membership", () => { it("re-resolves a hand when the member re-sends their membership", () => {
const { participation, timeline, hands } = setUp(); const { rtcParticipationManager, timeline, hands } = setUp();
participation.setMemberships([alice]); rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline); raisedHand(timeline);
expect(Object.keys(hands())).toEqual([aliceId]); expect(Object.keys(hands())).toEqual([aliceId]);
// A new membership event without a hand on it: the hand goes. // A new membership event without a hand on it: the hand goes.
const resent = fakeMembership({ const resent = fakeMembership({
member: { ...alice.member, eventId: "$alice-join-2" }, member: { ...alice.member, eventId: "$alice-join-2" },
}); });
participation.setMemberships([resent]); rtcParticipationManager.setMemberships([resent]);
expect(hands()).toEqual({}); expect(hands()).toEqual({});
// Raised again on the new event: back. // Raised again on the new event: back.
raisedHand(timeline, resent, "$hand-2"); raisedHand(timeline, resent, "$hand-2");
@@ -140,8 +140,8 @@ describe("ParticipationReactionsReader", () => {
}); });
it("shows a reaction keyed by the member's media id", () => { it("shows a reaction keyed by the member's media id", () => {
const { participation, timeline, reactions } = setUp(); const { rtcParticipationManager, timeline, reactions } = setUp();
participation.setMemberships([alice]); rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({ timeline.emitTimelineEvent({
eventId: "$reaction", eventId: "$reaction",
type: ElementCallReactionEventType, type: ElementCallReactionEventType,
@@ -29,7 +29,7 @@ const RAISED_HAND_KEY = "🖐️";
const REACTION_EVENT_TYPE = "m.reaction"; const REACTION_EVENT_TYPE = "m.reaction";
const REDACTION_EVENT_TYPE = "m.room.redaction"; 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 { export interface ParticipationReactionsSource {
memberships$: Behavior<Epoch<FfiMembership[]>>; 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 * driver's timeline: the counterpart of {@link ReactionsReader}, which reads
* the same from a matrix-js-sdk session. * the same from a matrix-js-sdk session.
* *
@@ -68,7 +68,7 @@ export class ParticipationReactionsReader {
public constructor( public constructor(
scope: ObservableScope, scope: ObservableScope,
participation: ParticipationReactionsSource, rtcParticipationManager: ParticipationReactionsSource,
private readonly timeline: Pick< private readonly timeline: Pick<
TimelineDriver, TimelineDriver,
"subscribeTimeline" | "getRelatedEvents" "subscribeTimeline" | "getRelatedEvents"
@@ -88,7 +88,7 @@ export class ParticipationReactionsReader {
}); });
scope.onEnd(timeline.subscribeTimeline(this.handleEvent)); scope.onEnd(timeline.subscribeTimeline(this.handleEvent));
participation.memberships$ rtcParticipationManager.memberships$
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe((memberships) => this.onMembershipsChanged(memberships.value)); .subscribe((memberships) => this.onMembershipsChanged(memberships.value));
} }
+9 -5
View File
@@ -461,7 +461,7 @@ describe("the call implementation switch", () => {
createCallView(nullHostBridge); createCallView(nullHostBridge);
await waitFor(() => expect(ActiveCall).toHaveBeenCalled()); await waitFor(() => expect(ActiveCall).toHaveBeenCalled());
expect( expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation, vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).toBeNull(); ).toBeNull();
expect(window.matrixRtc).toBeUndefined(); expect(window.matrixRtc).toBeUndefined();
}); });
@@ -489,12 +489,16 @@ describe("the call implementation switch", () => {
createCallView(nullHostBridge, true, { drivers }); createCallView(nullHostBridge, true, { drivers });
await waitFor(() => await waitFor(() =>
expect( expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation, vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).not.toBeNull(), ).not.toBeNull(),
); );
const { participation } = vi.mocked(ActiveCall).mock.calls.at(-1)![0]; const { rtcParticipationManager } = vi
expect(window.matrixRtc?.participation).toBe(participation); .mocked(ActiveCall)
.mock.calls.at(-1)![0];
expect(window.matrixRtc?.rtcParticipationManager).toBe(
rtcParticipationManager,
);
// The participation is bound to the drivers' room and identity. // 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 { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts"; import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx"; import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { useCallParticipation } from "../state/rtc/useCallParticipation.ts"; import { useRtcParticipationManager } from "../state/rtc/useRtcParticipationManager.ts";
import { type CallParticipation } from "../state/rtc/CallParticipation.ts"; import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { participationConfig } from "../state/rtc/joinParams.ts"; import { participationConfig } from "../state/rtc/joinParams.ts";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts"; import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts";
import { import {
@@ -97,8 +97,8 @@ export const MUTE_PARTICIPANT_COUNT = 8;
declare global { declare global {
interface Window { interface Window {
rtcSession?: MatrixRTCSession; rtcSession?: MatrixRTCSession;
/** The crate's participation, when the Rust implementation carries the call. */ /** The crate's participation manager, when the Rust implementation carries the call. */
matrixRtc?: { participation: CallParticipation }; matrixRtc?: { rtcParticipationManager: RtcParticipationManager };
} }
} }
@@ -202,6 +202,7 @@ const LoadedCallView: FC<LoadedProps> = ({
const [implementation] = useState(() => const [implementation] = useState(() =>
effectiveCallViewModelImplementation(), effectiveCallViewModelImplementation(),
); );
// this is the rustsdkmatrix rtc. So we should call it useRustRtcSdk
const useMatrixRtc = const useMatrixRtc =
implementation === CallViewModelImplementation.MatrixRtc || implementation === CallViewModelImplementation.MatrixRtc ||
rtcSession === undefined; rtcSession === undefined;
@@ -219,12 +220,12 @@ const LoadedCallView: FC<LoadedProps> = ({
}) })
: null, : null,
); );
const participation = useCallParticipation( const rtcParticipationManager = useRtcParticipationManager(
useMatrixRtc ? drivers : null, useMatrixRtc ? drivers : null,
participationConfigValue, participationConfigValue,
); );
const participationMemberships = useBehavior( 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. // The call's members, whichever side lists them; only who they are matters here.
const memberUserIds = useMemo( const memberUserIds = useMemo(
@@ -279,13 +280,15 @@ const LoadedCallView: FC<LoadedProps> = ({
}, [rootElement]); }, [rootElement]);
useEffect(() => { useEffect(() => {
// Storing in the window to access it for the rageshake summary.
if (rtcSession !== undefined) window.rtcSession = rtcSession; if (rtcSession !== undefined) window.rtcSession = rtcSession;
if (participation !== null) window.matrixRtc = { participation }; if (rtcParticipationManager !== null)
window.matrixRtc = { rtcParticipationManager };
return (): void => { return (): void => {
delete window.rtcSession; delete window.rtcSession;
delete window.matrixRtc; delete window.matrixRtc;
}; };
}, [rtcSession, participation]); }, [rtcSession, rtcParticipationManager]);
// TODO move this into the callViewModel LocalMembership.ts // 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? // 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, roomId,
latestMemberUserIds.current.length, latestMemberUserIds.current.length,
sendInstantly, sendInstantly,
participation !== null rtcParticipationManager !== null
? participation.mediaKeyStatistics() ? rtcParticipationManager.mediaKeyStatistics()
: rtcSession === undefined : rtcSession === undefined
? NO_MEDIA_KEY_STATISTICS ? NO_MEDIA_KEY_STATISTICS
: mediaKeyStatisticsOf(rtcSession), : mediaKeyStatisticsOf(rtcSession),
@@ -556,7 +559,7 @@ const LoadedCallView: FC<LoadedProps> = ({
roomId, roomId,
latestMemberUserIds, latestMemberUserIds,
rtcSession, rtcSession,
participation, rtcParticipationManager,
isPasswordlessUser, isPasswordlessUser,
confineToRoom, confineToRoom,
returnToLobby, returnToLobby,
@@ -604,7 +607,7 @@ const LoadedCallView: FC<LoadedProps> = ({
<> <>
{shareModal} {shareModal}
<LobbyView <LobbyView
client={client} developerSettingsClient={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={() => setJoined(true)} onEnter={() => setJoined(true)}
@@ -625,9 +628,9 @@ const LoadedCallView: FC<LoadedProps> = ({
throw externalError; throw externalError;
}; };
body = <ErrorComponent />; 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 // 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; body = null;
} else if (joined) { } else if (joined) {
body = ( body = (
@@ -637,7 +640,7 @@ const LoadedCallView: FC<LoadedProps> = ({
client={client} client={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession} rtcSession={rtcSession}
participation={participation} rtcParticipationManager={rtcParticipationManager}
roomId={roomId} roomId={roomId}
onLeft={onLeft} onLeft={onLeft}
muteStates={muteStates} muteStates={muteStates}
@@ -694,8 +697,8 @@ const LoadedCallView: FC<LoadedProps> = ({
}} }}
onError={(_error) => { onError={(_error) => {
const joinedViaCrate = const joinedViaCrate =
participation !== null && rtcParticipationManager !== null &&
FfiStatus.Connected.instanceOf(participation.status$.value); FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value);
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error"); if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
// If there is an error we need to be dismissible again. This is done in // 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 // `onLeft` as well; we need it here explicitly in case
+2 -2
View File
@@ -253,7 +253,7 @@ describe("ActiveCall", () => {
<ActiveCall <ActiveCall
client={matrixRoom.client} client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()} rtcSession={rtcSession.asMockedSession()}
participation={null} rtcParticipationManager={null}
roomId={matrixRoom.roomId} roomId={matrixRoom.roomId}
muteStates={mockMuteStates()} muteStates={mockMuteStates()}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
@@ -304,7 +304,7 @@ describe("ActiveCall", () => {
<ActiveCall <ActiveCall
client={matrixRoom.client} client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()} rtcSession={rtcSession.asMockedSession()}
participation={null} rtcParticipationManager={null}
roomId={matrixRoom.roomId} roomId={matrixRoom.roomId}
muteStates={mockMuteStates()} muteStates={mockMuteStates()}
matrixInfo={matrixInfo} 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 { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx"; import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.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 { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts"; import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
@@ -115,11 +115,11 @@ export interface ActiveCallProps extends Omit<
> { > {
e2eeSystem: EncryptionSystem; e2eeSystem: EncryptionSystem;
/** /**
* The crate's participation in the session when the Rust implementation * The crate's participation manager for the session when the Rust
* carries this call (see `CallViewModelImplementation`); null when * implementation carries this call (see `CallViewModelImplementation`); null when
* matrix-js-sdk's `rtcSession` does. * matrix-js-sdk's `rtcSession` does.
*/ */
participation: CallParticipation | null; rtcParticipationManager: RtcParticipationManager | null;
// TODO refactor those reasons into an enum // TODO refactor those reasons into an enum
onLeft: ( onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error", 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 // 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. // gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement(); 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 drivers = useOptionalMatrixDrivers();
const { participation, rtcSession, client, roomId } = props; const { rtcParticipationManager, rtcSession, client, roomId } = props;
if (participation !== null && drivers === null) if (rtcParticipationManager !== null && drivers === null)
throw new Error( throw new Error(
"A call over the matrix-rtc crate needs the Matrix drivers to be provided", "A call over the matrix-rtc crate needs the Matrix drivers to be provided",
); );
if ( if (
participation === null && rtcParticipationManager === null &&
(rtcSession === undefined || client === undefined) (rtcSession === undefined || client === undefined)
) )
throw new Error( throw new Error(
@@ -173,18 +173,18 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}; };
let vm: CallViewModel; let vm: CallViewModel;
if (participation !== null && drivers !== null) { if (rtcParticipationManager !== null && drivers !== null) {
rootLogger.info( rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`, `Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
); );
const reactionsReader = new ParticipationReactionsReader( const reactionsReader = new ParticipationReactionsReader(
scope, scope,
participation, rtcParticipationManager,
drivers.clientDriver, drivers.clientDriver,
); );
vm = createCallViewModel$( vm = createCallViewModel$(
scope, scope,
participation, rtcParticipationManager,
drivers.clientDriver, drivers.clientDriver,
mediaDevices, mediaDevices,
props.muteStates, props.muteStates,
@@ -232,7 +232,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices, mediaDevices,
trackProcessorState$, trackProcessorState$,
rootElement, rootElement,
participation, rtcParticipationManager,
drivers, drivers,
]); ]);
@@ -246,10 +246,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// Reactions relate to our current membership event, wherever that lives. // Reactions relate to our current membership event, wherever that lives.
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession); const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
const ownParticipationMembership = useBehavior( const ownParticipationMembership = useBehavior(
participation?.ownMembership$ ?? NO_OWN_MEMBERSHIP, rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
); );
const ownMembershipEventId = const ownMembershipEventId =
participation !== null rtcParticipationManager !== null
? ownParticipationMembership?.member.eventId ? ownParticipationMembership?.member.eventId
: jsSdkMemberships.find( : jsSdkMemberships.find(
(m) => (m) =>
@@ -258,10 +258,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
)?.eventId; )?.eventId;
const reactionsTimeline = useMemo( const reactionsTimeline = useMemo(
() => () =>
participation === null && client !== undefined rtcParticipationManager === null && client !== undefined
? jsSdkReactionsTimeline(client, roomId) ? jsSdkReactionsTimeline(client, roomId)
: drivers!.clientDriver, : drivers!.clientDriver,
[participation, drivers, client, roomId], [rtcParticipationManager, drivers, client, roomId],
); );
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -64,7 +64,7 @@ export const KnockLobbyView: FC<Props> = ({
return ( return (
<LobbyView <LobbyView
client={client} developerSettingsClient={client}
matrixInfo={{ matrixInfo={{
userId: client.getUserId() ?? "", userId: client.getUserId() ?? "",
displayName: profile.displayName, displayName: profile.displayName,
+1 -1
View File
@@ -84,7 +84,7 @@ function renderLobbyView(
const hideHeader = withAppBar ? true : false; const hideHeader = withAppBar ? true : false;
const lobbyView = ( const lobbyView = (
<LobbyView <LobbyView
client={mockClient} developerSettingsClient={mockClient}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={() => {}} onEnter={() => {}}
+3 -3
View File
@@ -54,7 +54,7 @@ import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props { interface Props {
/** The matrix-js-sdk client, for what the developer settings still read from it. */ /** The matrix-js-sdk client, for what the developer settings still read from it. */
client?: MatrixClient; developerSettingsClient?: MatrixClient;
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; muteStates: MuteStates;
onEnter: () => void; onEnter: () => void;
@@ -67,7 +67,7 @@ interface Props {
} }
export const LobbyView: FC<Props> = ({ export const LobbyView: FC<Props> = ({
client, developerSettingsClient,
matrixInfo, matrixInfo,
muteStates, muteStates,
onEnter, onEnter,
@@ -260,7 +260,7 @@ export const LobbyView: FC<Props> = ({
)} )}
</div> </div>
<SettingsModal <SettingsModal
client={client} client={developerSettingsClient}
open={settingsModalOpen} open={settingsModalOpen}
onDismiss={closeSettings} onDismiss={closeSettings}
tab={settingsTab} tab={settingsTab}
+2 -2
View File
@@ -146,8 +146,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
) )
: drivers !== null : drivers !== null
? drivers.clientDriver ? drivers.clientDriver
.getCapabilities() .getMatrixClientFeatures()
.then((capabilities) => capabilities.stickyEvents) .then((features) => features.stickyEvents)
: Promise.resolve(false); : Promise.resolve(false);
probe probe
.then((result) => { .then((result) => {
+7 -3
View File
@@ -221,9 +221,13 @@ export function useSubmitRageshake(
logger.warn("Could not collect the driver's diagnostics", e); logger.warn("Could not collect the driver's diagnostics", e);
} }
} }
const participation = window.matrixRtc?.participation; const rtcParticipationManager =
if (participation) window.matrixRtc?.rtcParticipationManager;
body.append("matrix_rtc_snapshot", participation.debugSnapshot()); if (rtcParticipationManager)
body.append(
"matrix_rtc_snapshot",
rtcParticipationManager.debugSnapshot(),
);
body.append("hostname", window.location.hostname); body.append("hostname", window.location.hostname);
if (client) { 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 * the mock drivers) and mocked LiveKit connections: the Matrix side end to
* end, from the user's join to the roster and back out. * end, from the user's join to the roster and back out.
* *
@@ -40,7 +40,7 @@ import {
} from "../../utils/test"; } from "../../utils/test";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc"; import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { constant } from "../Behavior"; import { constant } from "../Behavior";
import { CallParticipation } from "../rtc/CallParticipation"; import { RtcParticipationManager } from "../rtc/RtcParticipationManager";
import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams"; import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams";
import { type CallViewModel, createCallViewModel$ } from "./CallViewModel"; import { type CallViewModel, createCallViewModel$ } from "./CallViewModel";
@@ -61,7 +61,7 @@ const peer = {
function createEnvironment(driver: MockRtcMatrixDriver): { function createEnvironment(driver: MockRtcMatrixDriver): {
vm: CallViewModel; vm: CallViewModel;
participation: CallParticipation; rtcParticipationManager: RtcParticipationManager;
clientDriver: MockElementCallMatrixClientDriver; clientDriver: MockElementCallMatrixClientDriver;
} { } {
const scope = testScope(); const scope = testScope();
@@ -84,7 +84,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
}, },
], ],
}); });
const participation = new CallParticipation( const rtcParticipationManager = new RtcParticipationManager(
scope, scope,
driver, driver,
driver.roomId, driver.roomId,
@@ -107,7 +107,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
}); });
const vm = createCallViewModel$( const vm = createCallViewModel$(
scope, scope,
participation, rtcParticipationManager,
clientDriver, clientDriver,
mockMediaDevices({}), mockMediaDevices({}),
mockMuteStates(), mockMuteStates(),
@@ -141,10 +141,10 @@ function createEnvironment(driver: MockRtcMatrixDriver): {
new BehaviorSubject<Record<string, ReactionInfo>>({}), new BehaviorSubject<Record<string, ReactionInfo>>({}),
constant({ processor: undefined, supported: false }), 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 () => { beforeAll(async () => {
await initMatrixRtcSdkForTests(); await initMatrixRtcSdkForTests();
}); });
@@ -154,13 +154,13 @@ describe("createCallViewModel$ over a CallParticipation", () => {
const driver = new MockRtcMatrixDriver({ const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })], roomState: [slotEvent({ status: "open" })],
}); });
const { vm, participation } = createEnvironment(driver); const { vm, rtcParticipationManager } = createEnvironment(driver);
expect(vm.participantCount$.value).toBe(0); expect(vm.participantCount$.value).toBe(0);
expect(vm.connected$.value).toBe(false); expect(vm.connected$.value).toBe(false);
vm.join(); vm.join();
await waitFor("the crate to be connected", () => 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 // The crate discovered the transport and minted our token; the view
// model holds a connection to it. // model holds a connection to it.
@@ -201,7 +201,7 @@ describe("createCallViewModel$ over a CallParticipation", () => {
vm.leave(); vm.leave();
await waitFor("the crate to be disconnected", () => await waitFor("the crate to be disconnected", () =>
FfiStatus.Disconnected.instanceOf(participation.status$.value), FfiStatus.Disconnected.instanceOf(rtcParticipationManager.status$.value),
); );
await waitFor( await waitFor(
"our tile to go", "our tile to go",
+17 -17
View File
@@ -168,7 +168,7 @@ import {
} from "../media/RingingMediaViewModel.ts"; } from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts";
import { mapEpoch } from "../ObservableScope.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 { joinParamsFromConfig } from "../rtc/joinParams.ts";
import { type FfiJoinParams } from "../../matrix-rtc-sdk"; import { type FfiJoinParams } from "../../matrix-rtc-sdk";
import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts"; import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts";
@@ -801,7 +801,7 @@ export function createJsClientCallViewModel$(
* *
* {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's * {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's
* `MatrixRTCSession`; {@link createCallViewModel$} from a * `MatrixRTCSession`; {@link createCallViewModel$} from a
* {@link CallParticipation} over the drivers. * {@link RtcParticipationManager} over the drivers.
*/ */
export interface CallViewModelCore { export interface CallViewModelCore {
localMembership: LocalMembership; 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 * (the crate: memberships, connections and their tokens, media keys, our own
* membership) and an {@link ElementCallMatrixClientDriver} (the room's * membership) and an {@link ElementCallMatrixClientDriver} (the room's
* members and metadata, the timeline for notifications). * members and metadata, the timeline for notifications).
@@ -2035,7 +2035,7 @@ function assembleCallViewModel(
*/ */
export function createCallViewModel$( export function createCallViewModel$(
scope: ObservableScope, scope: ObservableScope,
participation: CallParticipation, rtcParticipationManager: RtcParticipationManager,
clientDriver: ElementCallMatrixClientDriver, clientDriver: ElementCallMatrixClientDriver,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
muteStates: MuteStates, muteStates: MuteStates,
@@ -2056,7 +2056,7 @@ export function createCallViewModel$(
const livekitKeyProvider = getParticipationKeyProvider( const livekitKeyProvider = getParticipationKeyProvider(
options.encryptionSystem, options.encryptionSystem,
scope, scope,
participation, rtcParticipationManager,
logger, logger,
); );
@@ -2078,7 +2078,7 @@ export function createCallViewModel$(
const connectionManager = createParticipationConnectionManager$({ const connectionManager = createParticipationConnectionManager$({
scope, scope,
participation, rtcParticipationManager,
connectionFactory, connectionFactory,
ownIdentity: { userId, deviceId }, ownIdentity: { userId, deviceId },
logger, logger,
@@ -2086,7 +2086,7 @@ export function createCallViewModel$(
const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({ const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({
scope, scope,
participation, rtcParticipationManager,
connectionManager, connectionManager,
}); });
@@ -2101,16 +2101,16 @@ export function createCallViewModel$(
// Whether the homeserver takes sticky events decides how a failed first // Whether the homeserver takes sticky events decides how a failed first
// send reads; assume it does until the driver says otherwise. // send reads; assume it does until the driver says otherwise.
let stickyEventsSupported = true; let stickyEventsSupported = true;
clientDriver.getCapabilities().then( clientDriver.getMatrixClientFeatures().then(
(capabilities) => { (features) => {
stickyEventsSupported = capabilities.stickyEvents; 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$({ const localMembership = createParticipationLocalMembership$({
scope, scope,
participation, rtcParticipationManager,
connectionManager, connectionManager,
createPublisherFactory: (connection: Connection) => createPublisherFactory: (connection: Connection) =>
new Publisher( new Publisher(
@@ -2151,7 +2151,7 @@ export function createCallViewModel$(
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> = const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
scope.behavior( scope.behavior(
participation.ownMembership$.pipe( rtcParticipationManager.ownMembership$.pipe(
map((membership) => map((membership) =>
membership === null ? null : callMemberOf(membership), membership === null ? null : callMemberOf(membership),
), ),
@@ -2186,7 +2186,7 @@ export function createCallViewModel$(
clientDriver, clientDriver,
); );
const callMemberUserIds$ = scope.behavior( const callMemberUserIds$ = scope.behavior(
participation.memberships$.pipe( rtcParticipationManager.memberships$.pipe(
mapEpoch((memberships) => mapEpoch((memberships) =>
memberships.map((m) => ({ userId: m.member.userId })), memberships.map((m) => ({ userId: m.member.userId })),
), ),
@@ -2207,7 +2207,7 @@ export function createCallViewModel$(
matrixRoomMembers$, matrixRoomMembers$,
sentCallNotification$: createParticipationSentCallNotification$({ sentCallNotification$: createParticipationSentCallNotification$({
scope, scope,
participation, rtcParticipationManager,
timeline: clientDriver, timeline: clientDriver,
options, options,
logger, logger,
@@ -2242,14 +2242,14 @@ export function createCallViewModel$(
function getParticipationKeyProvider( function getParticipationKeyProvider(
e2eeSystem: EncryptionSystem, e2eeSystem: EncryptionSystem,
scope: ObservableScope, scope: ObservableScope,
participation: CallParticipation, rtcParticipationManager: RtcParticipationManager,
logger: Logger, logger: Logger,
): BaseKeyProvider | undefined { ): BaseKeyProvider | undefined {
if (e2eeSystem.kind === E2eeType.NONE) return undefined; if (e2eeSystem.kind === E2eeType.NONE) return undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) { if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
const keyProvider = new ParticipationKeyProvider(); const keyProvider = new ParticipationKeyProvider();
keyProvider.attach(scope, participation); keyProvider.attach(scope, rtcParticipationManager);
return keyProvider; return keyProvider;
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) { } else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
const keyProvider = new ExternalE2EEKeyProvider(); const keyProvider = new ExternalE2EEKeyProvider();
@@ -30,11 +30,11 @@ const peer = fakeMembership({ member: { memberId: "m-peer" } });
describe("createParticipationSentCallNotification$", () => { describe("createParticipationSentCallNotification$", () => {
it("rings once our membership echoes back, if we were first, and again after a rejoin", async () => { 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 timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({ const sent$ = createParticipationSentCallNotification$({
scope: testScope(), scope: testScope(),
participation, rtcParticipationManager,
timeline, timeline,
options: { sendNotificationType: "ring", callIntent: "video" }, options: { sendNotificationType: "ring", callIntent: "video" },
logger, logger,
@@ -42,8 +42,8 @@ describe("createParticipationSentCallNotification$", () => {
expect(sent$.value).toBeNull(); expect(sent$.value).toBeNull();
// Our echo arrives; the roster has only us. // Our echo arrives; the roster has only us.
participation.setMemberships([own]); rtcParticipationManager.setMemberships([own]);
participation.ownMembership$.next(own); rtcParticipationManager.ownMembership$.next(own);
await waitFor("notification sent", () => sent$.value !== null); await waitFor("notification sent", () => sent$.value !== null);
const [call] = timeline.calls("sendRoomEvent"); const [call] = timeline.calls("sendRoomEvent");
expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE); expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE);
@@ -60,15 +60,15 @@ describe("createParticipationSentCallNotification$", () => {
}); });
// A refresh of our membership is not a join. // A refresh of our membership is not a join.
participation.ownMembership$.next({ ...own }); rtcParticipationManager.ownMembership$.next({ ...own });
expect(timeline.calls("sendRoomEvent")).toHaveLength(1); expect(timeline.calls("sendRoomEvent")).toHaveLength(1);
// We leave and come back alone: the room rings again. // We leave and come back alone: the room rings again.
participation.ownMembership$.next(null); rtcParticipationManager.ownMembership$.next(null);
participation.setMemberships([]); rtcParticipationManager.setMemberships([]);
expect(sent$.value).toBeNull(); expect(sent$.value).toBeNull();
participation.setMemberships([own]); rtcParticipationManager.setMemberships([own]);
participation.ownMembership$.next(own); rtcParticipationManager.ownMembership$.next(own);
await waitFor( await waitFor(
"second notification", "second notification",
() => timeline.calls("sendRoomEvent").length === 2, () => timeline.calls("sendRoomEvent").length === 2,
@@ -76,34 +76,34 @@ describe("createParticipationSentCallNotification$", () => {
}); });
it("does not ring when somebody was in the session before us", async () => { 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 timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({ const sent$ = createParticipationSentCallNotification$({
scope: testScope(), scope: testScope(),
participation, rtcParticipationManager,
timeline, timeline,
options: { sendNotificationType: "ring" }, options: { sendNotificationType: "ring" },
logger, logger,
}); });
participation.setMemberships([peer, own]); rtcParticipationManager.setMemberships([peer, own]);
participation.ownMembership$.next(own); rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]); expect(timeline.calls("sendRoomEvent")).toEqual([]);
expect(sent$.value).toBeNull(); expect(sent$.value).toBeNull();
}); });
it("does nothing without a notification type", async () => { it("does nothing without a notification type", async () => {
const participation = new FakeParticipation(); const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver(); const timeline = new MockElementCallMatrixClientDriver();
createParticipationSentCallNotification$({ createParticipationSentCallNotification$({
scope: testScope(), scope: testScope(),
participation, rtcParticipationManager,
timeline, timeline,
options: {}, options: {},
logger, logger,
}); });
participation.setMemberships([own]); rtcParticipationManager.setMemberships([own]);
participation.ownMembership$.next(own); rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]); 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. */ /** How long a ring is offered for, as matrix-js-sdk has it. */
export const NOTIFICATION_LIFETIME_MS = 90_000; 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 { export interface ParticipationNotificationSource {
ownMembership$: Behavior<FfiMembership | null>; ownMembership$: Behavior<FfiMembership | null>;
memberships$: Behavior<Epoch<FfiMembership[]>>; memberships$: Behavior<Epoch<FfiMembership[]>>;
@@ -43,7 +43,7 @@ export interface ParticipationNotificationSource {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
participation: ParticipationNotificationSource; rtcParticipationManager: ParticipationNotificationSource;
timeline: TimelineDriver; timeline: TimelineDriver;
options: { options: {
/** Whether and what kind of notification to send when joining the call. */ /** Whether and what kind of notification to send when joining the call. */
@@ -64,7 +64,7 @@ interface Props {
*/ */
export function createParticipationSentCallNotification$({ export function createParticipationSentCallNotification$({
scope, scope,
participation, rtcParticipationManager,
timeline, timeline,
options: { sendNotificationType, callIntent }, options: { sendNotificationType, callIntent },
logger: parentLogger, logger: parentLogger,
@@ -73,11 +73,11 @@ export function createParticipationSentCallNotification$({
const sent$ = new BehaviorSubject<CallNotificationWrapper | null>(null); const sent$ = new BehaviorSubject<CallNotificationWrapper | null>(null);
if (sendNotificationType === undefined) return scope.behavior(sent$); if (sendNotificationType === undefined) return scope.behavior(sent$);
participation.ownMembership$ rtcParticipationManager.ownMembership$
.pipe( .pipe(
startWith(null), startWith(null),
pairwise(), pairwise(),
withLatestFrom(participation.memberships$), withLatestFrom(rtcParticipationManager.memberships$),
scope.bind(), scope.bind(),
) )
.subscribe(([[previous, own], memberships]) => { .subscribe(([[previous, own], memberships]) => {
@@ -76,7 +76,7 @@ export {
/** /**
* The crate's view of our membership, for `LocalMemberState.matrix` when the * 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). * `RTCSessionStatus` there).
*/ */
export enum MatrixConnectionStatus { export enum MatrixConnectionStatus {
@@ -34,7 +34,7 @@ import {
type FfiStatus as FfiStatusType, type FfiStatus as FfiStatusType,
type FfiTransportIntent, type FfiTransportIntent,
} from "../../../matrix-rtc-sdk"; } 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 { type DisconnectContext, errorForStatus } from "../../rtc/errors.ts";
import { publishOnLivekit } from "../../rtc/transportIntent.ts"; import { publishOnLivekit } from "../../rtc/transportIntent.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
@@ -52,7 +52,7 @@ import {
} from "./LocalMember.ts"; } from "./LocalMember.ts";
import { type HomeserverDisconnectReason } from "./HomeserverConnected.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 { export interface ParticipationLocalMemberSource {
status$: Behavior<FfiStatusType>; status$: Behavior<FfiStatusType>;
connections$: Behavior<FfiConnectionWithMembers[]>; connections$: Behavior<FfiConnectionWithMembers[]>;
@@ -68,7 +68,7 @@ export interface ParticipationLocalMemberSource {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
participation: ParticipationLocalMemberSource; rtcParticipationManager: ParticipationLocalMemberSource;
connectionManager: IConnectionManager; connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
muteStates: MuteStates; 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 * and keeps alive the membership, discovers the transport and mints its
* token; this joins and leaves when the user asks, publishes our media on * 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 * 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$ = ({ export const createParticipationLocalMembership$ = ({
scope, scope,
participation, rtcParticipationManager,
connectionManager, connectionManager,
createPublisherFactory, createPublisherFactory,
muteStates, muteStates,
@@ -170,8 +170,8 @@ export const createParticipationLocalMembership$ = ({
// The connection we publish on: the one the crate lists our own member on. // The connection we publish on: the one the crate lists our own member on.
const ownServiceUrl$ = scope.behavior( const ownServiceUrl$ = scope.behavior(
combineLatest([ combineLatest([
participation.connections$, rtcParticipationManager.connections$,
participation.ownMemberId$, rtcParticipationManager.ownMemberId$,
]).pipe( ]).pipe(
map( map(
([connections, ownMemberId]) => ([connections, ownMemberId]) =>
@@ -205,7 +205,7 @@ export const createParticipationLocalMembership$ = ({
); );
const matrixConnection$ = scope.behavior( const matrixConnection$ = scope.behavior(
participation.status$.pipe( rtcParticipationManager.status$.pipe(
map(describeStatus), map(describeStatus),
distinctUntilChanged( distinctUntilChanged(
(a, b) => (a, b) =>
@@ -266,7 +266,7 @@ export const createParticipationLocalMembership$ = ({
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(roomId); PosthogAnalytics.instance.eventCallStarted.track(roomId);
try { try {
await participation.join( await rtcParticipationManager.join(
publishOnLivekit(customLivekitUrl || undefined), publishOnLivekit(customLivekitUrl || undefined),
joinParams, joinParams,
slotPolicy$.value, slotPolicy$.value,
@@ -277,7 +277,7 @@ export const createParticipationLocalMembership$ = ({
error instanceof ElementCallError error instanceof ElementCallError
? error ? error
: (errorForStatus( : (errorForStatus(
participation.status$.value, rtcParticipationManager.status$.value,
disconnectContext(), disconnectContext(),
) ?? ) ??
new MembershipManagerError( new MembershipManagerError(
@@ -288,7 +288,7 @@ export const createParticipationLocalMembership$ = ({
return Promise.resolve(async (): Promise<void> => { return Promise.resolve(async (): Promise<void> => {
try { try {
await participation.leave(); await rtcParticipationManager.leave();
} catch (e) { } catch (e) {
logger.error("Error leaving the session", 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 // 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 // manager stopped): while the user still wants to be in the call, that is
// an error to show. // an error to show.
combineLatest([participation.status$, joinAndPublishRequested$]) combineLatest([rtcParticipationManager.status$, joinAndPublishRequested$])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([status, shouldConnect]) => { .subscribe(([status, shouldConnect]) => {
if (!shouldConnect) return; if (!shouldConnect) return;
@@ -398,7 +398,7 @@ export const createParticipationLocalMembership$ = ({
// The call intent follows the camera (C11). Before the join the crate // The call intent follows the camera (C11). Before the join the crate
// refuses, which is expected. // refuses, which is expected.
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => { muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
participation rtcParticipationManager
.updateApplication(videoEnabled ? "video" : "audio") .updateApplication(videoEnabled ? "video" : "audio")
.catch((e) => { .catch((e) => {
logger.debug( logger.debug(
@@ -63,11 +63,11 @@ function recordingFactory(): {
describe("createParticipationConnectionManager$", () => { describe("createParticipationConnectionManager$", () => {
it("opens one connection per service with the crate's token and keeps it across a refresh", () => { it("opens one connection per service with the crate's token and keeps it across a refresh", () => {
const scope = testScope(); const scope = testScope();
const participation = new FakeParticipation(); const rtcParticipationManager = new FakeParticipation();
const { factory, created } = recordingFactory(); const { factory, created } = recordingFactory();
const manager = createParticipationConnectionManager$({ const manager = createParticipationConnectionManager$({
scope, scope,
participation, rtcParticipationManager,
connectionFactory: factory, connectionFactory: factory,
ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" }, ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" },
logger, logger,
@@ -76,7 +76,7 @@ describe("createParticipationConnectionManager$", () => {
[], [],
); );
participation.connections$.next([ rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }), fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }),
]); ]);
expect(created).toEqual([ expect(created).toEqual([
@@ -92,13 +92,13 @@ describe("createParticipationConnectionManager$", () => {
).toBe("https://a"); ).toBe("https://a");
// The crate refreshed the token: the same connection stays up. // The crate refreshed the token: the same connection stays up.
participation.connections$.next([ rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }), fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
]); ]);
expect(created).toHaveLength(1); expect(created).toHaveLength(1);
// A second service appears; the first is untouched. // A second service appears; the first is untouched.
participation.connections$.next([ rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }), fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }), fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]); ]);
@@ -111,7 +111,7 @@ describe("createParticipationConnectionManager$", () => {
).toHaveLength(2); ).toHaveLength(2);
// Everybody left the first service: its connection goes away. // Everybody left the first service: its connection goes away.
participation.connections$.next([ rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }), fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]); ]);
expect( expect(
@@ -18,7 +18,7 @@ import {
type IConnectionManager, type IConnectionManager,
} from "./ConnectionManager"; } from "./ConnectionManager";
/** What this module needs from a {@link CallParticipation}. */ /** What this module needs from a {@link RtcParticipationManager}. */
export interface ParticipationConnectionsSource { export interface ParticipationConnectionsSource {
/** The LiveKit rooms to hold, with a token for each, keyed by service URL. */ /** The LiveKit rooms to hold, with a token for each, keyed by service URL. */
connections$: Behavior<FfiConnectionWithMembers[]>; connections$: Behavior<FfiConnectionWithMembers[]>;
@@ -26,7 +26,7 @@ export interface ParticipationConnectionsSource {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
participation: ParticipationConnectionsSource; rtcParticipationManager: ParticipationConnectionsSource;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
/** Who we publish as. Connections only log it; the tokens come minted. */ /** Who we publish as. Connections only log it; the tokens come minted. */
ownIdentity: { userId: string; deviceId: string }; ownIdentity: { userId: string; deviceId: string };
@@ -42,7 +42,7 @@ interface Props {
*/ */
export function createParticipationConnectionManager$({ export function createParticipationConnectionManager$({
scope, scope,
participation, rtcParticipationManager,
connectionFactory, connectionFactory,
ownIdentity, ownIdentity,
logger: parentLogger, logger: parentLogger,
@@ -50,7 +50,7 @@ export function createParticipationConnectionManager$({
const logger = parentLogger.getChild("[ParticipationConnections]"); const logger = parentLogger.getChild("[ParticipationConnections]");
const connections$ = scope.behavior( const connections$ = scope.behavior(
participation.connections$.pipe( rtcParticipationManager.connections$.pipe(
trackEpoch(), trackEpoch(),
generateItemsWithEpoch( generateItemsWithEpoch(
"ParticipationConnections connections$", "ParticipationConnections connections$",
@@ -56,8 +56,8 @@ describe("callMemberOf", () => {
describe("createParticipationRemoteMembers$", () => { describe("createParticipationRemoteMembers$", () => {
it("lists everyone but us, with their connection and participant", () => { it("lists everyone but us, with their connection and participant", () => {
const scope = testScope(); const scope = testScope();
const participation = new FakeParticipation(); const rtcParticipationManager = new FakeParticipation();
participation.ownMemberId$.next("m-me"); rtcParticipationManager.ownMemberId$.next("m-me");
const connection = new MockConnection( const connection = new MockConnection(
{ {
@@ -84,14 +84,14 @@ describe("createParticipationRemoteMembers$", () => {
const members$ = createParticipationRemoteMembers$({ const members$ = createParticipationRemoteMembers$({
scope, scope,
participation, rtcParticipationManager,
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(data, 1)), connectionManagerData$: constant(new Epoch(data, 1)),
}, },
}); });
expect(members$.value.value).toEqual([]); expect(members$.value.value).toEqual([]);
participation.setMemberships([ rtcParticipationManager.setMemberships([
fakeMembership({ member: { memberId: "m-me", userId: "@me:x" } }), fakeMembership({ member: { memberId: "m-me", userId: "@me:x" } }),
fakeMembership({ fakeMembership({
member: { memberId: "m-peer", userId: "@peer:x" }, member: { memberId: "m-peer", userId: "@peer:x" },
@@ -18,7 +18,7 @@ import {
type RemoteMatrixLivekitMember, type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers"; } from "./MatrixLivekitMembers";
/** What this module needs from a {@link CallParticipation}. */ /** What this module needs from a {@link RtcParticipationManager}. */
export interface ParticipationRoster { export interface ParticipationRoster {
memberships$: Behavior<Epoch<FfiMembership[]>>; memberships$: Behavior<Epoch<FfiMembership[]>>;
ownMemberId$: Behavior<string | null>; ownMemberId$: Behavior<string | null>;
@@ -41,7 +41,7 @@ export function callMemberOf(membership: FfiMembership): CallMember {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
participation: ParticipationRoster; rtcParticipationManager: ParticipationRoster;
connectionManager: IConnectionManager; connectionManager: IConnectionManager;
} }
@@ -52,13 +52,13 @@ interface Props {
*/ */
export function createParticipationRemoteMembers$({ export function createParticipationRemoteMembers$({
scope, scope,
participation, rtcParticipationManager,
connectionManager, connectionManager,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> { }: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
return scope.behavior( return scope.behavior(
combineLatest([ combineLatest([
participation.memberships$, rtcParticipationManager.memberships$,
participation.ownMemberId$, rtcParticipationManager.ownMemberId$,
connectionManager.connectionManagerData$, connectionManager.connectionManagerData$,
]).pipe( ]).pipe(
map( 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 { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { testScope } from "../../utils/test"; import { testScope } from "../../utils/test";
import { ObservableScope } from "../ObservableScope"; import { ObservableScope } from "../ObservableScope";
import { CallParticipation } from "./CallParticipation"; import { RtcParticipationManager } from "./RtcParticipationManager";
import { errorForStatus } from "./errors"; import { errorForStatus } from "./errors";
import { import {
compatForMode, compatForMode,
@@ -55,8 +55,8 @@ function create(
driver: MockRtcMatrixDriver, driver: MockRtcMatrixDriver,
overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {}, overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {},
scope = testScope(), scope = testScope(),
): CallParticipation { ): RtcParticipationManager {
return new CallParticipation( return new RtcParticipationManager(
scope, scope,
driver, driver,
driver.roomId, driver.roomId,
@@ -82,7 +82,7 @@ const peer = {
memberId: "m-peer", memberId: "m-peer",
}; };
describe("CallParticipation", () => { describe("RtcParticipationManager", () => {
beforeAll(async () => { beforeAll(async () => {
await initMatrixRtcSdkForTests(); await initMatrixRtcSdkForTests();
}); });
@@ -90,7 +90,7 @@ describe("CallParticipation", () => {
it("in compatibility mode joins with a legacy state event and no slot", async () => { 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 // a room that never had a slot, as every pre-slot room is
const driver = new MockRtcMatrixDriver(); const driver = new MockRtcMatrixDriver();
const callParticipation = new CallParticipation( const callParticipation = new RtcParticipationManager(
testScope(), testScope(),
driver, driver,
driver.roomId, 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. */ /** How long to wait for the seed, and for our own slot event to echo back. */
const SLOT_WAIT_MS = 15_000; 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 { export interface MediaKeyStatistics {
sent: number; sent: number;
received: number; received: number;
@@ -64,7 +64,7 @@ export interface MediaKeyStatistics {
receivedTotalAge: number; receivedTotalAge: number;
} }
export interface CallParticipationOptions { export interface RtcParticipationManagerOptions {
/** /**
* One manager per `(room, slot)`; Element Call has one slot per room. * One manager per `(room, slot)`; Element Call has one slot per room.
* Defaults to the slot for the config's dialect ({@link slotIdForCompat}). * 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 * Element Call's view of one participation in a MatrixRTC session: the
* crate's `FfiParticipationManager` as behaviors. "Participation" is the * crate's `FfiParticipationManager` as behaviors.
* crate's word for the FFI side; this is the RxJS wrapper a call is built on. * 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 * driver's events, publishes and keeps alive our own membership, mints
* transport tokens and exchanges media keys. This class owns the manager * 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 * 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 * keeps it current from the manager's listener, and ends the participation
* (leaving if still joined) when the scope ends. * (leaving if still joined) when the scope ends.
*/ */
// TODO-RENAME: the call participationmanager wrapper represents the RtcParticipationManager export class RtcParticipationManager {
export class CallParticipation {
private readonly logger: Logger; private readonly logger: Logger;
private readonly matrixDriver: FfiMatrixDriver; private readonly matrixDriver: FfiMatrixDriver;
private readonly manager: FfiParticipationManager; private readonly manager: FfiParticipationManager;
@@ -140,10 +139,10 @@ export class CallParticipation {
roomId: string, roomId: string,
userId: string, userId: string,
deviceId: string, deviceId: string,
options: CallParticipationOptions, options: RtcParticipationManagerOptions,
) { ) {
this.logger = (options.logger ?? rootLogger).getChild( this.logger = (options.logger ?? rootLogger).getChild(
"[CallParticipation]", "[RtcParticipationManager]",
); );
const rtcDriver = const rtcDriver =
options.transportFallbackUrl === undefined options.transportFallbackUrl === undefined
@@ -14,7 +14,7 @@ import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallM
import { MockRtcMatrixDriver } from "../../driver/MockRtcMatrixDriver"; import { MockRtcMatrixDriver } from "../../driver/MockRtcMatrixDriver";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc"; import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { participationConfig } from "./joinParams"; import { participationConfig } from "./joinParams";
import { useCallParticipation } from "./useCallParticipation"; import { useRtcParticipationManager } from "./useRtcParticipationManager";
const config = participationConfig({ const config = participationConfig({
mode: MatrixRTCMode.Matrix_2_0, mode: MatrixRTCMode.Matrix_2_0,
@@ -26,7 +26,7 @@ const config = participationConfig({
}, },
}); });
describe("useCallParticipation", () => { describe("useRtcParticipationManager", () => {
beforeAll(async () => { beforeAll(async () => {
await initMatrixRtcSdkForTests(); await initMatrixRtcSdkForTests();
}); });
@@ -37,7 +37,7 @@ describe("useCallParticipation", () => {
clientDriver: new MockElementCallMatrixClientDriver(), clientDriver: new MockElementCallMatrixClientDriver(),
}; };
const { result, rerender, unmount } = renderHook( const { result, rerender, unmount } = renderHook(
({ drivers }) => useCallParticipation(drivers, config), ({ drivers }) => useRtcParticipationManager(drivers, config),
{ initialProps: { drivers: null as MatrixDrivers | null } }, { initialProps: { drivers: null as MatrixDrivers | null } },
); );
// Nothing without drivers (matrix-js-sdk carries the call). // Nothing without drivers (matrix-js-sdk carries the call).
@@ -45,12 +45,14 @@ describe("useCallParticipation", () => {
rerender({ drivers }); rerender({ drivers });
await waitFor(() => expect(result.current).not.toBeNull()); await waitFor(() => expect(result.current).not.toBeNull());
const participation = result.current!; const rtcParticipationManager = result.current!;
// It seeds the session from the driver right away. // 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(); unmount();
// Ended: the manager is gone, its diagnostics say nothing. // 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"; } from "../../matrix-rtc-sdk";
import { ObservableScope } from "../ObservableScope"; import { ObservableScope } from "../ObservableScope";
import { import {
CallParticipation, RtcParticipationManager,
type CallParticipationOptions, type RtcParticipationManagerOptions,
} from "./CallParticipation"; } 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 * drivers or the configuration change, ended (leaving the session if still
* joined) when the view unmounts. Null for the first render, like the other * joined) when the view unmounts. Null for the first render, like the other
* scoped objects the views own. * scoped objects the views own.
@@ -35,14 +35,13 @@ import {
* nearest error boundary shows it instead of the call silently never * nearest error boundary shows it instead of the call silently never
* starting. * starting.
*/ */
export function useCallParticipation( export function useRtcParticipationManager(
drivers: MatrixDrivers | null, drivers: MatrixDrivers | null,
config: FfiParticipationConfig | null, config: FfiParticipationConfig | null,
options: Omit<CallParticipationOptions, "config"> = {}, options: Omit<RtcParticipationManagerOptions, "config"> = {},
): CallParticipation | null { ): RtcParticipationManager | null {
const [participation, setParticipation] = useState<CallParticipation | null>( const [rtcParticipationManager, setParticipation] =
null, useState<RtcParticipationManager | null>(null);
);
const [loadError, setLoadError] = useState<unknown>(null); const [loadError, setLoadError] = useState<unknown>(null);
const { transportFallbackUrl, slotId } = options; const { transportFallbackUrl, slotId } = options;
useEffect(() => { useEffect(() => {
@@ -58,7 +57,7 @@ export function useCallParticipation(
logger.info( logger.info(
`[Lifecycle] Creating the call participation for ${clientDriver.roomId} (compat ${config.compat})`, `[Lifecycle] Creating the call participation for ${clientDriver.roomId} (compat ${config.compat})`,
); );
const participation = new CallParticipation( const rtcParticipationManager = new RtcParticipationManager(
scope, scope,
rtcDriver, rtcDriver,
clientDriver.roomId, clientDriver.roomId,
@@ -66,7 +65,7 @@ export function useCallParticipation(
clientDriver.deviceId, clientDriver.deviceId,
{ config, transportFallbackUrl, slotId }, { config, transportFallbackUrl, slotId },
); );
setParticipation(participation); setParticipation(rtcParticipationManager);
}, },
(e: unknown) => { (e: unknown) => {
if (ended) return; if (ended) return;
@@ -82,5 +81,5 @@ export function useCallParticipation(
}; };
}, [drivers, config, transportFallbackUrl, slotId]); }, [drivers, config, transportFallbackUrl, slotId]);
if (loadError !== null) throw loadError; 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 * 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 { 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 * Modules take structural slices of the participation, so this stands in for
* it wherever the crate is not what is under test. * it wherever the crate is not what is under test.
*/ */