diff --git a/component/index.tsx b/component/index.tsx index 840a8a176..663b10c21 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -104,7 +104,7 @@ export { supportedLanguages } from "./localization"; export { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver"; export { type ElementCallMatrixClientDriver, - type DriverCapabilities, + type MatrixClientFeatures, type RoomInfo, type RoomMemberProfile, type TimelineEvent, diff --git a/element-call-oxidation-plan.md b/element-call-oxidation-plan.md index 11a19b97e..783a15911 100644 --- a/element-call-oxidation-plan.md +++ b/element-call-oxidation-plan.md @@ -157,13 +157,13 @@ membership, roster, encryption, impairments} | Leaving`. | Own profile read/write | not RTC | `ProfileDriver` | | `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` | | Homeserver sync connectivity | needed by the crate too | `RtcMatrixDriver` (`isHomeserverConnected`, `subscribeConnectivity`, C12); reaches Element Call as `HomeserverUnreachable` in the status | -| Sticky-events support probe | capability probe | `MatrixDriver.getCapabilities()` | +| Sticky-events support probe | capability probe | `MatrixDriver.getMatrixClientFeatures()` | **Must change in the crate (S0a) — each blocks a later slice:** | # | Problem | Change | | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`CallParticipation.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. | +| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`RtcParticipationManager.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. | | C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). | | C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. | | C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option` 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` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. | | C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. | | C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. | -| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `CallParticipation.session$` is fed from it. | +| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `RtcParticipationManager.session$` is fed from it. | | C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). | No crate work is deferred: `update_application` is C11. @@ -193,8 +193,8 @@ host (SPA · widget · sdk · a third-party page) │ ▼ component/index.tsx ┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐ - │ CallView owns one CallParticipation for lobby → call → ended │ - │ CallParticipation (src/state/rtc/CallParticipation.ts) │ + │ CallView owns one RtcParticipationManager for lobby → call → ended │ + │ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │ │ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│ │ memberships$ · connections$ · keyChanges$ · status$ · session$ │ │ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │ @@ -231,11 +231,11 @@ export interface ElementCallMatrixClientDriver readonly deviceId: string; /** The room this driver is bound to (one driver per room, as in the crate). */ readonly roomId: string; - getCapabilities(): Promise; + getMatrixClientFeatures(): Promise; /** Free-form diagnostics for rageshakes (crypto version, sync state, …). */ getDiagnostics?(): Promise>; } -export interface DriverCapabilities { +export interface MatrixClientFeatures { stickyEvents: boolean; /** The host's events carry decryption metadata (false on a widget client). */ verifiedEventOrigins: boolean; @@ -320,16 +320,16 @@ its own driver; the RTC driver is the crate's contract untouched, so a host with a crate-side adapter (matrix-rust-sdk) implements nothing extra for MatrixRTC. -### 4.2 `CallParticipation` (Element Call's RxJS view of the manager) +### 4.2 `RtcParticipationManager` (Element Call's RxJS view of the manager) Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`, -`FfiParticipationConfig`); `CallParticipation` is Element Call's RxJS wrapper +`FfiParticipationConfig`); `RtcParticipationManager` is Element Call's RxJS wrapper over it. -`src/state/rtc/CallParticipation.ts`, a class taking the scope in its constructor: +`src/state/rtc/RtcParticipationManager.ts`, a class taking the scope in its constructor: ```ts -new CallParticipation(scope, driver, { +new RtcParticipationManager(scope, driver, { slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender, useKeyDelayMs, transportFallbackUrl?, logger }) memberships$: Behavior> // Joined only; LeftWithKeys filtered (v1) @@ -384,7 +384,7 @@ createCallViewModel$( | `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` | | `MatrixKeyProvider.setRTCSession` | `MatrixKeyProvider.attach(participation)`: `keyChanges$` × `memberships$` × `ownTransportIdentity$` → `onSetEncryptionKey(material, identity, index)`; keys whose member has no identity yet are held per member id and replayed | | `enterRTCSession` (`joinRTCSession(...)`) | `callParticipation.join(intent, joinParamsFromConfig(...), { encrypted: roomInfo.encrypted, canOpen: roomInfo.canOpenSlot })` in the same `scope.reconcile`: opens the room's slot first when none is open (power level permitting, otherwise `NoOpenSlotError`), then joins; cleanup calls `callParticipation.leave()` | -| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `CallParticipation.homeserverConnected$` from the manager's getter | +| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `RtcParticipationManager.homeserverConnected$` from the manager's getter | | `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) | | `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold | | `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` | @@ -416,7 +416,7 @@ MSC4143: sticky member events, slots, the spec key message), in drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every `useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell. - `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`. - It creates the `CallParticipation` (scope tied to its mount) and hands it to + It creates the `RtcParticipationManager` (scope tied to its mount) and hands it to `LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from `driver.getRoomInfo()` / `driver.getOwnProfile()`. - `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`. @@ -431,7 +431,7 @@ MSC4143: sticky member events, slots, the spec key message), in - `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`. - `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`; rageshake requests via `subscribeTimeline`. -- `DeveloperSettingsTab`: sticky probe → `getCapabilities()`; custom LiveKit +- `DeveloperSettingsTab`: sticky probe → `getMatrixClientFeatures()`; custom LiveKit URL validation → `driver.getLivekitToken(...)`. - `window.rtcSession` debug handle → `window.matrixRtc = { participation }`. @@ -454,7 +454,7 @@ MSC4143: sticky member events, slots, the spec key message), in `org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key` (alongside `io.element.call.encryption_keys`); events `m.rtc.decline`. - **`sdk/main.ts`**: builds the driver from the widget client, a - `CallParticipation`, and waits on `status$` instead of `JoinStateChanged`. + `RtcParticipationManager`, and waits on `status$` instead of `JoinStateChanged`. - **`component/dev` harness**: the two js-sdk drivers per pane. ### 4.6 The js-sdk drivers — two classes, two clients each @@ -480,7 +480,7 @@ Differences from the draft, all required by the widget client: reports `Encrypted{ senderDeviceId: content.member.device_id }` for member events and `Encrypted{ senderDeviceId: content.device_id }` for key events, i.e. the _claimed_ trust level js-sdk applies today - (`ToDeviceKeyTransport.ts:133-140`). `getCapabilities().verifiedEventOrigins` + (`ToDeviceKeyTransport.ts:133-140`). `getMatrixClientFeatures().verifiedEventOrigins` says which. - cross-signing verdict: `undefined` on a widget client (`crossSigningVerdicts: false`); Element Call then forces @@ -525,7 +525,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`. the component build uses `?url&no-inline` plus an `exports` entry for `./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })` lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app - `useCallParticipation` awaits `initMatrixRtcSdk()` before constructing + `useRtcParticipationManager` awaits `initMatrixRtcSdk()` before constructing the participation (so the js-sdk path never fetches it, §5.15), not the `Initializer`; vitest reads the file from disk and only suites that need it call `initMatrixRtcSdk()`, never in @@ -561,7 +561,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`. 13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects the session from the MSC3401 state events alone and requires the legacy slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`, - `slotIdForCompat`); `CallParticipation` picks it from the config and skips + `slotIdForCompat`); `RtcParticipationManager` picks it from the config and skips the slot check and the slot open. Found by the real-backend check: with `m.call#ROOM` the crate saw its own legacy membership as a candidate but never projected it. Element Call's own rooms already let every member send @@ -587,7 +587,7 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`. against the old path in the same session, and that Playwright can run both paths from one build. Everything the js-sdk path needs (the client, the `MatrixRTCSession`, `ReactionsReader` over the session) therefore - stays reachable from `CallView` until S6, and the `CallParticipation` is + stays reachable from `CallView` until S6, and the `RtcParticipationManager` is created only when the crate path is selected, so neither path pays for the other. @@ -642,11 +642,11 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni `encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients, sink emission and origin synthesis, room-info/member updates. -### S2 — `CallParticipation` ☑ +### S2 — `RtcParticipationManager` ☑ -- `src/state/rtc/CallParticipation.ts`, `joinParams.ts`, `transportIntent.ts`, +- `src/state/rtc/RtcParticipationManager.ts`, `joinParams.ts`, `transportIntent.ts`, `errors.ts` (cause → `ElementCallError`). -- `CallParticipation.test.ts` through the real wasm + `MockMatrixDriver`: +- `RtcParticipationManager.test.ts` through the real wasm + `MockMatrixDriver`: memberships follow a remote join/leave; `LeftWithKeys` filtered; join → `Connected`; `connections$` carries the token; `keyChanges$` fires for a peer key; `ownTransportIdentity$` set before the echo; leave → @@ -713,7 +713,7 @@ participation, clientDriver, …)` sits next to it; both build a - **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()` (`src/driver/MatrixDriverContext.tsx`), provided by the component from its props and by `RoomPage` through `useJsSdkDrivers(client, room)`; - `useCallParticipation(drivers, config)` owned by `CallView`, created only + `useRtcParticipationManager(drivers, config)` owned by `CallView`, created only when the crate path is selected; `ActiveCall` takes `participation` and builds either view model; the lobby's member count, the big-call auto-mute and the error boundary's "were we joined" read from whichever side carries @@ -723,7 +723,7 @@ participation, clientDriver, …)` sits next to it; both build a `matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the MatrixRTC mode; `ActiveCall` reads the sampled value and calls either factory — for `"matrix-rtc"` it takes the drivers from the - `MatrixDriverProvider` and the `CallParticipation` from `CallView`, for + `MatrixDriverProvider` and the `RtcParticipationManager` from `CallView`, for `"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen implementation is logged at join and added to the rageshake fields so a report says which path it came from. Playwright gets a helper that sets @@ -732,7 +732,7 @@ participation, clientDriver, …)` sits next to it; both build a Lands first in S4a, before anything else in the tree moves, so that every later S4 change is verifiable against the old path. - **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns - `CallParticipation` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`, + `RtcParticipationManager` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`, `VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`), `InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`, `SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`, @@ -747,7 +747,7 @@ participation, clientDriver, …)` sits next to it; both build a (`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a re-sent membership instead of dropping it blindly. Notifications went through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`, - `useCallParticipation.test.tsx`, three `CallView.test.tsx` cases for the + `useRtcParticipationManager.test.tsx`, three `CallView.test.tsx` cases for the switch. - **S4a views on the drivers (2026-09-16):** `CallView` now requires the drivers (`useMatrixDrivers()`; both hosts provide them) and reads the @@ -771,7 +771,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the `client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the client driver's optional `setDisplayName`/`setAvatar(file | null)` and shows read-only fields without them; `DeveloperSettingsTab` reads the - sticky probe from `getCapabilities()`, the crypto version from + sticky probe from `getMatrixClientFeatures()`, the crypto version from `getDiagnostics()` and validates a custom LiveKit URL through `rtcDriver.getLivekitToken` when there is no client; the rageshake request event goes through the client driver's timeline; reactions are supported @@ -800,7 +800,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the read by `playwright.config.ts` into `use.storageState` (the developer setting in local storage for every context), and the CI Playwright job is a matrix over both implementations (`matrix-rtc` non-blocking until it - has passed once). `CallParticipation.mediaKeyStatistics()` counts sent + has passed once). `RtcParticipationManager.mediaKeyStatistics()` counts sent and received keys and their age from the crate's key changes for the ended-call event. Found on the way: `CallView` rendered `ActiveCall` before the participation existed (wasm loads on first use) — it now waits; @@ -877,7 +877,7 @@ roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the | four builds | | ● | | | | | ● | ● | | Playwright standalone + widget + component | | | | | | | ● | ● | | Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | | -| Real backend (`CallParticipation.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | | +| Real backend (`RtcParticipationManager.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | | --- @@ -908,7 +908,7 @@ Independent review findings incorporated in this revision: slot enforcement blocker (C1); delegation protocol and 1 h arm (C5, §5.9; later redefined as crate-only with arm-after-confirm); `StickyEvents` key type interop (C3, then made moot by removing the mode, C9); MSC4153 default (§5.8); widget-client differences for the js-sdk drivers (§4.6) and missing widget capabilities (§4.5); own identity -export (C6); `CallParticipation` lifetime at `CallView` (§4.2); notification +export (C6); `RtcParticipationManager` lifetime at `CallView` (§4.2); notification timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for @@ -916,8 +916,8 @@ the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b, S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the Playwright delegation helper. -**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/CallParticipation.backend.test.ts`** -(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/CallParticipation.backend.test.ts`; +**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/RtcParticipationManager.backend.test.ts`** +(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts`; two registered users with rust crypto in an encrypted room, `matrix_2_0` and `compatibility`): passes end to end — transport discovery from `/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`, @@ -946,7 +946,7 @@ the S6 deletions. **S4 (2026-09-16):** the implementation switch (§5.15) is in with its config pin, developer control and rageshake field; `CallView` owns a -`CallParticipation` when the crate path is selected and `ActiveCall` builds +`RtcParticipationManager` when the crate path is selected and `ActiveCall` builds the matching view model; the reactions reader and sender have participation and driver counterparts. Both paths run in the same build. Gates: `pnpm lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component` diff --git a/src/Header.tsx b/src/Header.tsx index 1965e5e5d..420837102 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -37,7 +37,11 @@ export const Header: FC = ({ ...rest }) => { return ( -
+
{children}
); diff --git a/src/driver/ElementCallMatrixClientDriver.ts b/src/driver/ElementCallMatrixClientDriver.ts index 7448b9202..4350b8fd6 100644 --- a/src/driver/ElementCallMatrixClientDriver.ts +++ b/src/driver/ElementCallMatrixClientDriver.ts @@ -24,7 +24,12 @@ Please see LICENSE in the repository root for full details. /** Removes the listener it was returned for. */ export type Unsubscribe = () => void; -export interface DriverCapabilities { +/** + * What this client and its homeserver can do, as far as Element Call cares — + * not to be confused with widget capabilities, which are what a host *permits* + * rather than what the stack supports. + */ +export interface MatrixClientFeatures { /** The homeserver accepts sticky events (MSC4354). */ stickyEvents: boolean; /** @@ -145,7 +150,7 @@ export interface MediaDriver { /** * Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to - * one room. One object, sliced into the capabilities above the way the crate + * one room. One object, sliced into the driver interfaces above the way the crate * slices its own driver, so a piece of Element Call can ask for no more than * it needs. */ @@ -156,7 +161,8 @@ export interface ElementCallMatrixClientDriver readonly deviceId: string; /** The room this driver is bound to. */ readonly roomId: string; - getCapabilities(): Promise; + /** What this client and homeserver support; see {@link MatrixClientFeatures}. */ + getMatrixClientFeatures(): Promise; /** Free-form facts for a rageshake: crypto version, sync state, and so on. */ getDiagnostics?(): Promise>; } diff --git a/src/driver/MockElementCallMatrixClientDriver.test.ts b/src/driver/MockElementCallMatrixClientDriver.test.ts index 2cf952c00..f339b9c32 100644 --- a/src/driver/MockElementCallMatrixClientDriver.test.ts +++ b/src/driver/MockElementCallMatrixClientDriver.test.ts @@ -82,7 +82,7 @@ describe("MockElementCallMatrixClientDriver", () => { driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"), ).resolves.toBeNull(); expect(driver.roomId).toBe(MOCK_ROOM_ID); - await expect(driver.getCapabilities()).resolves.toMatchObject({ + await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({ stickyEvents: true, }); }); diff --git a/src/driver/MockElementCallMatrixClientDriver.ts b/src/driver/MockElementCallMatrixClientDriver.ts index e52babd82..2c70b58f2 100644 --- a/src/driver/MockElementCallMatrixClientDriver.ts +++ b/src/driver/MockElementCallMatrixClientDriver.ts @@ -12,7 +12,7 @@ Please see LICENSE in the repository root for full details. */ import { - type DriverCapabilities, + type MatrixClientFeatures, type ElementCallMatrixClientDriver, type OwnProfile, type RoomInfo, @@ -42,7 +42,7 @@ export interface MockElementCallMatrixClientDriverOptions { roomInfo?: Partial; members?: RoomMemberProfile[]; ownProfile?: Partial; - capabilities?: Partial; + features?: Partial; } export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver { @@ -51,7 +51,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien public readonly roomId: string; public readonly outbound: ClientCall[] = []; - private capabilities: DriverCapabilities; + private features: MatrixClientFeatures; private roomInfo: RoomInfo; private members: RoomMemberProfile[]; private ownProfile: OwnProfile; @@ -71,11 +71,11 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien this.userId = options.userId ?? MOCK_OWN_USER_ID; this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID; this.roomId = options.roomId ?? MOCK_ROOM_ID; - this.capabilities = { + this.features = { stickyEvents: true, verifiedEventOrigins: true, crossSigningVerdicts: true, - ...options.capabilities, + ...options.features, }; this.roomInfo = { name: "Test room", @@ -194,7 +194,7 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien }); } - // --- profile, media, capabilities -------------------------------------------- + // --- profile, media, features -------------------------------------------- public getOwnProfile(): OwnProfile { return this.ownProfile; @@ -229,12 +229,14 @@ export class MockElementCallMatrixClientDriver implements ElementCallMatrixClien ); } - public async getCapabilities(): Promise { - return Promise.resolve(this.capabilities); + public async getMatrixClientFeatures(): Promise { + return Promise.resolve(this.features); } - public setCapabilities(capabilities: Partial): void { - this.capabilities = { ...this.capabilities, ...capabilities }; + public setMatrixClientFeatures( + features: Partial, + ): void { + this.features = { ...this.features, ...features }; } } diff --git a/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.test.ts b/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.test.ts index cabb136d8..17b85a403 100644 --- a/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.test.ts +++ b/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.test.ts @@ -95,7 +95,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => { expect.objectContaining({ displayName: "Moi" }), ); - await expect(driver.getCapabilities()).resolves.toEqual({ + await expect(driver.getMatrixClientFeatures()).resolves.toEqual({ stickyEvents: true, verifiedEventOrigins: true, crossSigningVerdicts: true, @@ -111,7 +111,7 @@ describe("JsSdkElementCallMatrixClientDriver", () => { asClient(fakeClient(true)), asRoom(fakeRoom()), ); - await expect(driver.getCapabilities()).resolves.toMatchObject({ + await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({ verifiedEventOrigins: false, crossSigningVerdicts: false, }); diff --git a/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.ts b/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.ts index 6cb615c1d..11d60be9e 100644 --- a/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.ts +++ b/src/driver/jsSdk/JsSdkElementCallMatrixClientDriver.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. /** * An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room * metadata and members, the room's timeline for reactions and notifications, - * the user's own profile, authenticated thumbnails and capability probes. + * the user's own profile, authenticated thumbnails and feature probes. * Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places * they differ are marked "widget". */ @@ -30,7 +30,7 @@ import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot"; import { - type DriverCapabilities, + type MatrixClientFeatures, type ElementCallMatrixClientDriver, type OwnProfile, type RoomInfo, @@ -61,7 +61,7 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie private readonly logger: Logger; /** Widget: no crypto backend, no access token, events without metadata. */ private readonly widget: boolean; - private capabilities: Promise | null = null; + private features: Promise | null = null; public constructor( private readonly client: MatrixClient, @@ -302,14 +302,14 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie return URL.createObjectURL(await response.blob()); } - // --- capabilities and diagnostics ------------------------------------------------ + // --- features and diagnostics ------------------------------------------------ - public async getCapabilities(): Promise { - this.capabilities ??= this.probeCapabilities(); - return this.capabilities; + public async getMatrixClientFeatures(): Promise { + this.features ??= this.probeMatrixClientFeatures(); + return this.features; } - private async probeCapabilities(): Promise { + private async probeMatrixClientFeatures(): Promise { const stickyEvents = await this.client .doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS) .catch((e: unknown) => { diff --git a/src/e2ee/participationKeyProvider.test.ts b/src/e2ee/participationKeyProvider.test.ts index 65ebefe23..2492cfe02 100644 --- a/src/e2ee/participationKeyProvider.test.ts +++ b/src/e2ee/participationKeyProvider.test.ts @@ -23,39 +23,41 @@ interface SetKey { } function attached(): { - participation: FakeParticipation; + rtcParticipationManager: FakeParticipation; setKeys: SetKey[]; } { - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const provider = new ParticipationKeyProvider(); const setKeys: SetKey[] = []; provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) => setKeys.push({ participantIdentity, keyIndex }), ); - provider.attach(testScope(), participation); - return { participation, setKeys }; + provider.attach(testScope(), rtcParticipationManager); + return { rtcParticipationManager, setKeys }; } describe("ParticipationKeyProvider", () => { it("hands our own key to LiveKit under our transport identity", async () => { - const { participation, setKeys } = attached(); - participation.ownMemberId$.next("m-me"); - participation.ownTransportIdentity$.next("lk-me"); - participation.keyMap$.next([fakeMediaKey({ memberId: "m-me", index: 0 })]); + const { rtcParticipationManager, setKeys } = attached(); + rtcParticipationManager.ownMemberId$.next("m-me"); + rtcParticipationManager.ownTransportIdentity$.next("lk-me"); + rtcParticipationManager.keyMap$.next([ + fakeMediaKey({ memberId: "m-me", index: 0 }), + ]); await waitFor("own key set", () => setKeys.length === 1); expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]); }); it("waits for a peer's transport identity and sets each key once", async () => { - const { participation, setKeys } = attached(); + const { rtcParticipationManager, setKeys } = attached(); // The key arrives before the roster knows the member's identity. - participation.keyMap$.next([ + rtcParticipationManager.keyMap$.next([ fakeMediaKey({ memberId: "m-peer", index: 2 }), ]); await new Promise((resolve) => setTimeout(resolve, 20)); expect(setKeys).toEqual([]); - participation.setMemberships([ + rtcParticipationManager.setMemberships([ fakeMembership({ member: { memberId: "m-peer" }, transportIdentity: "lk-peer", @@ -65,7 +67,7 @@ describe("ParticipationKeyProvider", () => { expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]); // The map is re-emitted (a rotation elsewhere): no second delivery. - participation.keyMap$.next([ + rtcParticipationManager.keyMap$.next([ fakeMediaKey({ memberId: "m-peer", index: 2 }), fakeMediaKey({ memberId: "m-peer", index: 3 }), ]); diff --git a/src/e2ee/participationKeyProvider.ts b/src/e2ee/participationKeyProvider.ts index e326132f7..fcfe874fc 100644 --- a/src/e2ee/participationKeyProvider.ts +++ b/src/e2ee/participationKeyProvider.ts @@ -13,7 +13,7 @@ import { type Behavior } from "../state/Behavior"; import { type Epoch, type ObservableScope } from "../state/ObservableScope"; import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk"; -/** What this provider needs from a {@link CallParticipation}. */ +/** What this provider needs from a {@link RtcParticipationManager}. */ export interface ParticipationKeys { /** Every media key in use, ours and theirs, one per (member, index). */ keyMap$: Behavior; @@ -43,13 +43,13 @@ export class ParticipationKeyProvider extends BaseKeyProvider { /** Follow the participation's keys for as long as `scope` lives. */ public attach( scope: ObservableScope, - participation: ParticipationKeys, + rtcParticipationManager: ParticipationKeys, ): void { combineLatest([ - participation.keyMap$, - participation.memberships$, - participation.ownMemberId$, - participation.ownTransportIdentity$, + rtcParticipationManager.keyMap$, + rtcParticipationManager.memberships$, + rtcParticipationManager.ownMemberId$, + rtcParticipationManager.ownTransportIdentity$, ]) .pipe(scope.bind()) .subscribe(([keys, memberships, ownMemberId, ownIdentity]) => { diff --git a/src/reactions/ParticipationReactionsReader.test.ts b/src/reactions/ParticipationReactionsReader.test.ts index ba145c4a3..63a04fbef 100644 --- a/src/reactions/ParticipationReactionsReader.test.ts +++ b/src/reactions/ParticipationReactionsReader.test.ts @@ -44,16 +44,16 @@ function raisedHand( } function setUp(): { - participation: FakeParticipation; + rtcParticipationManager: FakeParticipation; timeline: MockElementCallMatrixClientDriver; hands: () => Record; reactions: () => string[]; } { - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const timeline = new MockElementCallMatrixClientDriver(); const reader = new ParticipationReactionsReader( testScope(), - participation, + rtcParticipationManager, timeline, ); let hands: Record = {}; @@ -63,7 +63,7 @@ function setUp(): { (r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)), ); return { - participation, + rtcParticipationManager, timeline, hands: () => hands, reactions: () => reactions, @@ -72,8 +72,8 @@ function setUp(): { describe("ParticipationReactionsReader", () => { it("raises and lowers a hand with the member's reaction and its redaction", () => { - const { participation, timeline, hands } = setUp(); - participation.setMemberships([alice]); + const { rtcParticipationManager, timeline, hands } = setUp(); + rtcParticipationManager.setMemberships([alice]); raisedHand(timeline); expect(hands()).toEqual({ [aliceId]: { @@ -94,8 +94,8 @@ describe("ParticipationReactionsReader", () => { }); it("ignores a reaction that does not relate to the sender's own membership", () => { - const { participation, timeline, hands } = setUp(); - participation.setMemberships([alice]); + const { rtcParticipationManager, timeline, hands } = setUp(); + rtcParticipationManager.setMemberships([alice]); timeline.emitTimelineEvent({ eventId: "$forged", type: "m.reaction", @@ -113,26 +113,26 @@ describe("ParticipationReactionsReader", () => { }); it("picks up a hand raised before we looked, and drops it when the member leaves", () => { - const { participation, timeline, hands } = setUp(); + const { rtcParticipationManager, timeline, hands } = setUp(); // The reaction is already in the room when the roster arrives. raisedHand(timeline); expect(hands()).toEqual({}); - participation.setMemberships([alice]); + rtcParticipationManager.setMemberships([alice]); expect(Object.keys(hands())).toEqual([aliceId]); - participation.setMemberships([]); + rtcParticipationManager.setMemberships([]); expect(hands()).toEqual({}); }); it("re-resolves a hand when the member re-sends their membership", () => { - const { participation, timeline, hands } = setUp(); - participation.setMemberships([alice]); + const { rtcParticipationManager, timeline, hands } = setUp(); + rtcParticipationManager.setMemberships([alice]); raisedHand(timeline); expect(Object.keys(hands())).toEqual([aliceId]); // A new membership event without a hand on it: the hand goes. const resent = fakeMembership({ member: { ...alice.member, eventId: "$alice-join-2" }, }); - participation.setMemberships([resent]); + rtcParticipationManager.setMemberships([resent]); expect(hands()).toEqual({}); // Raised again on the new event: back. raisedHand(timeline, resent, "$hand-2"); @@ -140,8 +140,8 @@ describe("ParticipationReactionsReader", () => { }); it("shows a reaction keyed by the member's media id", () => { - const { participation, timeline, reactions } = setUp(); - participation.setMemberships([alice]); + const { rtcParticipationManager, timeline, reactions } = setUp(); + rtcParticipationManager.setMemberships([alice]); timeline.emitTimelineEvent({ eventId: "$reaction", type: ElementCallReactionEventType, diff --git a/src/reactions/ParticipationReactionsReader.ts b/src/reactions/ParticipationReactionsReader.ts index 3400ddbb4..4ba524cd5 100644 --- a/src/reactions/ParticipationReactionsReader.ts +++ b/src/reactions/ParticipationReactionsReader.ts @@ -29,7 +29,7 @@ const RAISED_HAND_KEY = "🖐️"; const REACTION_EVENT_TYPE = "m.reaction"; const REDACTION_EVENT_TYPE = "m.room.redaction"; -/** What the reader needs from a {@link CallParticipation}. */ +/** What the reader needs from a {@link RtcParticipationManager}. */ export interface ParticipationReactionsSource { memberships$: Behavior>; } @@ -45,7 +45,7 @@ function relationOf(content: Record): Relation | undefined { } /** - * Raised hands and reactions over a {@link CallParticipation} and the client + * Raised hands and reactions over a {@link RtcParticipationManager} and the client * driver's timeline: the counterpart of {@link ReactionsReader}, which reads * the same from a matrix-js-sdk session. * @@ -68,7 +68,7 @@ export class ParticipationReactionsReader { public constructor( scope: ObservableScope, - participation: ParticipationReactionsSource, + rtcParticipationManager: ParticipationReactionsSource, private readonly timeline: Pick< TimelineDriver, "subscribeTimeline" | "getRelatedEvents" @@ -88,7 +88,7 @@ export class ParticipationReactionsReader { }); scope.onEnd(timeline.subscribeTimeline(this.handleEvent)); - participation.memberships$ + rtcParticipationManager.memberships$ .pipe(scope.bind()) .subscribe((memberships) => this.onMembershipsChanged(memberships.value)); } diff --git a/src/room/CallView.test.tsx b/src/room/CallView.test.tsx index b0ae57d3a..da3cacb5e 100644 --- a/src/room/CallView.test.tsx +++ b/src/room/CallView.test.tsx @@ -461,7 +461,7 @@ describe("the call implementation switch", () => { createCallView(nullHostBridge); await waitFor(() => expect(ActiveCall).toHaveBeenCalled()); expect( - vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation, + vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager, ).toBeNull(); expect(window.matrixRtc).toBeUndefined(); }); @@ -489,12 +489,16 @@ describe("the call implementation switch", () => { createCallView(nullHostBridge, true, { drivers }); await waitFor(() => expect( - vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation, + vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager, ).not.toBeNull(), ); - const { participation } = vi.mocked(ActiveCall).mock.calls.at(-1)![0]; - expect(window.matrixRtc?.participation).toBe(participation); + const { rtcParticipationManager } = vi + .mocked(ActiveCall) + .mock.calls.at(-1)![0]; + expect(window.matrixRtc?.rtcParticipationManager).toBe( + rtcParticipationManager, + ); // The participation is bound to the drivers' room and identity. - expect(participation?.session$.value.roomId).toBe(roomId); + expect(rtcParticipationManager?.session$.value.roomId).toBe(roomId); }); }); diff --git a/src/room/CallView.tsx b/src/room/CallView.tsx index 3860a9b40..7a2673b73 100644 --- a/src/room/CallView.tsx +++ b/src/room/CallView.tsx @@ -74,8 +74,8 @@ import { useHostBridge } from "../HostBridge.ts"; import { useMuteStates } from "../state/useMuteStates.ts"; import { useLeaveToHome } from "../LeaveToHomeContext.ts"; import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx"; -import { useCallParticipation } from "../state/rtc/useCallParticipation.ts"; -import { type CallParticipation } from "../state/rtc/CallParticipation.ts"; +import { useRtcParticipationManager } from "../state/rtc/useRtcParticipationManager.ts"; +import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts"; import { participationConfig } from "../state/rtc/joinParams.ts"; import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts"; import { @@ -97,8 +97,8 @@ export const MUTE_PARTICIPANT_COUNT = 8; declare global { interface Window { rtcSession?: MatrixRTCSession; - /** The crate's participation, when the Rust implementation carries the call. */ - matrixRtc?: { participation: CallParticipation }; + /** The crate's participation manager, when the Rust implementation carries the call. */ + matrixRtc?: { rtcParticipationManager: RtcParticipationManager }; } } @@ -202,6 +202,7 @@ const LoadedCallView: FC = ({ const [implementation] = useState(() => effectiveCallViewModelImplementation(), ); + // this is the rustsdkmatrix rtc. So we should call it useRustRtcSdk const useMatrixRtc = implementation === CallViewModelImplementation.MatrixRtc || rtcSession === undefined; @@ -219,12 +220,12 @@ const LoadedCallView: FC = ({ }) : null, ); - const participation = useCallParticipation( + const rtcParticipationManager = useRtcParticipationManager( useMatrixRtc ? drivers : null, participationConfigValue, ); const participationMemberships = useBehavior( - participation?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS, + rtcParticipationManager?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS, ); // The call's members, whichever side lists them; only who they are matters here. const memberUserIds = useMemo( @@ -279,13 +280,15 @@ const LoadedCallView: FC = ({ }, [rootElement]); useEffect(() => { + // Storing in the window to access it for the rageshake summary. if (rtcSession !== undefined) window.rtcSession = rtcSession; - if (participation !== null) window.matrixRtc = { participation }; + if (rtcParticipationManager !== null) + window.matrixRtc = { rtcParticipationManager }; return (): void => { delete window.rtcSession; delete window.matrixRtc; }; - }, [rtcSession, participation]); + }, [rtcSession, rtcParticipationManager]); // TODO move this into the callViewModel LocalMembership.ts // We might actually not need this at all. Since we get into fatalError on those errors already? @@ -504,8 +507,8 @@ const LoadedCallView: FC = ({ roomId, latestMemberUserIds.current.length, sendInstantly, - participation !== null - ? participation.mediaKeyStatistics() + rtcParticipationManager !== null + ? rtcParticipationManager.mediaKeyStatistics() : rtcSession === undefined ? NO_MEDIA_KEY_STATISTICS : mediaKeyStatisticsOf(rtcSession), @@ -556,7 +559,7 @@ const LoadedCallView: FC = ({ roomId, latestMemberUserIds, rtcSession, - participation, + rtcParticipationManager, isPasswordlessUser, confineToRoom, returnToLobby, @@ -604,7 +607,7 @@ const LoadedCallView: FC = ({ <> {shareModal} setJoined(true)} @@ -625,9 +628,9 @@ const LoadedCallView: FC = ({ throw externalError; }; body = ; - } else if (joined && useMatrixRtc && participation === null) { + } else if (joined && useMatrixRtc && rtcParticipationManager === null) { // Joined before the crate is ready (its wasm loads on first use): the - // call appears with the participation, a render later. + // call appears with the participation manager, a render later. body = null; } else if (joined) { body = ( @@ -637,7 +640,7 @@ const LoadedCallView: FC = ({ client={client} matrixInfo={matrixInfo} rtcSession={rtcSession} - participation={participation} + rtcParticipationManager={rtcParticipationManager} roomId={roomId} onLeft={onLeft} muteStates={muteStates} @@ -694,8 +697,8 @@ const LoadedCallView: FC = ({ }} onError={(_error) => { const joinedViaCrate = - participation !== null && - FfiStatus.Connected.instanceOf(participation.status$.value); + rtcParticipationManager !== null && + FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value); if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error"); // If there is an error we need to be dismissible again. This is done in // `onLeft` as well; we need it here explicitly in case diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 2bfc5ecb5..d5e3567e9 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -253,7 +253,7 @@ describe("ActiveCall", () => { { { e2eeSystem: EncryptionSystem; /** - * The crate's participation in the session when the Rust implementation - * carries this call (see `CallViewModelImplementation`); null when + * The crate's participation manager for the session when the Rust + * implementation carries this call (see `CallViewModelImplementation`); null when * matrix-js-sdk's `rtcSession` does. */ - participation: CallParticipation | null; + rtcParticipationManager: RtcParticipationManager | null; // TODO refactor those reasons into an enum onLeft: ( reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error", @@ -141,15 +141,15 @@ export const ActiveCall: FC = (props) => { // The element we have to draw the call in: the page, or the container a host // gave us. Its size, not the window's, decides how the call is laid out. const rootElement = useRootElement(); - // The drivers, where a host provided them; required with a participation. + // The drivers, where a host provided them; required with a participation manager. const drivers = useOptionalMatrixDrivers(); - const { participation, rtcSession, client, roomId } = props; - if (participation !== null && drivers === null) + const { rtcParticipationManager, rtcSession, client, roomId } = props; + if (rtcParticipationManager !== null && drivers === null) throw new Error( "A call over the matrix-rtc crate needs the Matrix drivers to be provided", ); if ( - participation === null && + rtcParticipationManager === null && (rtcSession === undefined || client === undefined) ) throw new Error( @@ -173,18 +173,18 @@ export const ActiveCall: FC = (props) => { }; let vm: CallViewModel; - if (participation !== null && drivers !== null) { + if (rtcParticipationManager !== null && drivers !== null) { rootLogger.info( `Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`, ); const reactionsReader = new ParticipationReactionsReader( scope, - participation, + rtcParticipationManager, drivers.clientDriver, ); vm = createCallViewModel$( scope, - participation, + rtcParticipationManager, drivers.clientDriver, mediaDevices, props.muteStates, @@ -232,7 +232,7 @@ export const ActiveCall: FC = (props) => { mediaDevices, trackProcessorState$, rootElement, - participation, + rtcParticipationManager, drivers, ]); @@ -246,10 +246,10 @@ export const ActiveCall: FC = (props) => { // Reactions relate to our current membership event, wherever that lives. const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession); const ownParticipationMembership = useBehavior( - participation?.ownMembership$ ?? NO_OWN_MEMBERSHIP, + rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP, ); const ownMembershipEventId = - participation !== null + rtcParticipationManager !== null ? ownParticipationMembership?.member.eventId : jsSdkMemberships.find( (m) => @@ -258,10 +258,10 @@ export const ActiveCall: FC = (props) => { )?.eventId; const reactionsTimeline = useMemo( () => - participation === null && client !== undefined + rtcParticipationManager === null && client !== undefined ? jsSdkReactionsTimeline(client, roomId) : drivers!.clientDriver, - [participation, drivers, client, roomId], + [rtcParticipationManager, drivers, client, roomId], ); useEffect(() => { diff --git a/src/room/KnockLobbyView.tsx b/src/room/KnockLobbyView.tsx index 39643b20c..b80139919 100644 --- a/src/room/KnockLobbyView.tsx +++ b/src/room/KnockLobbyView.tsx @@ -64,7 +64,7 @@ export const KnockLobbyView: FC = ({ return ( {}} diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index b75744c50..ab5d893c2 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -54,7 +54,7 @@ import { useAppBarPrimaryButtonIconKind } from "../AppBar"; interface Props { /** The matrix-js-sdk client, for what the developer settings still read from it. */ - client?: MatrixClient; + developerSettingsClient?: MatrixClient; matrixInfo: MatrixInfo; muteStates: MuteStates; onEnter: () => void; @@ -67,7 +67,7 @@ interface Props { } export const LobbyView: FC = ({ - client, + developerSettingsClient, matrixInfo, muteStates, onEnter, @@ -260,7 +260,7 @@ export const LobbyView: FC = ({ )} = ({ ) : drivers !== null ? drivers.clientDriver - .getCapabilities() - .then((capabilities) => capabilities.stickyEvents) + .getMatrixClientFeatures() + .then((features) => features.stickyEvents) : Promise.resolve(false); probe .then((result) => { diff --git a/src/settings/submit-rageshake.ts b/src/settings/submit-rageshake.ts index a30494667..78fa0875c 100644 --- a/src/settings/submit-rageshake.ts +++ b/src/settings/submit-rageshake.ts @@ -221,9 +221,13 @@ export function useSubmitRageshake( logger.warn("Could not collect the driver's diagnostics", e); } } - const participation = window.matrixRtc?.participation; - if (participation) - body.append("matrix_rtc_snapshot", participation.debugSnapshot()); + const rtcParticipationManager = + window.matrixRtc?.rtcParticipationManager; + if (rtcParticipationManager) + body.append( + "matrix_rtc_snapshot", + rtcParticipationManager.debugSnapshot(), + ); body.append("hostname", window.location.hostname); if (client) { diff --git a/src/state/CallViewModel/CallViewModel.participation.test.ts b/src/state/CallViewModel/CallViewModel.participation.test.ts index 32a8d78f3..5107923b8 100644 --- a/src/state/CallViewModel/CallViewModel.participation.test.ts +++ b/src/state/CallViewModel/CallViewModel.participation.test.ts @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ /** - * `createCallViewModel$` over a real `CallParticipation` (the crate, through + * `createCallViewModel$` over a real `RtcParticipationManager` (the crate, through * the mock drivers) and mocked LiveKit connections: the Matrix side end to * end, from the user's join to the roster and back out. * @@ -40,7 +40,7 @@ import { } from "../../utils/test"; import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc"; import { constant } from "../Behavior"; -import { CallParticipation } from "../rtc/CallParticipation"; +import { RtcParticipationManager } from "../rtc/RtcParticipationManager"; import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams"; import { type CallViewModel, createCallViewModel$ } from "./CallViewModel"; @@ -61,7 +61,7 @@ const peer = { function createEnvironment(driver: MockRtcMatrixDriver): { vm: CallViewModel; - participation: CallParticipation; + rtcParticipationManager: RtcParticipationManager; clientDriver: MockElementCallMatrixClientDriver; } { const scope = testScope(); @@ -84,7 +84,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): { }, ], }); - const participation = new CallParticipation( + const rtcParticipationManager = new RtcParticipationManager( scope, driver, driver.roomId, @@ -107,7 +107,7 @@ function createEnvironment(driver: MockRtcMatrixDriver): { }); const vm = createCallViewModel$( scope, - participation, + rtcParticipationManager, clientDriver, mockMediaDevices({}), mockMuteStates(), @@ -141,10 +141,10 @@ function createEnvironment(driver: MockRtcMatrixDriver): { new BehaviorSubject>({}), constant({ processor: undefined, supported: false }), ); - return { vm, participation, clientDriver }; + return { vm, rtcParticipationManager, clientDriver }; } -describe("createCallViewModel$ over a CallParticipation", () => { +describe("createCallViewModel$ over a RtcParticipationManager", () => { beforeAll(async () => { await initMatrixRtcSdkForTests(); }); @@ -154,13 +154,13 @@ describe("createCallViewModel$ over a CallParticipation", () => { const driver = new MockRtcMatrixDriver({ roomState: [slotEvent({ status: "open" })], }); - const { vm, participation } = createEnvironment(driver); + const { vm, rtcParticipationManager } = createEnvironment(driver); expect(vm.participantCount$.value).toBe(0); expect(vm.connected$.value).toBe(false); vm.join(); await waitFor("the crate to be connected", () => - FfiStatus.Connected.instanceOf(participation.status$.value), + FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value), ); // The crate discovered the transport and minted our token; the view // model holds a connection to it. @@ -201,7 +201,7 @@ describe("createCallViewModel$ over a CallParticipation", () => { vm.leave(); await waitFor("the crate to be disconnected", () => - FfiStatus.Disconnected.instanceOf(participation.status$.value), + FfiStatus.Disconnected.instanceOf(rtcParticipationManager.status$.value), ); await waitFor( "our tile to go", diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 8d5f262b7..5006760fa 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -168,7 +168,7 @@ import { } from "../media/RingingMediaViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts"; import { mapEpoch } from "../ObservableScope.ts"; -import { type CallParticipation } from "../rtc/CallParticipation.ts"; +import { type RtcParticipationManager } from "../rtc/RtcParticipationManager.ts"; import { joinParamsFromConfig } from "../rtc/joinParams.ts"; import { type FfiJoinParams } from "../../matrix-rtc-sdk"; import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts"; @@ -801,7 +801,7 @@ export function createJsClientCallViewModel$( * * {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's * `MatrixRTCSession`; {@link createCallViewModel$} from a - * {@link CallParticipation} over the drivers. + * {@link RtcParticipationManager} over the drivers. */ export interface CallViewModelCore { localMembership: LocalMembership; @@ -2025,7 +2025,7 @@ function assembleCallViewModel( } /** - * The call view model over the host's drivers: a {@link CallParticipation} + * The call view model over the host's drivers: a {@link RtcParticipationManager} * (the crate: memberships, connections and their tokens, media keys, our own * membership) and an {@link ElementCallMatrixClientDriver} (the room's * members and metadata, the timeline for notifications). @@ -2035,7 +2035,7 @@ function assembleCallViewModel( */ export function createCallViewModel$( scope: ObservableScope, - participation: CallParticipation, + rtcParticipationManager: RtcParticipationManager, clientDriver: ElementCallMatrixClientDriver, mediaDevices: MediaDevices, muteStates: MuteStates, @@ -2056,7 +2056,7 @@ export function createCallViewModel$( const livekitKeyProvider = getParticipationKeyProvider( options.encryptionSystem, scope, - participation, + rtcParticipationManager, logger, ); @@ -2078,7 +2078,7 @@ export function createCallViewModel$( const connectionManager = createParticipationConnectionManager$({ scope, - participation, + rtcParticipationManager, connectionFactory, ownIdentity: { userId, deviceId }, logger, @@ -2086,7 +2086,7 @@ export function createCallViewModel$( const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({ scope, - participation, + rtcParticipationManager, connectionManager, }); @@ -2101,16 +2101,16 @@ export function createCallViewModel$( // Whether the homeserver takes sticky events decides how a failed first // send reads; assume it does until the driver says otherwise. let stickyEventsSupported = true; - clientDriver.getCapabilities().then( - (capabilities) => { - stickyEventsSupported = capabilities.stickyEvents; + clientDriver.getMatrixClientFeatures().then( + (features) => { + stickyEventsSupported = features.stickyEvents; }, - (e) => logger.warn("Could not read the driver's capabilities", e), + (e) => logger.warn("Could not read the driver's Matrix client features", e), ); const localMembership = createParticipationLocalMembership$({ scope, - participation, + rtcParticipationManager, connectionManager, createPublisherFactory: (connection: Connection) => new Publisher( @@ -2151,7 +2151,7 @@ export function createCallViewModel$( const localMatrixLivekitMember$: Behavior = scope.behavior( - participation.ownMembership$.pipe( + rtcParticipationManager.ownMembership$.pipe( map((membership) => membership === null ? null : callMemberOf(membership), ), @@ -2186,7 +2186,7 @@ export function createCallViewModel$( clientDriver, ); const callMemberUserIds$ = scope.behavior( - participation.memberships$.pipe( + rtcParticipationManager.memberships$.pipe( mapEpoch((memberships) => memberships.map((m) => ({ userId: m.member.userId })), ), @@ -2207,7 +2207,7 @@ export function createCallViewModel$( matrixRoomMembers$, sentCallNotification$: createParticipationSentCallNotification$({ scope, - participation, + rtcParticipationManager, timeline: clientDriver, options, logger, @@ -2242,14 +2242,14 @@ export function createCallViewModel$( function getParticipationKeyProvider( e2eeSystem: EncryptionSystem, scope: ObservableScope, - participation: CallParticipation, + rtcParticipationManager: RtcParticipationManager, logger: Logger, ): BaseKeyProvider | undefined { if (e2eeSystem.kind === E2eeType.NONE) return undefined; if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) { const keyProvider = new ParticipationKeyProvider(); - keyProvider.attach(scope, participation); + keyProvider.attach(scope, rtcParticipationManager); return keyProvider; } else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) { const keyProvider = new ExternalE2EEKeyProvider(); diff --git a/src/state/CallViewModel/ParticipationCallNotification.test.ts b/src/state/CallViewModel/ParticipationCallNotification.test.ts index 99cbddab2..43a22abb4 100644 --- a/src/state/CallViewModel/ParticipationCallNotification.test.ts +++ b/src/state/CallViewModel/ParticipationCallNotification.test.ts @@ -30,11 +30,11 @@ const peer = fakeMembership({ member: { memberId: "m-peer" } }); describe("createParticipationSentCallNotification$", () => { it("rings once our membership echoes back, if we were first, and again after a rejoin", async () => { - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const timeline = new MockElementCallMatrixClientDriver(); const sent$ = createParticipationSentCallNotification$({ scope: testScope(), - participation, + rtcParticipationManager, timeline, options: { sendNotificationType: "ring", callIntent: "video" }, logger, @@ -42,8 +42,8 @@ describe("createParticipationSentCallNotification$", () => { expect(sent$.value).toBeNull(); // Our echo arrives; the roster has only us. - participation.setMemberships([own]); - participation.ownMembership$.next(own); + rtcParticipationManager.setMemberships([own]); + rtcParticipationManager.ownMembership$.next(own); await waitFor("notification sent", () => sent$.value !== null); const [call] = timeline.calls("sendRoomEvent"); expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE); @@ -60,15 +60,15 @@ describe("createParticipationSentCallNotification$", () => { }); // A refresh of our membership is not a join. - participation.ownMembership$.next({ ...own }); + rtcParticipationManager.ownMembership$.next({ ...own }); expect(timeline.calls("sendRoomEvent")).toHaveLength(1); // We leave and come back alone: the room rings again. - participation.ownMembership$.next(null); - participation.setMemberships([]); + rtcParticipationManager.ownMembership$.next(null); + rtcParticipationManager.setMemberships([]); expect(sent$.value).toBeNull(); - participation.setMemberships([own]); - participation.ownMembership$.next(own); + rtcParticipationManager.setMemberships([own]); + rtcParticipationManager.ownMembership$.next(own); await waitFor( "second notification", () => timeline.calls("sendRoomEvent").length === 2, @@ -76,34 +76,34 @@ describe("createParticipationSentCallNotification$", () => { }); it("does not ring when somebody was in the session before us", async () => { - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const timeline = new MockElementCallMatrixClientDriver(); const sent$ = createParticipationSentCallNotification$({ scope: testScope(), - participation, + rtcParticipationManager, timeline, options: { sendNotificationType: "ring" }, logger, }); - participation.setMemberships([peer, own]); - participation.ownMembership$.next(own); + rtcParticipationManager.setMemberships([peer, own]); + rtcParticipationManager.ownMembership$.next(own); await new Promise((resolve) => setTimeout(resolve, 20)); expect(timeline.calls("sendRoomEvent")).toEqual([]); expect(sent$.value).toBeNull(); }); it("does nothing without a notification type", async () => { - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const timeline = new MockElementCallMatrixClientDriver(); createParticipationSentCallNotification$({ scope: testScope(), - participation, + rtcParticipationManager, timeline, options: {}, logger, }); - participation.setMemberships([own]); - participation.ownMembership$.next(own); + rtcParticipationManager.setMemberships([own]); + rtcParticipationManager.ownMembership$.next(own); await new Promise((resolve) => setTimeout(resolve, 20)); expect(timeline.calls("sendRoomEvent")).toEqual([]); }); diff --git a/src/state/CallViewModel/ParticipationCallNotification.ts b/src/state/CallViewModel/ParticipationCallNotification.ts index 1c101e2b7..0aa700c49 100644 --- a/src/state/CallViewModel/ParticipationCallNotification.ts +++ b/src/state/CallViewModel/ParticipationCallNotification.ts @@ -35,7 +35,7 @@ export const RTC_DECLINE_EVENT_TYPE = "org.matrix.msc4310.rtc.decline"; /** How long a ring is offered for, as matrix-js-sdk has it. */ export const NOTIFICATION_LIFETIME_MS = 90_000; -/** What sending the notification needs from a {@link CallParticipation}. */ +/** What sending the notification needs from a {@link RtcParticipationManager}. */ export interface ParticipationNotificationSource { ownMembership$: Behavior; memberships$: Behavior>; @@ -43,7 +43,7 @@ export interface ParticipationNotificationSource { interface Props { scope: ObservableScope; - participation: ParticipationNotificationSource; + rtcParticipationManager: ParticipationNotificationSource; timeline: TimelineDriver; options: { /** Whether and what kind of notification to send when joining the call. */ @@ -64,7 +64,7 @@ interface Props { */ export function createParticipationSentCallNotification$({ scope, - participation, + rtcParticipationManager, timeline, options: { sendNotificationType, callIntent }, logger: parentLogger, @@ -73,11 +73,11 @@ export function createParticipationSentCallNotification$({ const sent$ = new BehaviorSubject(null); if (sendNotificationType === undefined) return scope.behavior(sent$); - participation.ownMembership$ + rtcParticipationManager.ownMembership$ .pipe( startWith(null), pairwise(), - withLatestFrom(participation.memberships$), + withLatestFrom(rtcParticipationManager.memberships$), scope.bind(), ) .subscribe(([[previous, own], memberships]) => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index b52788609..ca7dcfa10 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -76,7 +76,7 @@ export { /** * The crate's view of our membership, for `LocalMemberState.matrix` when the - * call runs over a `CallParticipation` (matrix-js-sdk reports its own + * call runs over a `RtcParticipationManager` (matrix-js-sdk reports its own * `RTCSessionStatus` there). */ export enum MatrixConnectionStatus { diff --git a/src/state/CallViewModel/localMember/ParticipationLocalMember.ts b/src/state/CallViewModel/localMember/ParticipationLocalMember.ts index 6dff07570..3d5af7e5a 100644 --- a/src/state/CallViewModel/localMember/ParticipationLocalMember.ts +++ b/src/state/CallViewModel/localMember/ParticipationLocalMember.ts @@ -34,7 +34,7 @@ import { type FfiStatus as FfiStatusType, type FfiTransportIntent, } from "../../../matrix-rtc-sdk"; -import { type SlotPolicy } from "../../rtc/CallParticipation.ts"; +import { type SlotPolicy } from "../../rtc/RtcParticipationManager.ts"; import { type DisconnectContext, errorForStatus } from "../../rtc/errors.ts"; import { publishOnLivekit } from "../../rtc/transportIntent.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; @@ -52,7 +52,7 @@ import { } from "./LocalMember.ts"; import { type HomeserverDisconnectReason } from "./HomeserverConnected.ts"; -/** What our own membership needs from a {@link CallParticipation}. */ +/** What our own membership needs from a {@link RtcParticipationManager}. */ export interface ParticipationLocalMemberSource { status$: Behavior; connections$: Behavior; @@ -68,7 +68,7 @@ export interface ParticipationLocalMemberSource { interface Props { scope: ObservableScope; - participation: ParticipationLocalMemberSource; + rtcParticipationManager: ParticipationLocalMemberSource; connectionManager: IConnectionManager; createPublisherFactory: (connection: Connection) => Publisher; muteStates: MuteStates; @@ -143,7 +143,7 @@ function describeStatus(status: FfiStatusType): MatrixConnection { } /** - * Our own membership over a {@link CallParticipation}: the crate publishes + * Our own membership over a {@link RtcParticipationManager}: the crate publishes * and keeps alive the membership, discovers the transport and mints its * token; this joins and leaves when the user asks, publishes our media on * the connection the crate gave us, and projects the crate's status onto the @@ -151,7 +151,7 @@ function describeStatus(status: FfiStatusType): MatrixConnection { */ export const createParticipationLocalMembership$ = ({ scope, - participation, + rtcParticipationManager, connectionManager, createPublisherFactory, muteStates, @@ -170,8 +170,8 @@ export const createParticipationLocalMembership$ = ({ // The connection we publish on: the one the crate lists our own member on. const ownServiceUrl$ = scope.behavior( combineLatest([ - participation.connections$, - participation.ownMemberId$, + rtcParticipationManager.connections$, + rtcParticipationManager.ownMemberId$, ]).pipe( map( ([connections, ownMemberId]) => @@ -205,7 +205,7 @@ export const createParticipationLocalMembership$ = ({ ); const matrixConnection$ = scope.behavior( - participation.status$.pipe( + rtcParticipationManager.status$.pipe( map(describeStatus), distinctUntilChanged( (a, b) => @@ -266,7 +266,7 @@ export const createParticipationLocalMembership$ = ({ PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(roomId); try { - await participation.join( + await rtcParticipationManager.join( publishOnLivekit(customLivekitUrl || undefined), joinParams, slotPolicy$.value, @@ -277,7 +277,7 @@ export const createParticipationLocalMembership$ = ({ error instanceof ElementCallError ? error : (errorForStatus( - participation.status$.value, + rtcParticipationManager.status$.value, disconnectContext(), ) ?? new MembershipManagerError( @@ -288,7 +288,7 @@ export const createParticipationLocalMembership$ = ({ return Promise.resolve(async (): Promise => { try { - await participation.leave(); + await rtcParticipationManager.leave(); } catch (e) { logger.error("Error leaving the session", e); } @@ -299,7 +299,7 @@ export const createParticipationLocalMembership$ = ({ // The crate can end the participation on its own (the slot closed, the // manager stopped): while the user still wants to be in the call, that is // an error to show. - combineLatest([participation.status$, joinAndPublishRequested$]) + combineLatest([rtcParticipationManager.status$, joinAndPublishRequested$]) .pipe(scope.bind()) .subscribe(([status, shouldConnect]) => { if (!shouldConnect) return; @@ -398,7 +398,7 @@ export const createParticipationLocalMembership$ = ({ // The call intent follows the camera (C11). Before the join the crate // refuses, which is expected. muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => { - participation + rtcParticipationManager .updateApplication(videoEnabled ? "video" : "audio") .catch((e) => { logger.debug( diff --git a/src/state/CallViewModel/remoteMembers/ParticipationConnections.test.ts b/src/state/CallViewModel/remoteMembers/ParticipationConnections.test.ts index 2e64438be..51d430191 100644 --- a/src/state/CallViewModel/remoteMembers/ParticipationConnections.test.ts +++ b/src/state/CallViewModel/remoteMembers/ParticipationConnections.test.ts @@ -63,11 +63,11 @@ function recordingFactory(): { describe("createParticipationConnectionManager$", () => { it("opens one connection per service with the crate's token and keeps it across a refresh", () => { const scope = testScope(); - const participation = new FakeParticipation(); + const rtcParticipationManager = new FakeParticipation(); const { factory, created } = recordingFactory(); const manager = createParticipationConnectionManager$({ scope, - participation, + rtcParticipationManager, connectionFactory: factory, ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" }, logger, @@ -76,7 +76,7 @@ describe("createParticipationConnectionManager$", () => { [], ); - participation.connections$.next([ + rtcParticipationManager.connections$.next([ fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }), ]); expect(created).toEqual([ @@ -92,13 +92,13 @@ describe("createParticipationConnectionManager$", () => { ).toBe("https://a"); // The crate refreshed the token: the same connection stays up. - participation.connections$.next([ + rtcParticipationManager.connections$.next([ fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }), ]); expect(created).toHaveLength(1); // A second service appears; the first is untouched. - participation.connections$.next([ + rtcParticipationManager.connections$.next([ fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }), fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }), ]); @@ -111,7 +111,7 @@ describe("createParticipationConnectionManager$", () => { ).toHaveLength(2); // Everybody left the first service: its connection goes away. - participation.connections$.next([ + rtcParticipationManager.connections$.next([ fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }), ]); expect( diff --git a/src/state/CallViewModel/remoteMembers/ParticipationConnections.ts b/src/state/CallViewModel/remoteMembers/ParticipationConnections.ts index 1a3ef8a16..134c35b05 100644 --- a/src/state/CallViewModel/remoteMembers/ParticipationConnections.ts +++ b/src/state/CallViewModel/remoteMembers/ParticipationConnections.ts @@ -18,7 +18,7 @@ import { type IConnectionManager, } from "./ConnectionManager"; -/** What this module needs from a {@link CallParticipation}. */ +/** What this module needs from a {@link RtcParticipationManager}. */ export interface ParticipationConnectionsSource { /** The LiveKit rooms to hold, with a token for each, keyed by service URL. */ connections$: Behavior; @@ -26,7 +26,7 @@ export interface ParticipationConnectionsSource { interface Props { scope: ObservableScope; - participation: ParticipationConnectionsSource; + rtcParticipationManager: ParticipationConnectionsSource; connectionFactory: ConnectionFactory; /** Who we publish as. Connections only log it; the tokens come minted. */ ownIdentity: { userId: string; deviceId: string }; @@ -42,7 +42,7 @@ interface Props { */ export function createParticipationConnectionManager$({ scope, - participation, + rtcParticipationManager, connectionFactory, ownIdentity, logger: parentLogger, @@ -50,7 +50,7 @@ export function createParticipationConnectionManager$({ const logger = parentLogger.getChild("[ParticipationConnections]"); const connections$ = scope.behavior( - participation.connections$.pipe( + rtcParticipationManager.connections$.pipe( trackEpoch(), generateItemsWithEpoch( "ParticipationConnections connections$", diff --git a/src/state/CallViewModel/remoteMembers/ParticipationMembers.test.ts b/src/state/CallViewModel/remoteMembers/ParticipationMembers.test.ts index 110c5f81f..b5ca81086 100644 --- a/src/state/CallViewModel/remoteMembers/ParticipationMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/ParticipationMembers.test.ts @@ -56,8 +56,8 @@ describe("callMemberOf", () => { describe("createParticipationRemoteMembers$", () => { it("lists everyone but us, with their connection and participant", () => { const scope = testScope(); - const participation = new FakeParticipation(); - participation.ownMemberId$.next("m-me"); + const rtcParticipationManager = new FakeParticipation(); + rtcParticipationManager.ownMemberId$.next("m-me"); const connection = new MockConnection( { @@ -84,14 +84,14 @@ describe("createParticipationRemoteMembers$", () => { const members$ = createParticipationRemoteMembers$({ scope, - participation, + rtcParticipationManager, connectionManager: { connectionManagerData$: constant(new Epoch(data, 1)), }, }); expect(members$.value.value).toEqual([]); - participation.setMemberships([ + rtcParticipationManager.setMemberships([ fakeMembership({ member: { memberId: "m-me", userId: "@me:x" } }), fakeMembership({ member: { memberId: "m-peer", userId: "@peer:x" }, diff --git a/src/state/CallViewModel/remoteMembers/ParticipationMembers.ts b/src/state/CallViewModel/remoteMembers/ParticipationMembers.ts index ac6d6d92b..7b59e52bb 100644 --- a/src/state/CallViewModel/remoteMembers/ParticipationMembers.ts +++ b/src/state/CallViewModel/remoteMembers/ParticipationMembers.ts @@ -18,7 +18,7 @@ import { type RemoteMatrixLivekitMember, } from "./MatrixLivekitMembers"; -/** What this module needs from a {@link CallParticipation}. */ +/** What this module needs from a {@link RtcParticipationManager}. */ export interface ParticipationRoster { memberships$: Behavior>; ownMemberId$: Behavior; @@ -41,7 +41,7 @@ export function callMemberOf(membership: FfiMembership): CallMember { interface Props { scope: ObservableScope; - participation: ParticipationRoster; + rtcParticipationManager: ParticipationRoster; connectionManager: IConnectionManager; } @@ -52,13 +52,13 @@ interface Props { */ export function createParticipationRemoteMembers$({ scope, - participation, + rtcParticipationManager, connectionManager, }: Props): Behavior> { return scope.behavior( combineLatest([ - participation.memberships$, - participation.ownMemberId$, + rtcParticipationManager.memberships$, + rtcParticipationManager.ownMemberId$, connectionManager.connectionManagerData$, ]).pipe( map( diff --git a/src/state/rtc/CallParticipation.backend.test.ts b/src/state/rtc/CallParticipation.backend.test.ts deleted file mode 100644 index 2e0bb2c56..000000000 --- a/src/state/rtc/CallParticipation.backend.test.ts +++ /dev/null @@ -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; -} - -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 { - const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor( - Math.random() * 0xffff, - ).toString(16)}`; - const register = async (): Promise => - 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((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 { - 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> { - return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record< - string, - unknown - >; -} - -function connected( - participation: CallParticipation, -): InstanceType["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).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, - ); -}); diff --git a/src/state/rtc/RtcParticipationManager.backend.test.ts b/src/state/rtc/RtcParticipationManager.backend.test.ts new file mode 100644 index 000000000..4f7cbf5b8 --- /dev/null +++ b/src/state/rtc/RtcParticipationManager.backend.test.ts @@ -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; +} + +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 { + const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor( + Math.random() * 0xffff, + ).toString(16)}`; + const register = async (): Promise => + 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((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 { + 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> { + return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record< + string, + unknown + >; +} + +function connected( + rtcParticipationManager: RtcParticipationManager, +): InstanceType["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).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, + ); + }, +); diff --git a/src/state/rtc/CallParticipation.test.ts b/src/state/rtc/RtcParticipationManager.test.ts similarity index 98% rename from src/state/rtc/CallParticipation.test.ts rename to src/state/rtc/RtcParticipationManager.test.ts index 3ed7cb0dd..083cb7b75 100644 --- a/src/state/rtc/CallParticipation.test.ts +++ b/src/state/rtc/RtcParticipationManager.test.ts @@ -25,7 +25,7 @@ import { import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc"; import { testScope } from "../../utils/test"; import { ObservableScope } from "../ObservableScope"; -import { CallParticipation } from "./CallParticipation"; +import { RtcParticipationManager } from "./RtcParticipationManager"; import { errorForStatus } from "./errors"; import { compatForMode, @@ -55,8 +55,8 @@ function create( driver: MockRtcMatrixDriver, overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {}, scope = testScope(), -): CallParticipation { - return new CallParticipation( +): RtcParticipationManager { + return new RtcParticipationManager( scope, driver, driver.roomId, @@ -82,7 +82,7 @@ const peer = { memberId: "m-peer", }; -describe("CallParticipation", () => { +describe("RtcParticipationManager", () => { beforeAll(async () => { await initMatrixRtcSdkForTests(); }); @@ -90,7 +90,7 @@ describe("CallParticipation", () => { it("in compatibility mode joins with a legacy state event and no slot", async () => { // a room that never had a slot, as every pre-slot room is const driver = new MockRtcMatrixDriver(); - const callParticipation = new CallParticipation( + const callParticipation = new RtcParticipationManager( testScope(), driver, driver.roomId, diff --git a/src/state/rtc/CallParticipation.ts b/src/state/rtc/RtcParticipationManager.ts similarity index 96% rename from src/state/rtc/CallParticipation.ts rename to src/state/rtc/RtcParticipationManager.ts index 71be40dc6..ee789067c 100644 --- a/src/state/rtc/CallParticipation.ts +++ b/src/state/rtc/RtcParticipationManager.ts @@ -56,7 +56,7 @@ export interface SlotPolicy { /** How long to wait for the seed, and for our own slot event to echo back. */ const SLOT_WAIT_MS = 15_000; -/** Media keys sent and received over a participation; see {@link CallParticipation.mediaKeyStatistics}. */ +/** Media keys sent and received over a participation; see {@link RtcParticipationManager.mediaKeyStatistics}. */ export interface MediaKeyStatistics { sent: number; received: number; @@ -64,7 +64,7 @@ export interface MediaKeyStatistics { receivedTotalAge: number; } -export interface CallParticipationOptions { +export interface RtcParticipationManagerOptions { /** * One manager per `(room, slot)`; Element Call has one slot per room. * Defaults to the slot for the config's dialect ({@link slotIdForCompat}). @@ -82,18 +82,17 @@ export interface CallParticipationOptions { /** * Element Call's view of one participation in a MatrixRTC session: the - * crate's `FfiParticipationManager` as behaviors. "Participation" is the - * crate's word for the FFI side; this is the RxJS wrapper a call is built on. + * crate's `FfiParticipationManager` as behaviors. + * This is the RxJS wrapper a call is built on. * - * The crate does everything Matrix: it projects the session from the + * The crates ParticipationManager does everything Matrix: it projects the session from the * driver's events, publishes and keeps alive our own membership, mints * transport tokens and exchanges media keys. This class owns the manager * for the scope's lifetime, seeds each behavior from the manager's getter and * keeps it current from the manager's listener, and ends the participation * (leaving if still joined) when the scope ends. */ - // TODO-RENAME: the call participationmanager wrapper represents the RtcParticipationManager -export class CallParticipation { +export class RtcParticipationManager { private readonly logger: Logger; private readonly matrixDriver: FfiMatrixDriver; private readonly manager: FfiParticipationManager; @@ -140,10 +139,10 @@ export class CallParticipation { roomId: string, userId: string, deviceId: string, - options: CallParticipationOptions, + options: RtcParticipationManagerOptions, ) { this.logger = (options.logger ?? rootLogger).getChild( - "[CallParticipation]", + "[RtcParticipationManager]", ); const rtcDriver = options.transportFallbackUrl === undefined diff --git a/src/state/rtc/useCallParticipation.test.tsx b/src/state/rtc/useRtcParticipationManager.test.tsx similarity index 81% rename from src/state/rtc/useCallParticipation.test.tsx rename to src/state/rtc/useRtcParticipationManager.test.tsx index 2924623cc..7834cd218 100644 --- a/src/state/rtc/useCallParticipation.test.tsx +++ b/src/state/rtc/useRtcParticipationManager.test.tsx @@ -14,7 +14,7 @@ import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallM import { MockRtcMatrixDriver } from "../../driver/MockRtcMatrixDriver"; import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc"; import { participationConfig } from "./joinParams"; -import { useCallParticipation } from "./useCallParticipation"; +import { useRtcParticipationManager } from "./useRtcParticipationManager"; const config = participationConfig({ mode: MatrixRTCMode.Matrix_2_0, @@ -26,7 +26,7 @@ const config = participationConfig({ }, }); -describe("useCallParticipation", () => { +describe("useRtcParticipationManager", () => { beforeAll(async () => { await initMatrixRtcSdkForTests(); }); @@ -37,7 +37,7 @@ describe("useCallParticipation", () => { clientDriver: new MockElementCallMatrixClientDriver(), }; const { result, rerender, unmount } = renderHook( - ({ drivers }) => useCallParticipation(drivers, config), + ({ drivers }) => useRtcParticipationManager(drivers, config), { initialProps: { drivers: null as MatrixDrivers | null } }, ); // Nothing without drivers (matrix-js-sdk carries the call). @@ -45,12 +45,14 @@ describe("useCallParticipation", () => { rerender({ drivers }); await waitFor(() => expect(result.current).not.toBeNull()); - const participation = result.current!; + const rtcParticipationManager = result.current!; // It seeds the session from the driver right away. - await waitFor(() => expect(participation.session$.value.seeded).toBe(true)); + await waitFor(() => + expect(rtcParticipationManager.session$.value.seeded).toBe(true), + ); unmount(); // Ended: the manager is gone, its diagnostics say nothing. - expect(participation.debugSnapshot()).toBe("{}"); + expect(rtcParticipationManager.debugSnapshot()).toBe("{}"); }); }); diff --git a/src/state/rtc/useCallParticipation.ts b/src/state/rtc/useRtcParticipationManager.ts similarity index 81% rename from src/state/rtc/useCallParticipation.ts rename to src/state/rtc/useRtcParticipationManager.ts index e3d11b719..88c7e8b88 100644 --- a/src/state/rtc/useCallParticipation.ts +++ b/src/state/rtc/useRtcParticipationManager.ts @@ -15,12 +15,12 @@ import { } from "../../matrix-rtc-sdk"; import { ObservableScope } from "../ObservableScope"; import { - CallParticipation, - type CallParticipationOptions, -} from "./CallParticipation"; + RtcParticipationManager, + type RtcParticipationManagerOptions, +} from "./RtcParticipationManager"; /** - * A {@link CallParticipation} for the mounted call view: created when the + * A {@link RtcParticipationManager} for the mounted call view: created when the * drivers or the configuration change, ended (leaving the session if still * joined) when the view unmounts. Null for the first render, like the other * scoped objects the views own. @@ -35,14 +35,13 @@ import { * nearest error boundary shows it instead of the call silently never * starting. */ -export function useCallParticipation( +export function useRtcParticipationManager( drivers: MatrixDrivers | null, config: FfiParticipationConfig | null, - options: Omit = {}, -): CallParticipation | null { - const [participation, setParticipation] = useState( - null, - ); + options: Omit = {}, +): RtcParticipationManager | null { + const [rtcParticipationManager, setParticipation] = + useState(null); const [loadError, setLoadError] = useState(null); const { transportFallbackUrl, slotId } = options; useEffect(() => { @@ -58,7 +57,7 @@ export function useCallParticipation( logger.info( `[Lifecycle] Creating the call participation for ${clientDriver.roomId} (compat ${config.compat})`, ); - const participation = new CallParticipation( + const rtcParticipationManager = new RtcParticipationManager( scope, rtcDriver, clientDriver.roomId, @@ -66,7 +65,7 @@ export function useCallParticipation( clientDriver.deviceId, { config, transportFallbackUrl, slotId }, ); - setParticipation(participation); + setParticipation(rtcParticipationManager); }, (e: unknown) => { if (ended) return; @@ -82,5 +81,5 @@ export function useCallParticipation( }; }, [drivers, config, transportFallbackUrl, slotId]); if (loadError !== null) throw loadError; - return participation; + return rtcParticipationManager; } diff --git a/src/utils/test-participation.ts b/src/utils/test-participation.ts index c954d7f7b..4fc1d7361 100644 --- a/src/utils/test-participation.ts +++ b/src/utils/test-participation.ts @@ -19,7 +19,7 @@ import { Epoch } from "../state/ObservableScope"; /** * Hand-made values of the crate's records, for the modules that consume a - * `CallParticipation`'s behaviors without needing the crate itself. + * `RtcParticipationManager`'s behaviors without needing the crate itself. */ export function fakeMember(overrides: Partial = {}): FfiMember { @@ -81,7 +81,7 @@ export function fakeMediaKey( } /** - * The behaviors of a `CallParticipation`, as subjects a test drives by hand. + * The behaviors of a `RtcParticipationManager`, as subjects a test drives by hand. * Modules take structural slices of the participation, so this stands in for * it wherever the crate is not what is under test. */