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