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