plan oxidation

- add the drivers
 - add the uniffi build toolchain
 - create the plan document
This commit is contained in:
Timo K.
2026-09-15 19:06:04 +02:00
parent fe91162869
commit 36ed60d95c
38 changed files with 17213 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
# uniffi-generated MatrixRTC SDK bindings, vendored by scripts/sync-matrix-rtc-sdk.sh
src/matrix-rtc-sdk/generated/** linguist-generated=true
src/matrix-rtc-sdk/generated/**/*.wasm binary
+6 -1
View File
@@ -2,5 +2,10 @@
"$schema": "./node_modules/oxfmt/configuration_schema.json", "$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80, "printWidth": 80,
"sortPackageJson": false, "sortPackageJson": false,
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"] "ignorePatterns": [
"pnpm-lock.yaml",
"node_modules",
"dist",
"src/matrix-rtc-sdk/generated"
]
} }
+3
View File
@@ -28,6 +28,9 @@
"env": { "env": {
"builtin": true "builtin": true
}, },
// uniffi-generated bindings and wasm-bindgen glue, vendored by
// scripts/sync-matrix-rtc-sdk.sh; not ours to lint
"ignorePatterns": ["src/matrix-rtc-sdk/generated/**"],
"rules": { "rules": {
"element-call/copyright-header": [ "element-call/copyright-header": [
"error", "error",
+730
View File
@@ -0,0 +1,730 @@
# Element Call oxidation plan
Move Element Call's MatrixRTC participation logic off `matrix-js-sdk`'s
`MatrixRTCSession` and onto the Rust `matrix-rtc` crate
(`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`), consumed through its
uniffi wasm bindings. The Element Call **component** then depends on one
host-supplied object, a `MatrixDriver`, and never on a `MatrixClient`. The
standalone app and the widget become hosts like any other: they build a
`MatrixDriver` from their `matrix-js-sdk` client and hand it to the component.
Revision 2 — incorporates the independent review (§10 lists what changed and
the assumptions taken where only the user can decide).
Status legend: ☐ todo · ◐ in progress · ☑ done.
**Where things stand (2026-09-14):** S0a, S0b, S1a, S1b and S2 are
implemented and green (`pnpm lint`, `pnpm format:check`, `pnpm test:unit`:
101 files / 785 tests). Nothing is committed yet, in either repository: the
crate changes (C2–C8; C1 was reverted in favour of slot opening; C11 and C12 done;
C9 and C10 are pending) sit uncommitted in
`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`, and Element Call's
branch `toger5/oxidation` holds the vendored bindings, the driver layer
(`src/driver/**`), the participation layer (`src/state/rtc/**`) and the
config/lint changes. S3a is the next slice; its design is in §6 and the
files it touches are listed there.
---
## 1. Goals and non-goals
**Goals**
1. `ElementCall` (component) takes `driver: MatrixDriver` instead of
`client: MatrixClient`. Nothing rendered under `CallView` imports
`MatrixClient`, `Room`, `RoomMember`, `MatrixEvent` or
`matrix-js-sdk/lib/matrixrtc`.
2. All MatrixRTC participation logic (session projection, own membership
join/leave/keep-alive, transport tokens, media key exchange) comes from the
crate's `FfiParticipationManager`. Element Call keeps only what the crate
deliberately leaves to the host: the LiveKit media plane, tiles, room
metadata, reactions, notifications, UI.
3. The standalone SPA, widget mode and `sdk/main.ts` construct a
`JsSdkMatrixDriver` (a port of the draft's `web-test-app/src/jsSdkDriver.ts`
that also works on js-sdk's `RoomWidgetClient`, extended with what Element
Call needs beyond RTC) and stop using `client.matrixRTC`.
4. Every existing gate stays green: `pnpm lint` (tsc, oxlint, knip, component
externals), `pnpm format:check`, `pnpm test` (unit + storybook),
`pnpm i18n:check`, all four builds, Playwright (standalone, widget,
component).
**Non-goals**
- Replacing `matrix-js-sdk` in the standalone shell (login, registration, room
creation, home page, crypto bootstrap). The shell keeps its client and wraps
it. `src/home/useGroupCallRooms.ts` stays on `client.matrixRTC` for now.
- Removing `matrix-js-sdk/lib/logger`. It is isolated behind
`src/utils/logger.ts` (S6) so a later swap is one line.
- Writing a matrix-rust-sdk-backed driver (Element X). The interface is shaped
so one can be written; none is written here.
- Publishing the crate as an npm package. Until it exists, the generated
bindings are vendored (§5.1).
- Turning on MSC4153 (cross-signed sender) enforcement. Parity first (§5.8).
---
## 2. Where the code is today (inventory)
Entry: `component/index.tsx` → `src/room/CallView.tsx` → `LobbyView` |
`ActiveCall` (`src/room/InCallView.tsx`) | `CallEndedView`. `ActiveCall`
builds the view model with
`createCallViewModel$(scope, rtcSession, matrixRoom, mediaDevices, muteStates, options, raisedHands$, reactions$, trackProcessorState$)`.
| Concern | Files | js-sdk surface used |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session memberships | `src/state/SessionBehaviors.ts`, `src/useMatrixRTCSessionMemberships.ts` | `rtcSession.memberships`, `MembershipsChanged`, `membership.getTransport`, `isKeyRotationSuppressed` |
| Own membership | `localMember/LocalMember.ts` (`enterRTCSession`), `localMember/HomeserverConnected.ts` | `joinRTCSession`, `leaveRoomSession(1000)`, `updateCallIntent`, `MembershipManagerEvent.*`, `ClientEvent.Sync`; delegation probe at `LocalMember.ts:298-313`, delegation through the JWT service at `:731-756` |
| SFU / JWT | `src/livekit/openIDSFU.ts`, `localMember/LocalTransport.ts`, `localMember/RtcTransportAutoDiscovery.ts`, `remoteMembers/Connection.ts`, `ConnectionFactory.ts`, `ConnectionManager.ts` | `getOpenIdToken`, `_unstable_getRTCTransports`, `POST /get_token` (+ `delay_id`, `delay_timeout`, `delay_cs_api_url`), legacy `/sfu/get` |
| Remote member ↔ LiveKit identity | `remoteMembers/MatrixLivekitMembers.ts` | `rtcBackendIdentity`, `userId`, `deviceId`, `memberId` |
| E2EE media keys | `src/e2ee/matrixKeyProvider.ts`, `src/e2ee/sharedKeyManagement.ts` | `EncryptionKeyChanged`, `reemitEncryptionKeys`, `room.hasEncryptionStateEvent` |
| Room metadata | `remoteMembers/MatrixMemberMetadata.ts`, `src/utils/displayname.ts`, `src/room/useRoomName.ts`, `useRoomState.ts`, `useRoomAvatar.ts`, `useJoinRule.ts`, `InviteModal.tsx`, `CallView.tsx` | `getMembersWithMembership`, `RoomStateEvent.Members`, `room.name`, `getMxcAvatarUrl`, `getJoinRule`, `getCanonicalAlias` |
| Own profile / avatars | `src/profile/useProfile.ts`, `src/Avatar.tsx` | `getUser`, `UserEvent.*`, `setDisplayName`, `setAvatarUrl`, `uploadContent`, `mxcUrlToHttp`, `getAccessToken` |
| Reactions / hand raise | `src/reactions/ReactionsReader.ts`, `useReactionsSender.tsx`, `src/reactions/index.ts` | `RoomEvent.Timeline/Redaction/LocalEchoUpdated`, `MatrixEventEvent.Decrypted`, `relations.getChildEventsForEvent`, `sendEvent`, `redactEvent`, membership `eventId`, `RelationType` |
| Call notifications | `CallViewModel/CallNotificationLifecycle.ts` | `DidSendCallNotification`, `RoomEvent.Timeline` + `EventType.RTCDecline` |
| Rageshake / dev settings | `src/settings/submit-rageshake.ts`, `rageshake.ts`, `FeedbackSettingsTab.tsx`, `DeveloperSettingsTab.tsx` | `getCrypto`, `sendEvent(org.matrix.rageshake_request)`, `ClientEvent.Event`, `secureRandomString`, `doesServerSupportUnstableFeature`, `getSFUConfigWithOpenID` |
| Analytics | `src/analytics/PosthogEvents.ts`, `PosthogAnalytics.ts` | `rtcSession.statistics`, account data |
| Types only | `src/UrlParams.ts`, `src/state/MediaDevices.ts`, `AndroidControlledAudioOutput.ts`, `IOSControlledAudioOutput.ts`, `initialMuteState.ts`, `state/media/RingingMediaViewModel.ts` (`RTCCallIntent`), `src/useEvents.ts` (`TypedEventEmitter` types) | replaced by a local `CallIntent` type / kept as generic emitter typing |
| Runtime misc | `src/useLocalStorage.ts` (`TypedEventEmitter`), `src/room/GroupCallErrorBoundary.tsx` (`MatrixError`), `src/room/KnockLobbyView.tsx` (shell) | see S6 |
| Context | `src/ClientContext.tsx` | `useClient`/`useClientState` used by `Avatar`, `sharedKeyManagement`, `useReactionsSender`, `submit-rageshake`, `DisconnectedBanner` |
Hosts: `component/index.tsx:291`, `src/room/useLoadGroupCall.ts:335`,
`sdk/main.ts:128` (own `MatrixRTCSessionManager`; waits on
`MatrixRTCSessionEvent.JoinStateChanged` at `:292`). Test kit:
`src/utils/test.ts` (`MockRTCSession`, `mockRtcMembership`, `mockMatrixRoom`),
`src/utils/test-viewmodel.ts`, `CallViewModelTestUtils.ts`. Baseline on
`main` (fe911628): tsc green, 97 unit files / 757 tests green.
Component build: `vite-component.config.ts` (single string `fileName`,
externals list with 18 `matrix-js-sdk/lib/*` subpaths), `pnpm lint:externals`,
`component/package.json` (`matrix-js-sdk: "*"` peer, `exports` without a
wildcard).
---
## 3. What the crate gives us, what it does not, and what must change in it
Verified against `src/uniffi_api/mod.rs`, `src/participation/mod.rs`,
`src/session/state.rs`, `src/own_membership/machine.rs`,
`src/encryption/matrix_encryption_event.rs` and the generated
`web-test-app/src/generated/matrix_rtc.ts` (acceptance suites: 32 pass).
**Provided** (`FfiParticipationManager`, one per `(room, slot)`, any number
share one `FfiMatrixDriver`):
- `join(FfiTransportIntent, FfiJoinParams)` / `leave(code?, reason?)`; a
`Publish` intent with a bare LiveKit transport triggers discovery through
`driver.getRtcTransports()` (`connections/mod.rs:470-499`); a driver _error_
there is `NoTransport`, not a fallback.
- `memberships()` + listener: `FfiMembership { member { memberId, userId,
deviceId, displayName?, avatarUrl?, intent?, applicationType?,
publishedTransports, canSubscribe }, state: Joined | LeftWithKeys,
connections: serviceUrl[] (the FFI doc comment saying ws urls is wrong),
transportIdentity?, mediaKey? }`. `transportIdentity` is today's
`rtcBackendIdentity`. `LeftWithKeys` entries have empty `connections`.
- `connections()` + listener: `{ connection { serviceUrl, wsUrl, jwtToken,
expiresAtTs }, members }[]`; tokens re-minted a minute before `exp`.
- `keyMap()` + `setKeyMapListener(map, change)`: `FfiMediaKey { memberId, key:
ArrayBuffer, index, creationTsMs: bigint }`, inbound keys **and our own**
(`encryption/inbound.rs:251-257`).
- `status()` + listener: `Disconnected{cause} | Joining | Connected{keepAlive,
membership, roster, encryption, impairments} | Leaving`.
- `session()`, `ownMemberId()` (available as soon as `join()` starts),
`ownMembership()`, `connectionProblems()`, `debugSnapshot()`.
- Member display names and avatars, from the room's `m.room.member` state
(C8): the session keeps them current, so a rename is a memberships change.
- Slots are required: once the seed has read slot state, a slot with no event
is closed, so a room without an `m.rtc.slot` has no call, in every dialect.
`openSlot(application, encrypted)` / `closeSlot()` send the state event;
the slot id `m.call#ROOM` matches js-sdk's default.
- `FfiElementCallCompat.{Off, StickyEvents, StateEvents}` today; `StickyEvents`
is removed by C9, leaving `Off` (spec MSC4143) and `StateEvents` (MSC3401).
- All `u64` fields are `bigint` in TypeScript; `Vec<u8>` is `ArrayBuffer`.
- Listener callbacks arrive one timer tick after the emitting call (pumps
sleep through `setTimeout`, `executor.rs:86`); getters are fresh.
**Not provided — Element Call keeps it, through the driver** (§4.1 slices):
| Need | Why not in the crate | Where it goes |
| ------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Room name, alias, avatar, join rule, encryption flag | room metadata | `RoomDriver.getRoomInfo` |
| Profiles of room members who are not in the call (ringing, name-tag threshold) | not RTC | `RoomDriver.subscribeRoomMembers` |
| Reactions, hand raise, `org.matrix.rageshake_request` | application events | `TimelineDriver` |
| MSC4075 notification + decline | out of scope (only `wire_event_type` knows the type) | `TimelineDriver.sendRoomEvent`, decided in `CallNotificationLifecycle` (§4.3) |
| Own profile read/write | not RTC | `ProfileDriver` |
| `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` |
| Homeserver sync connectivity | needed by the crate too | `RtcMatrixDriver` (`isHomeserverConnected`, `subscribeConnectivity`, C12); reaches Element Call as `HomeserverUnreachable` in the status |
| Sticky-events support probe | capability probe | `MatrixDriver.getCapabilities()` |
**Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`CallParticipation.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
| C5 | Delegating the delayed leave (MSC4195) is split between the crate and the host: the draft's driver method makes the _adapter_ perform the whole delegation, its demo adapter calls a homeserver endpoint that 401s on a widget client, and Element Call today does it differently (probe, then the authorisation service's `get_token` with `delay_id`/`delay_timeout`/`delay_cs_api_url`). Nothing of this exists in the real `crates/` yet. | **The crate owns the policy; the driver keeps two primitives.** (a) `delegate_delayed_leave_via_homeserver(room, slot, member, delay_id)`: one authenticated POST to the CS API endpoint (`/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`, the spelling Element Call probes today; a widget client answers `Unsupported`). (b) `LivekitTokenRequest.delegation: Option<{ delay_id, delay_timeout_ms }>`: the adapter appends `delay_id`, `delay_timeout` and `delay_cs_api_url` (its own homeserver URL) to the `get_token` / `sfu/get` body it already sends. The own-membership machine tries (a) first and, on `Unsupported` or any failure, (b) against the transport we publish on, i.e. Element Call's OpenID → JWT → scheduled-event path; only if both fail does it keep restarting the switch itself. **Arm-after-confirm:** the short delayed leave stays armed through the join; delegation arms a second, ≥ 1 h delayed leave, delegates _that_, and cancels the short one on success (or the long one on failure), so no moment is left without an armed leave and a failed delegation costs nothing. `KeepAlive::Delegated` says which route succeeded. The interim C5 (service URL and delay on the request) and Element Call's driver-side probe and JWT delegation are replaced by this. |
| C6 | The transport identity is a pure function (`connections/mod.rs:116-135`) but not exported; the own identity is needed before our membership echo to set our own media key. | Export `FfiParticipationManager.own_transport_identity(): Option<String>`. |
| C7 | Doc comment on `FfiMembership.connections` says `ws_url`s. | Fix the comment. |
| C8 | Both converters set `display_name` / `avatar_url` to `None`, so every host would re-derive them from room members. | The session records each `m.room.member` profile (whether or not the roster condition is enforced) and stamps it on members at projection time. |
| C9 | `ElementCallCompat::StickyEvents` models the 2025 Element Call sticky dialect (`member: {user_id, device_id, id}`, flat `rtc_transports`, `versions`, legacy key message). No deployment uses it: sticky-event calls have not shipped. | **Remove the mode.** Delete `own_membership/compat_2025.rs`, the 2025 block in `session/convert/msc4143.rs` and its dispatch arm, the `StickyEvents` variants of `ElementCallCompat` / `FfiElementCallCompat`, and their tests and acceptance tests; `outbound_event_type` / `build_content` keep two arms (`Off`, `StateEvents`). Element Call then maps `Matrix_2_0 → Off`: spec MSC4143 sticky events with slots (which Element Call opens, C1) and the spec key message. |
| 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. | Keep the MSC4153 verdict of the accepted key per member and expose it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
No crate work is deferred: `update_application` is C11.
---
## 4. Target architecture
```text
host (SPA · widget · sdk · a third-party page)
├─ RtcMatrixDriver = the crate's MatrixDriverCallback, verbatim
│ (events, to-device, tokens, sinks, connectivity)
└─ ElementCallMatrixClientDriver = RoomDriver + TimelineDriver + ProfileDriver
+ MediaDriver + capabilities
│
▼ component/index.tsx <ElementCall rtcDriver clientDriver …/>
┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐
│ CallView owns one CallParticipation for lobby → call → ended │
│ CallParticipation (src/state/rtc/CallParticipation.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
│ join(intent, params) · leave(reason) │
│ │ │
│ ▼ │
│ createCallViewModel$(scope, callParticipation, roomInfo, mediaDevices, …) │
│ ConnectionManager ← connections$ (wsUrl + jwt; no OpenID in EC) │
│ RemoteMembers ← memberships$ (transportIdentity ↔ LK participant)│
│ MatrixKeyProvider ← keyChanges$ (memberId → transportIdentity) │
│ LocalMember ← status$, ownMembership; join/leave → participation│
│ MemberMetadata ← driver.room members │
│ Notifications ← driver.timeline + memberships$ │
│ React: Lobby / InCall / Settings / Avatar / Reactions │
│ read the driver via useMatrixDriver(), never a client │
└──────────────────────────────────────────────────────────────────────────┘
```
### 4.1 The two drivers (host-facing, framework-neutral)
Two files, two objects. `src/driver/RtcMatrixDriver.ts` is one line: the
crate's `MatrixDriverCallback`, re-exported. Everything MatrixRTC, including
homeserver connectivity (C12), goes through it and is consumed by the crate.
`src/driver/ElementCallMatrixClientDriver.ts` is what a call needs beyond
MatrixRTC. Plain TypeScript: async methods and
`subscribeX(listener) → unsubscribe` pairs, mirroring the crate's sink style.
No RxJS crosses this boundary; Element Call wraps subscriptions into
`Behavior`s internally (`src/driver/observe.ts`).
```ts
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to (one driver per room, as in the crate). */
readonly roomId: string;
getCapabilities(): Promise<DriverCapabilities>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>;
}
export interface DriverCapabilities {
stickyEvents: boolean;
/** The host's events carry decryption metadata (false on a widget client). */
verifiedEventOrigins: boolean;
/** The host can evaluate MSC4153 cross-signing of senders. */
crossSigningVerdicts: boolean;
}
// src/driver/RtcMatrixDriver.ts
export type RtcMatrixDriver = MatrixDriverCallback;
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
avatarUrl: string | null;
joinRule: string | null;
encrypted: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
avatarUrl: string | null;
membership: "join" | "invite";
}
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): () => void;
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): () => void;
}
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
redacts?: string;
}
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: unknown,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/** Decrypted live room events (not sticky), incl. redactions; no local echoes. */
subscribeTimeline(listener: (event: TimelineEvent) => void): () => void;
/** Events already known that relate to `eventId` (hand-raise catch-up). */
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
avatarUrl: string | null;
}
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): () => void;
/** Absent when the host does not allow profile changes. */
setDisplayName?(name: string): Promise<void>;
setAvatar?(file: Blob): Promise<void>;
}
export interface MediaDriver {
/** An `<img>`-usable URL for an mxc thumbnail (may be a blob: URL), or null. */
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
```
The client driver is a union of capability slices, the way the crate splits
its own driver; the RTC driver is the crate's contract untouched, so a host
with a crate-side adapter (matrix-rust-sdk) implements nothing extra for
MatrixRTC.
### 4.2 `CallParticipation` (Element Call's RxJS view of the manager)
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
`FfiParticipationConfig`); `CallParticipation` is Element Call's RxJS wrapper
over it.
`src/state/rtc/CallParticipation.ts`, a class taking the scope in its constructor:
```ts
new CallParticipation(scope, driver, {
slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender,
useKeyDelayMs, transportFallbackUrl?, logger })
memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1)
connections$: Behavior<FfiConnectionWithMembers[]>
keyChanges$: Observable<FfiMediaKey> // one changed key per emission
keyMap$: Behavior<FfiMediaKey[]>
status$: Behavior<FfiStatus>
session$: Behavior<FfiSessionSnapshot>
ownMemberId$: Behavior<string | null>
ownTransportIdentity$: Behavior<string | null>
ownMembership$: Behavior<FfiMembership | null>
join(intent: FfiTransportIntent, params: FfiJoinParams): Promise<void>
leave(code?: string, reason?: string): Promise<void>
```
- Wraps `new FfiMatrixDriver(driver)` and `new FfiParticipationManager(...)`;
`uniffiDestroy()` on scope end (leave first unless `Disconnected`).
- `transportFallbackUrl` decorates `getRtcTransports`: when the host's call
**throws or returns no LiveKit transport**, answer with
`Config.get().livekit.livekit_service_url` (today's precedence,
`RtcTransportAutoDiscovery.ts:72-94`).
- Lives at `CallView` level (the lobby reads memberships for the participant
count, auto-mute threshold and notification decision, `CallView.tsx:164-413`;
`useReactionsSender` needs the own membership). `join()` after `leave()` on
one manager is supported and mints a fresh member id.
- Behaviors are seeded from the getters and updated by the listeners.
### 4.3 `createCallViewModel$` after the change
```ts
createCallViewModel$(
scope,
participation,
roomInfo,
mediaDevices,
muteStates,
options,
handsRaised$,
reactions$,
trackProcessorState$,
);
// roomInfo: { roomId, userId, deviceId, members$: Behavior<RoomMemberProfile[]>,
// homeserverConnected$: Behavior<boolean>, timeline: TimelineDriver }
```
| Today | After |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createMemberships$(scope, rtcSession)` | `callParticipation.memberships$` |
| `membershipsAndTransports$` (`getTransport(oldest)`) | `membership.connections[]` (service urls) |
| `createLocalTransport$` + `RtcTransportAutoDiscovery` + `openIDSFU` | deleted; `join(Publish(custom url or bare))`; own transport = `ownMembership.member.publishedTransports[0]` |
| `Connection.start()` fetching a JWT | `Connection` takes `{ wsUrl, jwt, expiresAtTs }` from `connections$`, keyed by `serviceUrl`, holds `token$`; a refreshed token is used on the next full (re)connect; `expiresAtTs` logged on connect; `livekitAlias` decoded from the JWT stays |
| `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` |
| `MatrixKeyProvider.setRTCSession` | `MatrixKeyProvider.attach(participation)`: `keyChanges$` × `memberships$` × `ownTransportIdentity$` → `onSetEncryptionKey(material, identity, index)`; keys whose member has no identity yet are held per member id and replayed |
| `enterRTCSession` (`joinRTCSession(...)`) | `callParticipation.join(intent, joinParamsFromConfig(...), { encrypted: roomInfo.encrypted, canOpen: roomInfo.canOpenSlot })` in the same `scope.reconcile`: opens the room's slot first when none is open (power level permitting, otherwise `NoOpenSlotError`), then joins; cleanup calls `callParticipation.leave()` |
| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `CallParticipation.homeserverConnected$` from the manager's getter |
| `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) |
| `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold |
| `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` |
| `updateCallIntent` on camera toggle | `callParticipation.updateApplication(videoEnabled ? "video" : "audio")` (C11); Element X's room-header intent keeps following the camera |
| `createKeyRotationSuppressed$` + `key_rotation_participant_limit` | dropped (the crate has no participant limit; the indicator has no equivalent) |
| `rtcSession.statistics` (PostHog) | counts of `keyChanges$` (sent = own member id, received = others) |
| `MembershipManagerError` → `StickyEventsRequiredError` | `status$` `Disconnected{cause: JoinFailed{error: Driver/Unsupported}}` → `StickyEventsRequiredError`; `NoTransport` → `MatrixRTCTransportMissingError`; `ManagerStopped`/`SlotClosed` → `ConnectionLostError` |
| `impairments` | fed into the inert `src/state/ServiceInterruptionsViewModel.ts` (S6) |
Join parameters (`joinParamsFromConfig`, from `Config.get().matrix_rtc_session`):
`stickyDurationMs = BigInt(Math.min(membership_event_expiry_ms ?? 4 h, 1 h))`
(js-sdk caps sticky at 1 h, the crate clamps to 1 h; the config default is
undefined today); `keepAliveTimeoutMs = BigInt(delayed_leave.delay_ms)`;
`degradedLifetimeMs = undefined`; `applicationType = "m.call"`;
`intent = options.callIntent`. Config keys that stop having an effect and are
documented as such in `docs/`: `delayed_leave.restart_ms`,
`restart_timeout_ms`, `network_error_retry_ms`,
`key_rotation_participant_limit`, `delegated_delayed_leave.*` (the crate arms
1 h when delegating). `wait_for_key_rotation_ms` maps to
`FfiParticipationConfig.useKeyDelayMs`.
Compat: `MatrixRTCMode.Compatibility → StateEvents`, `Matrix_2_0 → Off` (spec
MSC4143: sticky member events, slots, the spec key message). Until C9 lands the
code still maps `Matrix_2_0` to the crate's `StickyEvents`; `compatForMode` is
the one place that changes.
### 4.4 React tree
- `src/driver/MatrixDriverContext.tsx`: `MatrixDriverProvider` holding both
drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every
`useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell.
- `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`.
It creates the `CallParticipation` (scope tied to its mount) and hands it to
`LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`.
- `Avatar` → `driver.thumbnailUrl` (host bridge `downloadMedia` first).
- `useProfile(client)` → `useOwnProfile()`; `ProfileSettingsTab` hides editing
when `setDisplayName` is absent.
- `ReactionsReader(scope, participation, driver)`, `useReactionsSender` over
`TimelineDriver`. Relation target stays the **current own membership event
id** (`member.eventId`, C4) — protocol status quo; the reader keys raised
hands by `memberId` and re-resolves the event id on re-send instead of
dropping the hand.
- `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`.
- `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`;
rageshake requests via `subscribeTimeline`.
- `DeveloperSettingsTab`: sticky probe → `getCapabilities()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`.
- `DisconnectedBanner` → `HomeserverUnreachable` in `status$` (C12).
- `window.rtcSession` debug handle → `window.matrixRtc = { participation }`.
### 4.5 Hosts
- **Component**: `ElementCallProps.rtcDriver: RtcMatrixDriver` and
`clientDriver: ElementCallMatrixClientDriver`; `roomId` stays as an
**optional** prop for one release and must equal `clientDriver.roomId` when given
(assertion), then goes. `initializeElementCall(config, { matrixRtcWasm? })`
awaits `initMatrixRtcSdk()`. Externals shrink to `react*`, `livekit-client`,
`matrix-js-sdk/lib/logger`. `JsSdkRtcMatrixDriver` and `JsSdkElementCallMatrixClientDriver` are
exported from a second entry `@element-hq/element-call-component/matrix-js-sdk` (needs
`lib.fileName` as a function and a new `exports` key); only that entry has
`matrix-js-sdk` as a peer.
- **SPA / widget**: `useLoadGroupCall` returns the `Room`; `RoomPage` memoises
the two js-sdk drivers and renders `<CallView rtcDriver clientDriver>`. The
widget capability list in `src/widget.ts` grows by: send and receive state
`m.rtc.slot` and `org.matrix.msc4143.rtc.slot` (opening the slot), `m.room.avatar`,
`m.room.canonical_alias`, `m.room.join_rules`; send/receive to-device
`org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key`
(alongside `io.element.call.encryption_keys`); events `m.rtc.decline`.
- **`sdk/main.ts`**: builds the driver from the widget client, a
`CallParticipation`, and waits on `status$` instead of `JoinStateChanged`.
- **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each
`src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` (the crate seam, a port of the
draft's `jsSdkDriver.ts`, connectivity from `ClientEvent.Sync`) and
`JsSdkElementCallMatrixClientDriver.ts` (the §4.1 slices), both written so
that they work on a full `MatrixClient`
**and** on js-sdk's `RoomWidgetClient` (`node_modules/matrix-js-sdk/src/embedded.ts`).
Differences from the draft, all required by the widget client:
- to-device inbound: listen on `ClientEvent.ToDeviceEvent` (the widget client
never emits `ReceivedToDeviceMessage`, `embedded.ts:788-802`); on a full
client use `ReceivedToDeviceMessage` for the `encryptionInfo`.
- to-device outbound: always `encryptAndSendToDevice` (works without a crypto
backend on the widget client, `embedded.ts:598-612`; plain `sendToDevice`
there is **unencrypted**, `:614-619`).
- transports: `client._unstable_getRTCTransports()` (the widget override,
`embedded.ts:641-646`), never a raw `http.authedRequest`; `.well-known`
fallback only on a full client.
- event origins: on a full client from decryption metadata (as the draft); on
a widget client events arrive decrypted without metadata, so the driver
reports `Encrypted{ senderDeviceId: content.member.device_id }` for member
events and `Encrypted{ senderDeviceId: content.device_id }` for key
events, i.e. the _claimed_ trust level js-sdk applies today
(`ToDeviceKeyTransport.ts:133-140`). `getCapabilities().verifiedEventOrigins`
says which.
- cross-signing verdict: `undefined` on a widget client
(`crossSigningVerdicts: false`); Element Call then forces
`requireCrossSignedSender = false`.
- delegation: two primitives and no policy. `delegateDelayedLeaveViaHomeserver`
is one `authedRequest` on a full client and `Unsupported` on a widget
client; `getLivekitToken` appends `delay_id`, `delay_timeout` and
`delay_cs_api_url` (`client.baseUrl`) when the request carries a
delegation. The crate decides when to call which (C5). Until C5 lands the
driver still carries Element Call's probe and JWT delegation; both go then.
- sticky listener attached after `startClient()` resolves (the widget room
only exists then, `embedded.ts:326`).
- `getLivekitToken` reuses today's request shapes (`slot_id: "m.call#ROOM"`,
legacy `/sfu/get`), errors mapped to `RtcError` incl. `M_LIMIT_EXCEEDED →
RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
---
## 5. Decisions and assumptions
1. **Vendored bindings.** `src/matrix-rtc-sdk/generated/` holds `matrix_rtc.ts`,
`matrix_rtc-ffi.ts`, `wasm-bindgen/index.js`, `index_bg.wasm` and a
hand-written `wasm-bindgen/index.d.ts` (no `allowJs` in `tsconfig.json`),
synced by `scripts/sync-matrix-rtc-sdk.sh` (runs `ubrn build web` in the
draft **without** the `runtime-probe` feature, copies). Committed so CI
works. `@ubjs/core` becomes a dependency. The wasm is ~6.5 MB unoptimised.
**Assumption:** committing the binary is acceptable for the draft phase.
2. **Wasm loading.** Verified: Vite 8 library mode inlines `?url` and
`new URL(…, import.meta.url)` assets as base64 regardless of
`assetsInlineLimit`; `?url&no-inline` emits a file. App builds use `?url`;
the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. vitest reads the file from disk; wasm boot is
**lazy** (only suites that need it call `initMatrixRtcSdk()`), never in
`src/vitest.setup.ts`; Storybook boots it in `.storybook/preview.tsx`
`beforeAll`. Suites using `vi.useFakeTimers` never share a file with
real-wasm tests (pumps sleep on `setTimeout`).
3. **Own identity via the driver.** `userId`/`deviceId` are properties of the
driver, not props.
4. **One driver per room**, as in the crate.
5. **`matrix-js-sdk/lib/logger` stays** behind `src/utils/logger.ts`.
6. **`updateCallIntent` stays**, through the crate's `update_application` (C11).
7. **Reactions relate to the current own membership event id** (status quo);
reader keys by `memberId`. Alternatives (stable join event id, or
`memberId` as relation target) are protocol changes left to the user.
8. **MSC4153 default off** (`requireCrossSignedSender = false`) for every
host, confirmed: js-sdk performs no such check today, and a passwordless
SPA peer would otherwise be inaudible to everyone. The intent is to turn
it on; the default carries a TODO (`src/state/rtc/joinParams.ts`), and C10
puts the sender's verdict on the tile so the UI can show it meanwhile.
9. **Delegation is the crate's alone.** Element Call always asks for it; the
crate tries the CS API, then the authorisation service's token endpoint,
then its own restarts, and arms the long delegated leave only once
delegation is confirmed (C5). Element Call keeps no probe and no
delegation code.
10. **Widget trust model:** origins synthesised from claimed device ids equal
today's js-sdk trust level; the crate records them as
`DeviceAttribution::Verified` because it cannot tell. Documented in the
driver; a `Claimed` attribution flag on the sink is a follow-up crate ask.
11. **Scratch files** go to `agent-workspace/oxidation/`; this plan lives at
the repo root because it was asked for by name.
12. **Branch** `toger5/oxidation`, one commit per slice.
---
## 6. Work breakdown
Each slice is independently green (`pnpm lint && pnpm test:unit` at least).
### S0a — crate changes ☑ (in `MatrixSdkArchitectureDraft`)
C2–C12 from §3 (C1 reverted), each with a Rust unit test; acceptance tests in
`web-test-app/test/`: a slot-less room refuses joins until `openSlot`, in every dialect;
`FfiParticipationConfig` with `manageMediaKeys: false` exchanges no keys;
no `StickyEvents` variant left anywhere (C9); `member.eventId` present;
delegation tries the homeserver first, then the token endpoint with the delay fields, and keeps the short leave armed until one succeeds;
`ownTransportIdentity()` equals the joined membership's `transportIdentity`.
Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uniffi -- -D warnings`,
`npm run ubrn:web && npm test`.
### S0b — SDK intake ☑
- `scripts/sync-matrix-rtc-sdk.sh`, `src/matrix-rtc-sdk/generated/**`,
`src/matrix-rtc-sdk/index.ts` (loader + curated re-exports),
`src/matrix-rtc-sdk/index.test.ts` (boot, one join round-trip against the
TS mock driver).
- `package.json` (`@ubjs/core`), `knip.ts` (`ignore` for `generated/**`),
`.oxlintrc.json` `ignorePatterns`, `.oxfmtrc.json` ignore,
`vite.config.ts` (nothing needed for `?url`; verified `vite-plugin-wasm`
ignores it), `tsconfig.json` untouched thanks to the `.d.ts`.
- Gate: `pnpm lint && pnpm format:check && pnpm test:unit`.
### S1a — driver interface, mock driver ☑
- `src/driver/RtcMatrixDriver.ts`, `src/driver/ElementCallMatrixClientDriver.ts`,
`src/driver/observe.ts`, `MockRtcMatrixDriver.ts` (port of
`web-test-app/src/mockDriver.ts`) and `MockElementCallMatrixClientDriver.ts`
(in-memory room info, members, timeline, profile), with a test each.
- `knip.ts` `ignore` for `src/driver/**` until consumed, with the reason.
### S1b — `JsSdkMatrixDriver` ☑
- `src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` and
`JsSdkElementCallMatrixClientDriver.ts` (§4.6) + tests against **two**
fakes: a `MatrixClient`-shaped one (`mockMatrixRoom`) and a
`RoomWidgetClient`-shaped one (`ToDeviceEvent`, no crypto,
`_unstable_getRTCTransports`, sticky updates after `startClient`).
Asserts request shapes of `_unstable_sendStickyEvent`,
`_unstable_sendStickyDelayedEvent`, `_unstable_updateDelayedEvent`,
`encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients,
sink emission and origin synthesis, room-info/member updates.
### S2 — `CallParticipation` ☑
- `src/state/rtc/CallParticipation.ts`, `joinParams.ts`, `transportIntent.ts`,
`errors.ts` (cause → `ElementCallError`).
- `CallParticipation.test.ts` through the real wasm + `MockMatrixDriver`:
memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave →
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
manager; fallback transport when the host throws or advertises none.
### S3 — view model, four slices ☐
- **S3a** `Connection`/`ConnectionManager`/`ConnectionFactory` keyed by
`serviceUrl`, fed by `Behavior<{ serviceUrl, wsUrl, jwt, expiresAtTs }[]>`,
with a temporary adapter from today's `SFUConfig` so `openIDSFU` stays
until S3c. Introduces `mockCallParticipation()` and `mockFfiMembership()` in
`src/utils/test.ts`.
- **S3b** `MatrixLivekitMembers` + `MatrixKeyProvider` on `FfiMembership` /
key changes, with an adapter from `CallMembership` for the still-js-sdk
`memberships$`.
- **S3c** `LocalMember` on `callParticipation.join/leave` + status-derived
connectivity; delete `LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`.
- **S3d** `createCallViewModel$` signature, `CallNotificationLifecycle`,
`MatrixMemberMetadata`, `SessionBehaviors.ts` and
`useMatrixRTCSessionMemberships.ts` deleted, `ReactionsReader` on
`participation` + `TimelineDriver`; test kit swapped
(`MockRTCSession`/`mockRtcMembership` deleted). To stay green before S5,
`CallView` builds a `JsSdkMatrixDriver` from its existing `client` /
`rtcSession.room` props as a temporary shim.
### S4 — React tree, two slices ☐
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`CallParticipation`), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
`DisconnectedBanner.tsx`, `analytics/PosthogEvents.ts`, `controls.ts`, and a
first `CallView.stories.tsx` (lobby, in call, ended) driven by
`MockMatrixDriver`.
- **S4b** reactions and notifications: `useReactionsSender.tsx`,
`ReactionsReader` keyed by `memberId`, `CallNotificationLifecycle` sending
through the driver; tests incl. a membership re-send mid-call.
### S5 — hosts ☐
- `component/index.tsx`, `component/matrix-js-sdk.ts`, `component/package.json`
(`exports`, peers), `vite-component.config.ts` (two entries, `fileName`
function, externals shrink, `?url&no-inline`), `component/tsconfig.build.json`,
`component/dev/Harness.tsx`, `README.md`.
- `src/room/useLoadGroupCall.ts`, `RoomPage.tsx`, `src/widget.ts`
(capabilities, §4.5), `sdk/main.ts`, `playwright/spa-helpers.ts`
(delegation route helper), `docs/` config notes.
- Gate: all four builds; Playwright standalone + widget + component against
`pnpm backend`; a widget media-key round trip and `reconnect.spec.ts`
re-checked under the new delegation path.
### S6 — fence and cleanup ☐
- `src/utils/logger.ts`; local `CallIntent` type for the six `RTCCallIntent`
users; `useLocalStorage.ts` on a local emitter; oxlint
`no-restricted-imports` scoped to the call tree (allow-list: `src/home`,
`src/auth`, `src/utils/spa.ts`, `src/utils/matrix.ts`, `src/driver/jsSdk/**`,
`src/ClientContext.tsx`, `src/widget.ts`, `src/initializer.tsx`,
`src/IndexedDBWorker.ts`, `src/room/KnockLobbyView.tsx`, `src/settings/rageshake.ts`)
banning `matrix-js-sdk` except `matrix-js-sdk/lib/logger`.
- `ServiceInterruptionsViewModel` fed from `status$.impairments`.
- `docs/agents/architecture.md`, `docs/matrix_rtc_modes.md` updated;
`src/@types/matrix-js-sdk.d.ts` removed if nothing merges into js-sdk types.
---
## 7. Risks and mitigations
| Risk | Mitigation |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| A user without the power level to open a slot, in a room that never had a call | `NoOpenSlotError` names the remedy; a room admin opens the slot by starting the first call |
| A client on the removed sticky dialect (none is deployed) | Not supported: `Off` and `StateEvents` are the only dialects (C9) |
| Component bundle size / wasm delivery | `?url&no-inline` + `exports` wildcard + `matrixRtcWasm` override; verified in S0b and S5 |
| Token refresh vs LiveKit reconnect | `Connection.token$`; full reconnect uses the latest token; `expiresAtTs` logged |
| Own identity before the echo | C6 export; JWT `sub` only as a logged cross-check |
| Keys before identity | key provider buffers per member id and replays |
| Widget mode regressions (origins, to-device event, capabilities) | S1b tests against a `RoomWidgetClient` fake; S5 widget e2e adds a media-key round trip |
| Delegation fails after the long leave was armed | Arm-after-confirm (C5): the short leave stays armed until delegation is confirmed |
| Behaviour drift in keep-alive (6 s vs 4 s restarts) and dropped config keys | documented in `docs/` |
| Test time: wasm per file | lazy boot in the suites that need it only |
---
## 8. Verification matrix
| Check | S0a | S0b | S1 | S2 | S3 | S4 | S5 | S6 |
| ---------------------------------------------------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- |
| `cargo test/clippy`, `web-test-app` suites | ● | | | | | | | |
| `pnpm lint` + `format:check` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:unit` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:storybook` | | | | | | ● | ● | ● |
| four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
---
## 9. Open questions for the user (answered by the assumptions in §5 until told otherwise)
1. Slot semantics, **answered**: no slot means no call; Element Call opens the
slot on the first call in a room, power level permitting.
2. Delegation, **answered**: CS API endpoint first, then Element Call's
OpenID → JWT → scheduled-event path, all inside the crate and invisible to
Element Call (C5).
3. Media-key type under the sticky compat, **answered**: there are no deployed
sticky-event clients, so the compat mode is removed (C9) and the question
with it.
4. MSC4153, **answered**: off everywhere for now, with a TODO to turn it on
and C10 so tiles show unverified senders in the meantime.
5. Reactions relation target: current membership event id (taken), stable join
event id, or `memberId`?
6. Committing the 6.5 MB wasm (taken) vs a build-time fetch.
7. `roomId` prop: optional for one release (taken) vs removed outright.
8. `updateCallIntent`, **answered**: added to the crate as `update_application`
(C11); Element Call keeps the behaviour.
---
## 10. Review log
Independent review findings incorporated in this revision: slot enforcement
blocker (C1); delegation protocol and 1 h arm (C5, §5.9; later redefined as crate-only with arm-after-confirm); `StickyEvents` key
type interop (C3, then made moot by removing the mode, C9); MSC4153 default (§5.8); widget-client differences for the
js-sdk drivers (§4.6) and missing widget capabilities (§4.5); own identity
export (C6); `CallParticipation` lifetime at `CallView` (§4.2); notification
timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper.
+9
View File
@@ -26,6 +26,15 @@ export default {
// This is a shell built-in. // This is a shell built-in.
"printf", "printf",
], ],
// uniffi-generated bindings (scripts/sync-matrix-rtc-sdk.sh): every
// export the crate has, most of them unused here by design
ignore: [
"src/matrix-rtc-sdk/generated/**",
// The host-facing driver seam, landed ahead of its consumers (the
// `CallParticipation` layer and the hosts, see element-call-oxidation-plan.md)
"src/driver/**",
"src/state/rtc/**",
],
ignoreFiles: [ ignoreFiles: [
"scripts/.pnpmfile.cjs", "scripts/.pnpmfile.cjs",
// Deliberately added prior to any component or business logic // Deliberately added prior to any component or business logic
+2
View File
@@ -110,6 +110,8 @@
"membership_manager": "Membership Manager Error", "membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.", "membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.", "no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
"no_open_slot": "No call to join here",
"no_open_slot_description": "Nobody has started a call in this room yet, and you do not have permission to start one. Ask a room admin to start the call, or to allow you to start calls.",
"open_elsewhere": "Opened in another tab", "open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"peer_connection_timeout": "Connection timeout", "peer_connection_timeout": "Connection timeout",
+1
View File
@@ -82,6 +82,7 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5", "@types/sdp-transform": "^2.4.5",
"@typescript-eslint/utils": "^8.61.0", "@typescript-eslint/utils": "^8.61.0",
"@ubjs/core": "0.31.0-5",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0", "@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^10.0.0", "@vector-im/compound-web": "^10.0.0",
+8
View File
@@ -127,6 +127,9 @@ importers:
'@typescript-eslint/utils': '@typescript-eslint/utils':
specifier: ^8.61.0 specifier: ^8.61.0
version: 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) version: 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@ubjs/core':
specifier: 0.31.0-5
version: 0.31.0-5
'@use-gesture/react': '@use-gesture/react':
specifier: ^10.2.11 specifier: ^10.2.11
version: 10.3.1(react@19.2.8) version: 10.3.1(react@19.2.8)
@@ -3218,6 +3221,9 @@ packages:
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ubjs/core@0.31.0-5':
resolution: {integrity: sha512-oRBRtyYOhaodiOY3rLMZFgAHGFMmD44XefMpsAx4Ja0/rO+45kVdQB1VZsblQMLYxP965i3IHm56eymgo3I5eQ==}
'@ungap/structured-clone@1.3.3': '@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
@@ -8686,6 +8692,8 @@ snapshots:
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.69.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@ubjs/core@0.31.0-5': {}
'@ungap/structured-clone@1.3.3': {} '@ungap/structured-clone@1.3.3': {}
'@use-gesture/core@10.3.1': {} '@use-gesture/core@10.3.1': {}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
#
# 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.
#
# Vendors the matrix-rtc crate's uniffi web bindings into
# src/matrix-rtc-sdk/generated/. Until the crate ships as an npm package this
# is how Element Call picks up a new build of it: build it there, copy the
# generated TypeScript, the wasm-bindgen glue and the wasm here.
#
# scripts/sync-matrix-rtc-sdk.sh [path-to-MatrixSdkArchitectureDraft] [--no-build]
#
# The build uses web-test-app/ubrn.element-call.config.yaml (feature `uniffi`
# only, release profile, its own output directory so the app's test build is
# left alone). Pass --no-build to copy whatever was built last.
set -euo pipefail
DRAFT="${1:-../matrix-rust-rtc/MatrixSdkArchitectureDraft}"
BUILD=1
for arg in "$@"; do
[[ "$arg" == "--no-build" ]] && BUILD=0
done
APP="$DRAFT/web-test-app"
GENERATED="$APP/src/generated-element-call"
DEST="$(cd "$(dirname "$0")/.." && pwd)/src/matrix-rtc-sdk/generated"
if [[ ! -f "$APP/ubrn.element-call.config.yaml" ]]; then
echo "No web-test-app/ubrn.element-call.config.yaml under $DRAFT" >&2
exit 1
fi
if [[ "$BUILD" == 1 ]]; then
(cd "$APP" && npx ubrn build web --config ubrn.element-call.config.yaml --release)
fi
mkdir -p "$DEST/wasm-bindgen"
cp "$GENERATED/matrix_rtc.ts" "$GENERATED/matrix_rtc-ffi.ts" "$DEST/"
cp "$GENERATED/wasm-bindgen/index.js" "$GENERATED/wasm-bindgen/index_bg.wasm" "$DEST/wasm-bindgen/"
REV="$(git -C "$DRAFT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
DIRTY="$(git -C "$DRAFT" status --porcelain 2>/dev/null | grep -q . && echo '-dirty' || true)"
cat > "$DEST/VERSION" <<VERSION
matrix-rtc (MatrixSdkArchitectureDraft) ${REV}${DIRTY}
built $(date -u +%Y-%m-%dT%H:%M:%SZ) by scripts/sync-matrix-rtc-sdk.sh
VERSION
echo "Synced matrix-rtc bindings from $DRAFT ($REV$DIRTY) into $DEST"
ls -la "$DEST" "$DEST/wasm-bindgen"
+161
View File
@@ -0,0 +1,161 @@
/*
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.
*/
/**
* What Element Call needs from a Matrix client beyond MatrixRTC.
*
* A host implements this once per room next to its {@link RtcMatrixDriver}.
* It covers what the crate deliberately leaves to the application: who is
* in the room and what they are called, sending a reaction, showing an
* avatar, the user's own profile, and what the deployment can do. Homeserver
* connectivity is *not* here: the crate needs it too, so it is part of the
* RTC driver and reaches Element Call as a participation impairment.
*
* Deliberately framework-neutral: plain values, promises and
* `subscribeX(listener)` pairs that return an unsubscribe function, the
* same shape as the crate's sinks. No RxJS crosses this boundary; Element
* Call wraps subscriptions into behaviors itself (see `observe.ts`).
*/
/** Removes the listener it was returned for. */
export type Unsubscribe = () => void;
export interface DriverCapabilities {
/** The homeserver accepts sticky events (MSC4354). */
stickyEvents: boolean;
/**
* The events the RTC driver emits carry real decryption metadata. A
* widget client receives events already decrypted by its host and can
* only report the device the content *claims*.
*/
verifiedEventOrigins: boolean;
/** The RTC driver can say whether a sending device is cross-signed (MSC4153). */
crossSigningVerdicts: boolean;
}
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
/** The `m.room.join_rules` value, `null` while unknown. */
joinRule: string | null;
/** Whether the room has an `m.room.encryption` state event. */
encrypted: boolean;
/**
* Whether this user may send the MatrixRTC slot state event
* (`org.matrix.msc4143.rtc.slot`). A call needs an open slot; the client
* that starts a call opens one, which takes the power level for it.
*/
canOpenSlot: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
membership: "join" | "invite";
}
/**
* Room metadata and the profiles of the people in it.
*
* Call members' names and avatars do not come from here: the crate reads
* `m.room.member` itself and puts them on each membership. This roster is
* for the people who are in the room but not (yet) in the call — the person
* being rung, and how many others there are.
*/
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe;
/** Joined and invited members of the room, in or out of the call. */
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe;
}
/** A decrypted room event, as far as a call needs to know it. */
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
/** Set on an `m.room.redaction`. */
redacts?: string;
}
/** Application events in the room: reactions, hand raises, notifications. */
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/**
* Live room events (not sticky ones — those reach the crate through its
* own sink), decrypted, redactions included, local echoes excluded.
*/
subscribeTimeline(listener: (event: TimelineEvent) => void): Unsubscribe;
/**
* Events already known that relate to `eventId` with the given relation
* type and event type — how a late joiner learns of a raised hand.
*/
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
}
/** The user's own profile. Editing is optional: a host may not allow it. */
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): Unsubscribe;
setDisplayName?(name: string): Promise<void>;
setAvatar?(file: Blob): Promise<void>;
}
export interface MediaDriver {
/**
* A URL an `<img>` can show for an `mxc://` thumbnail — possibly a `blob:`
* URL the driver fetched with its credentials — or null when the media
* cannot be resolved.
*/
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
/**
* 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
* slices its own driver, so a piece of Element Call can ask for no more than
* it needs.
*/
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
/** Who we publish as. */
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to. */
readonly roomId: string;
getCapabilities(): Promise<DriverCapabilities>;
/** Free-form facts for a rageshake: crypto version, sync state, and so on. */
getDiagnostics?(): Promise<Record<string, string>>;
}
@@ -0,0 +1,89 @@
/*
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.
*/
import { describe, expect, it, vi } from "vitest";
import { MockElementCallMatrixClientDriver } from "./MockElementCallMatrixClientDriver";
import { MOCK_ROOM_ID } from "./MockRtcMatrixDriver";
describe("MockElementCallMatrixClientDriver", () => {
it("notifies room info, member and profile subscribers until unsubscribed", () => {
const driver = new MockElementCallMatrixClientDriver({
roomInfo: { name: "Standup" },
});
expect(driver.getRoomInfo().name).toBe("Standup");
const onInfo = vi.fn();
const off = driver.subscribeRoomInfo(onInfo);
driver.setRoomInfo({ name: "Retro" });
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
off();
driver.setRoomInfo({ name: "Planning" });
expect(onInfo).toHaveBeenCalledTimes(1);
const onMembers = vi.fn();
driver.subscribeRoomMembers(onMembers);
const alice = {
userId: "@alice:example.org",
displayName: "Alice",
avatarUrl: null,
membership: "join" as const,
};
driver.setRoomMembers([alice]);
expect(onMembers).toHaveBeenCalledWith([alice]);
expect(driver.getRoomMembers()).toEqual([alice]);
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
driver.setOwnProfile({ displayName: "Bob" });
expect(onProfile).toHaveBeenCalledWith({
displayName: "Bob",
avatarUrl: null,
});
});
it("records sent room events, echoes them, and resolves relations", async () => {
const driver = new MockElementCallMatrixClientDriver();
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(e.type));
const { eventId } = await driver.sendRoomEvent("m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$membership",
key: "🖐️",
},
});
expect(driver.calls("sendRoomEvent")[0]).toMatchObject({
eventType: "m.reaction",
eventId,
});
expect(seen).toEqual(["m.reaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(1);
await driver.redactEvent(eventId);
expect(seen).toEqual(["m.reaction", "m.room.redaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(0);
});
it("answers thumbnails for mxc urls only", async () => {
const driver = new MockElementCallMatrixClientDriver();
await expect(
driver.thumbnailUrl("mxc://example.org/abc", 96, 96, "crop"),
).resolves.toContain("abc");
await expect(
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
).resolves.toBeNull();
expect(driver.roomId).toBe(MOCK_ROOM_ID);
await expect(driver.getCapabilities()).resolves.toMatchObject({
stickyEvents: true,
});
});
});
@@ -0,0 +1,246 @@
/*
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.
*/
/**
* An {@link ElementCallMatrixClientDriver} whose room lives in memory, for
* tests and stories: room info, members, a timeline and a profile, with
* setters that notify subscribers, and a record of what was sent.
*/
import {
type DriverCapabilities,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "./ElementCallMatrixClientDriver";
import {
MOCK_OWN_DEVICE_ID,
MOCK_OWN_USER_ID,
MOCK_ROOM_ID,
} from "./MockRtcMatrixDriver";
export type ClientCall =
| {
kind: "sendRoomEvent";
eventType: string;
content: Record<string, unknown>;
eventId: string;
}
| { kind: "redactEvent"; eventId: string };
export interface MockElementCallMatrixClientDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
roomInfo?: Partial<RoomInfo>;
members?: RoomMemberProfile[];
ownProfile?: Partial<OwnProfile>;
capabilities?: Partial<DriverCapabilities>;
}
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: ClientCall[] = [];
private capabilities: DriverCapabilities;
private roomInfo: RoomInfo;
private members: RoomMemberProfile[];
private ownProfile: OwnProfile;
private readonly timeline: TimelineEvent[] = [];
private readonly roomInfoListeners = new Set<(info: RoomInfo) => void>();
private readonly memberListeners = new Set<
(members: RoomMemberProfile[]) => void
>();
private readonly timelineListeners = new Set<
(event: TimelineEvent) => void
>();
private readonly profileListeners = new Set<(profile: OwnProfile) => void>();
private nextEventId = 0;
public constructor(options: MockElementCallMatrixClientDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.capabilities = {
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
...options.capabilities,
};
this.roomInfo = {
name: "Test room",
canonicalAlias: null,
avatarUrl: null,
joinRule: "public",
encrypted: false,
canOpenSlot: true,
...options.roomInfo,
};
this.members = options.members ?? [];
this.ownProfile = {
displayName: "Me",
avatarUrl: null,
...options.ownProfile,
};
}
public calls<K extends ClientCall["kind"]>(
kind: K,
): Extract<ClientCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
ClientCall,
{ kind: K }
>[];
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return this.roomInfo;
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
return listen(this.roomInfoListeners, listener);
}
public setRoomInfo(info: Partial<RoomInfo>): void {
this.roomInfo = { ...this.roomInfo, ...info };
for (const l of this.roomInfoListeners) l(this.roomInfo);
}
public getRoomMembers(): RoomMemberProfile[] {
return this.members;
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
return listen(this.memberListeners, listener);
}
public setRoomMembers(members: RoomMemberProfile[]): void {
this.members = members;
for (const l of this.memberListeners) l(members);
}
// --- timeline --------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const eventId = `$echo-${this.nextEventId++}`;
this.outbound.push({ kind: "sendRoomEvent", eventType, content, eventId });
// Like sync, the room sees our event once the server has it.
this.emitTimelineEvent({
eventId,
type: eventType,
sender: this.userId,
content,
originServerTs: Date.now(),
});
return Promise.resolve({ eventId });
}
public async redactEvent(eventId: string): Promise<void> {
this.outbound.push({ kind: "redactEvent", eventId });
this.emitTimelineEvent({
eventId: `$echo-${this.nextEventId++}`,
type: "m.room.redaction",
sender: this.userId,
content: { redacts: eventId },
originServerTs: Date.now(),
redacts: eventId,
});
return Promise.resolve();
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
return listen(this.timelineListeners, listener);
}
/** A room event arrives from another user (or is echoed back). */
public emitTimelineEvent(event: TimelineEvent): void {
this.timeline.push(event);
for (const l of this.timelineListeners) l(event);
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const redacted = new Set(
this.timeline.flatMap((e) => (e.redacts ? [e.redacts] : [])),
);
return this.timeline.filter((e) => {
if (e.type !== eventType || redacted.has(e.eventId)) return false;
const relation = e.content["m.relates_to"] as
| { rel_type?: string; event_id?: string }
| undefined;
return relation?.rel_type === relType && relation.event_id === eventId;
});
}
// --- profile, media, capabilities --------------------------------------------
public getOwnProfile(): OwnProfile {
return this.ownProfile;
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
return listen(this.profileListeners, listener);
}
public setOwnProfile(profile: Partial<OwnProfile>): void {
this.ownProfile = { ...this.ownProfile, ...profile };
for (const l of this.profileListeners) l(this.ownProfile);
}
public async setDisplayName(name: string): Promise<void> {
this.setOwnProfile({ displayName: name });
return Promise.resolve();
}
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
_resizeMethod: "crop" | "scale",
): Promise<string | null> {
return Promise.resolve(
mxcUrl.startsWith("mxc://")
? `https://media.example.org/thumbnail/${mxcUrl.slice("mxc://".length)}?width=${width}&height=${height}`
: null,
);
}
public async getCapabilities(): Promise<DriverCapabilities> {
return Promise.resolve(this.capabilities);
}
public setCapabilities(capabilities: Partial<DriverCapabilities>): void {
this.capabilities = { ...this.capabilities, ...capabilities };
}
}
function listen<T>(listeners: Set<T>, listener: T): Unsubscribe {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
}
+171
View File
@@ -0,0 +1,171 @@
/*
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.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import {
FfiElementCallCompat,
FfiMatrixDriver,
FfiMembershipState,
FfiParticipationManager,
FfiStatus,
FfiTransportIntent,
type FfiParticipationConfig,
} from "../matrix-rtc-sdk";
import {
MOCK_LK_SERVICE_URL,
MOCK_OWN_USER_ID,
MOCK_SLOT_ID,
MockRtcMatrixDriver,
roomEncryptionEvent,
slotEvent,
waitFor,
} from "./MockRtcMatrixDriver";
const config: FfiParticipationConfig = {
compat: FfiElementCallCompat.StickyEvents,
manageMediaKeys: true,
requireCrossSignedSender: false,
useKeyDelayMs: 50n,
};
const joinParams = {
applicationType: "m.call",
intent: undefined,
stickyDurationMs: 240_000n,
keepAliveTimeoutMs: 15_000n,
degradedLifetimeMs: undefined,
delegateDelayedLeave: false,
};
const publish = (): FfiTransportIntent =>
new FfiTransportIntent.Publish({
transport: {
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
});
function newManager(driver: MockRtcMatrixDriver): FfiParticipationManager {
return new FfiParticipationManager(
driver.roomId,
MOCK_SLOT_ID,
driver.userId,
driver.deviceId,
new FfiMatrixDriver(driver),
config,
);
}
describe("MockRtcMatrixDriver as the crate's driver", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("echoes our sticky join so our own membership reaches the roster", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
await manager.join(publish(), joinParams);
expect(FfiStatus.Connected.instanceOf(manager.status())).toBe(true);
const sticky = driver.calls("stickyEvent");
expect(sticky).toHaveLength(1);
expect(sticky[0].eventType).toBe("org.matrix.msc4143.rtc.member");
const me = manager
.memberships()
.find((m) => m.member.userId === MOCK_OWN_USER_ID);
expect(me?.state).toBe(FfiMembershipState.Joined);
expect(me?.connections).toEqual([MOCK_LK_SERVICE_URL]);
expect(me?.transportIdentity).toBe(manager.ownTransportIdentity());
// the token the mock minted is what the crate hands out
expect(manager.connections()[0].connection.jwtToken).toBe(
`jwt-for-${MOCK_LK_SERVICE_URL}`,
);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("hosts peers that join, answer our key and leave", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [
roomEncryptionEvent(),
slotEvent({ status: "open", encrypted: true }),
],
});
const manager = newManager(driver);
const peer = driver.addPeer({
userId: "@peer:example.org",
deviceId: "PEERDEV",
memberId: "m-peer",
});
await manager.join(
new FfiTransportIntent.ReceiveOnly({ canSubscribe: ["livekit"] }),
joinParams,
);
driver.peerJoins(peer);
expect(manager.memberships().map((m) => m.member.userId)).toContain(
peer.userId,
);
await waitFor("key exchange", () =>
manager.keyMap().some((k) => k.memberId === peer.memberId),
);
// StickyEvents compat: our key went out in the deployed dialect
expect(driver.calls("toDevice")[0].eventType).toBe(
"io.element.call.encryption_keys",
);
driver.peerLeaves(peer);
expect(
manager.memberships().find((m) => m.member.memberId === peer.memberId)
?.state,
).toBe(FfiMembershipState.LeftWithKeys);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("reports homeserver connectivity into the crate", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
expect(manager.isHomeserverConnected()).toBe(true);
await manager.join(publish(), joinParams);
driver.setHomeserverConnected(false);
await waitFor("outage reported", () => !manager.isHomeserverConnected());
await waitFor("impairment", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
);
});
driver.setHomeserverConnected(true);
await waitFor("outage clears", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments.length === 0
);
});
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("can refuse sticky and delayed events like an old homeserver", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
driver.refuseStickyEvents = true;
const manager = newManager(driver);
await expect(manager.join(publish(), joinParams)).rejects.toThrow();
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
manager.uniffiDestroy();
});
});
+676
View File
@@ -0,0 +1,676 @@
/*
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.
*/
/**
* An {@link RtcMatrixDriver} with a homeserver made of arrays, for tests and
* stories.
*
* It models what sync would do: it records every outbound call, echoes
* accepted sticky and state events back through the room-event sink (so our
* own membership reaches the roster like anybody else's), answers `readState`
* from `roomState`, mints tokens, reports homeserver connectivity, and hosts
* simulated peers that answer our media key with theirs.
*
* A port of the crate's `web-test-app/src/mockDriver.ts`, which is the
* source of truth for the fabricated wire shapes below.
*/
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../matrix-rtc-sdk";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
export const MOCK_LK_SERVICE_URL = "https://lk.example.org";
export const MOCK_ROOM_ID = "!room:example.org";
/** MSC4143: a slot id is `{application_type}#{id}`; Element Call's is this. */
export const MOCK_SLOT_ID = "m.call#ROOM";
export const MOCK_OWN_USER_ID = "@me:example.org";
export const MOCK_OWN_DEVICE_ID = "MYDEV";
/** Every call the crate made on the driver, in order. */
export type OutboundCall =
| {
kind: "stickyEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
durationMs: bigint;
}
| {
kind: "stateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
}
| {
kind: "delayedEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
delayMs: bigint;
stickyDurationMs: bigint | undefined;
delayId: string;
}
| {
kind: "delayedStateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
delayMs: bigint;
delayId: string;
}
| { kind: "restartDelayed"; roomId: string; delayId: string }
| { kind: "cancelDelayed"; roomId: string; delayId: string }
| {
kind: "delegateDelayedLeave";
roomId: string;
slotId: string;
delayId: string;
livekitServiceUrl: string | undefined;
delayMs: bigint;
}
| {
kind: "toDevice";
recipients: FfiToDeviceRecipient[];
eventType: string;
content: Record<string, unknown>;
}
| { kind: "getRtcTransports" }
| {
kind: "getLivekitToken";
url: string;
roomId: string;
slotId: string;
member: Record<string, unknown>;
legacySfuGet: boolean;
};
/** A simulated remote participant. */
export interface RemotePeer {
userId: string;
deviceId: string;
memberId: string;
/** 32 key bytes; defaults to a constant pattern. */
key?: Uint8Array;
}
/** A raw Matrix event as the crate reads it. */
export type RawEvent = Record<string, unknown>;
export interface MockRtcMatrixDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
/** Room state answered by `readState` (the crate's session seed). */
roomState?: RawEvent[];
/** Advertised by `getRtcTransports`; an empty list is "none". */
transports?: FfiRtcTransport[];
}
export class MockRtcMatrixDriver implements RtcMatrixDriver {
/** Who this driver publishes as — not part of the contract, handy in tests. */
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: OutboundCall[] = [];
/** Refuse delayed events like a homeserver without MSC4140 (404). */
public refuseDelayedEvents = false;
/** Refuse sticky events like a homeserver without MSC4354 (404). */
public refuseStickyEvents = false;
/** Make `getRtcTransports` fail rather than answer. */
public failTransportDiscovery = false;
public roomState: RawEvent[];
public transports: FfiRtcTransport[];
/** Simulated peers answer our media key with theirs (index 0). */
public readonly peers: RemotePeer[] = [];
private roomEventSink?: RoomEventSinkLike;
private toDeviceSink?: ToDeviceSinkLike;
private stateUpdateSink?: StateUpdateSinkLike;
private connectivitySink?: ConnectivitySinkLike;
private homeserverConnected = true;
private nextDelayId = 0;
private nextEventId = 0;
public constructor(options: MockRtcMatrixDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.roomState = options.roomState ?? [];
this.transports = options.transports ?? [
{
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
];
}
// --- assertions ----------------------------------------------------------
public calls<K extends OutboundCall["kind"]>(
kind: K,
): Extract<OutboundCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
OutboundCall,
{ kind: K }
>[];
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({
kind: "stickyEvent",
roomId,
eventType,
content,
durationMs,
});
if (this.refuseStickyEvents)
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: sticky events are not supported",
);
const eventId = this.eventId();
// The homeserver echoes our event through sync.
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: Number(durationMs) },
content,
},
new FfiEventOrigin.Encrypted({ senderDeviceId: this.deviceId }),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({ kind: "stateEvent", roomId, eventType, stateKey, content });
const eventId = this.eventId();
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
state_key: stateKey,
origin_server_ts: Date.now(),
content,
},
new FfiEventOrigin.Cleartext(),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedEvent",
roomId,
eventType,
content: parse(contentJson),
delayMs,
stickyDurationMs,
delayId,
});
if (this.refuseDelayedEvents)
// 404 M_UNRECOGNIZED: "this homeserver will never do delayed events".
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: delayed events are not supported",
);
return Promise.resolve(delayId);
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedStateEvent",
roomId,
eventType,
stateKey,
content: parse(contentJson),
delayMs,
delayId,
});
return Promise.resolve(delayId);
}
public async restartDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "restartDelayed", roomId, delayId });
return Promise.resolve();
}
public async cancelDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "cancelDelayed", roomId, delayId });
return Promise.resolve();
}
public async delegateLivekitDelayedLeave(
roomId: string,
slotId: string,
_memberJson: string,
delayId: string,
livekitServiceUrl: string | undefined,
delayMs: bigint,
): Promise<void> {
this.record({
kind: "delegateDelayedLeave",
roomId,
slotId,
delayId,
livekitServiceUrl,
delayMs,
});
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
this.record({
kind: "toDevice",
recipients,
eventType,
content: parse(contentJson),
});
// Simulated peers answer with their own key.
for (const recipient of recipients) {
const peer = this.peers.find(
(p) =>
p.userId === recipient.userId && p.deviceId === recipient.deviceId,
);
if (peer) queueMicrotask(() => this.peerSendsKey(peer, 0));
}
// every recipient reachable
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
this.record({ kind: "getRtcTransports" });
if (this.failTransportDiscovery)
throw new RtcError.Http("500: transports endpoint unavailable");
return Promise.resolve(this.transports);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
this.record({
kind: "getLivekitToken",
url: request.url,
roomId: request.roomId,
slotId: request.slotId,
member: parse(request.memberJson),
legacySfuGet: request.legacySfuGet,
});
return Promise.resolve({
jwt: "jwt-for-" + request.url,
url: request.url.replace("https", "wss"),
});
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
return Promise.resolve(
this.roomState
.filter(
(e) =>
e.type === eventType &&
(stateKey === undefined || e.state_key === stateKey),
)
.map((e) => JSON.stringify(e)),
);
}
// --- inbound sinks -----------------------------------------------------------
// The crate subscribes exactly once, when its FfiMatrixDriver is built, and
// hands over sinks; a real driver hooks client listeners onto them. The
// mock stores them so tests can emit fabricated events.
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
this.roomEventSink = sink;
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
this.toDeviceSink = sink;
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
this.stateUpdateSink = sink;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.connectivitySink = sink;
}
public isHomeserverConnected(): boolean {
return this.homeserverConnected;
}
/** The homeserver comes or goes, as a syncing client would report it. */
public setHomeserverConnected(connected: boolean): void {
this.homeserverConnected = connected;
this.connectivitySink?.emit(connected);
}
/** Emit any room event — sticky or state; the crate dispatches on type. */
public emitRoomEvent(event: RawEvent, origin: FfiEventOrigin): boolean {
if (!this.roomEventSink)
throw new Error("The SDK has not subscribed to room events");
return this.roomEventSink.emit(JSON.stringify(event), origin);
}
/** `senderCrossSigned` is the MSC4153 verdict; peers are cross-signed by default. */
public emitToDevice(
eventType: string,
sender: string,
content: RawEvent,
origin: FfiEventOrigin,
senderCrossSigned: boolean | undefined = true,
): boolean {
if (!this.toDeviceSink)
throw new Error("The SDK has not subscribed to to-device events");
return this.toDeviceSink.emit(
eventType,
sender,
JSON.stringify(content),
origin,
senderCrossSigned,
);
}
public emitStateUpdate(events: RawEvent[]): boolean {
if (!this.stateUpdateSink)
throw new Error("The SDK has not subscribed to state updates");
return this.stateUpdateSink.emit(events.map((e) => JSON.stringify(e)));
}
// --- simulated peers --------------------------------------------------------
public addPeer(peer: RemotePeer): RemotePeer {
this.peers.push(peer);
return peer;
}
/** The peer publishes a join (on `MOCK_LK_SERVICE_URL` unless given). */
public peerJoins(
peer: RemotePeer,
opts: { lkServiceUrl?: string; durationMs?: number } = {},
): boolean {
return this.emitRoomEvent(
memberJoinEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
...opts,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerLeaves(peer: RemotePeer): boolean {
return this.emitRoomEvent(
memberLeaveEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerSendsKey(peer: RemotePeer, index: number): boolean {
return this.emitToDevice(
"m.rtc.encryption_key",
peer.userId,
encryptionKeyContent({
roomId: this.roomId,
memberId: peer.memberId,
index,
key: peer.key,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
// --- internals -----------------------------------------------------------------
private record(call: OutboundCall): void {
this.outbound.push(call);
}
private eventId(): string {
return `$echo-${this.nextEventId++}`;
}
private echo(event: RawEvent, origin: FfiEventOrigin): void {
this.roomEventSink?.emit(JSON.stringify(event), origin);
}
}
function parse(json: string): Record<string, unknown> {
return JSON.parse(json) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Inbound event fabrication — the MSC4143/MSC4354 wire shapes the crate's
// dispatch reads (see its src/session/dispatch.rs). Adjust here, not in
// every test.
// ---------------------------------------------------------------------------
let eventCounter = 0;
export function memberJoinEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
lkServiceUrl?: string;
durationMs?: number;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: opts.durationMs ?? 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
// MSC4354: the sticky key lives in the content and equals member.id.
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{
type: "livekit",
livekit_service_url: opts.lkServiceUrl ?? MOCK_LK_SERVICE_URL,
},
],
can_subscribe: ["livekit"],
},
},
};
}
export function memberLeaveEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "leave" },
leave_reason: { code: "leave" },
},
};
}
export function slotEvent(
opts: { roomId?: string; status: "open" | "closed"; encrypted?: boolean } = {
status: "open",
},
): RawEvent {
const content: Record<string, unknown> = {
status: opts.status,
application: { type: "m.call" },
};
if (opts.encrypted) content.encryption = { type: "m.per_member" };
return {
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: MOCK_SLOT_ID,
origin_server_ts: Date.now(),
content,
};
}
export function roomEncryptionEvent(roomId = MOCK_ROOM_ID): RawEvent {
return {
type: "m.room.encryption",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: roomId,
state_key: "",
origin_server_ts: Date.now(),
content: { algorithm: "m.megolm.v1.aes-sha2" },
};
}
/** `m.room.member` state with the profile fields the crate puts on a member. */
export function roomMemberEvent(opts: {
roomId?: string;
userId: string;
membership?: "join" | "invite" | "leave";
displayName?: string;
avatarUrl?: string;
}): RawEvent {
const content: Record<string, unknown> = {
membership: opts.membership ?? "join",
};
if (opts.displayName !== undefined) content.displayname = opts.displayName;
if (opts.avatarUrl !== undefined) content.avatar_url = opts.avatarUrl;
return {
type: "m.room.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: opts.userId,
origin_server_ts: Date.now(),
content,
};
}
const DEFAULT_KEY = new Uint8Array(32).fill(7);
function base64(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/=+$/, "");
}
/** MSC4143 `m.rtc.encryption_key` content. */
export function encryptionKeyContent(opts: {
roomId?: string;
memberId: string;
index: number;
key?: Uint8Array;
}): RawEvent {
return {
room_id: opts.roomId ?? MOCK_ROOM_ID,
member_id: opts.memberId,
media_key: { index: opts.index, key: base64(opts.key ?? DEFAULT_KEY) },
format: 0,
};
}
/** One timer tick: the crate's listener callbacks arrive after the emitting task yields. */
export const tick = async (): Promise<void> =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
export async function waitFor(
what: string,
cond: () => boolean,
timeoutMs = 3000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!cond()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for: ${what}`);
await new Promise((r) => setTimeout(r, 10));
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
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.
*/
/**
* The MatrixRTC half of what a host supplies: the `matrix-rtc` crate's own
* driver contract, taken verbatim from its bindings. Everything MatrixRTC —
* sticky and delayed events, to-device key delivery, transport tokens, the
* inbound event sinks and homeserver connectivity — goes through this and
* is consumed by the crate, never by Element Call directly.
*
* What a call needs from a Matrix client beyond MatrixRTC is the separate
* {@link ElementCallMatrixClientDriver}.
*/
import { type MatrixDriverCallback } from "../matrix-rtc-sdk";
export type RtcMatrixDriver = MatrixDriverCallback;
@@ -0,0 +1,123 @@
/*
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.
*/
import { MatrixEvent, RoomEvent, UserEvent } from "matrix-js-sdk";
import { describe, expect, it, vi } from "vitest";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import {
ME,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
} from "./jsSdkTestFakes";
describe("JsSdkElementCallMatrixClientDriver", () => {
it("serves room info, members, timeline and profile from a full client", async () => {
const client = fakeClient(false);
const room = fakeRoom();
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(client),
asRoom(room),
);
expect(driver.userId).toBe(ME);
expect(driver.getRoomInfo()).toEqual({
name: "Standup",
canonicalAlias: "#standup:example.org",
avatarUrl: "mxc://example.org/room",
joinRule: "public",
encrypted: true,
canOpenSlot: true,
});
const onInfo = vi.fn();
const offInfo = driver.subscribeRoomInfo(onInfo);
room.name = "Retro";
room.emit(RoomEvent.Name, room);
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
offInfo();
expect(driver.getRoomMembers()).toEqual([
{
userId: "@a:example.org",
displayName: "Alice",
avatarUrl: "mxc://example.org/alice",
membership: "join",
},
{
userId: "@b:example.org",
displayName: null,
avatarUrl: null,
membership: "invite",
},
]);
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(`${e.type}:${e.eventId}`));
const reaction = new MatrixEvent({
type: "m.reaction",
sender: "@a:example.org",
event_id: "$r1",
room_id: ROOM_ID,
origin_server_ts: 3,
content: { "m.relates_to": { rel_type: "m.annotation", key: "🖐️" } },
});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
await vi.waitFor(() => expect(seen).toEqual(["m.reaction:$r1"]));
await driver.sendRoomEvent("io.element.call.reaction", { emoji: "🎉" });
expect(client.sendEvent).toHaveBeenCalledWith(
ROOM_ID,
"io.element.call.reaction",
{ emoji: "🎉" },
);
await driver.redactEvent("$r1");
expect(client.redactEvent).toHaveBeenCalledWith(ROOM_ID, "$r1");
expect(driver.getOwnProfile()).toEqual({
displayName: "Me",
avatarUrl: "mxc://example.org/me",
});
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
const user = client.getUser(ME)!;
user.rawDisplayName = "Moi";
user.emit(UserEvent.DisplayName, undefined, user);
expect(onProfile).toHaveBeenCalledWith(
expect.objectContaining({ displayName: "Moi" }),
);
await expect(driver.getCapabilities()).resolves.toEqual({
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
});
await expect(driver.getDiagnostics()).resolves.toMatchObject({
matrix_backend: "jssdk",
crypto_version: "fake 1.0",
});
});
it("says what a widget client cannot vouch for and leaves media to the host bridge", async () => {
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(fakeClient(true)),
asRoom(fakeRoom()),
);
await expect(driver.getCapabilities()).resolves.toMatchObject({
verifiedEventOrigins: false,
crossSigningVerdicts: false,
});
// no token of its own: media comes through the host bridge instead
await expect(
driver.thumbnailUrl("mxc://example.org/x", 96, 96, "crop"),
).resolves.toBeNull();
});
});
@@ -0,0 +1,329 @@
/*
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.
*/
/**
* An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
* metadata and members, the room's timeline for reactions and notifications,
* the user's own profile, authenticated thumbnails and capability probes.
* Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
* they differ are marked "widget".
*/
import {
KnownMembership,
type MatrixClient,
type MatrixEvent,
type Room,
RoomEvent,
type RoomMember,
RoomStateEvent,
RoomWidgetClient,
UNSTABLE_MSC4354_STICKY_EVENTS,
type User,
UserEvent,
} from "matrix-js-sdk";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
import {
type DriverCapabilities,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "../ElementCallMatrixClientDriver";
/** The state event types `getRoomInfo()` is computed from. */
const ROOM_INFO_EVENT_TYPES = new Set([
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.join_rules",
"m.room.encryption",
"m.room.power_levels",
]);
export interface JsSdkElementCallMatrixClientDriverOptions {
logger?: Logger;
}
export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
private readonly logger: Logger;
/** Widget: no crypto backend, no access token, events without metadata. */
private readonly widget: boolean;
private capabilities: Promise<DriverCapabilities> | null = null;
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkElementCallMatrixClientDriverOptions = {},
) {
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (userId === null || deviceId === null)
throw new Error(
"The client must be logged in before it can drive a call",
);
this.userId = userId;
this.deviceId = deviceId;
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkElementCallMatrixClientDriver ${room.roomId}]`,
);
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return {
name: this.room.name,
canonicalAlias: this.room.getCanonicalAlias(),
avatarUrl: this.room.getMxcAvatarUrl(),
joinRule: this.room.currentState.getJoinRule() ?? null,
encrypted: this.room.hasEncryptionStateEvent(),
canOpenSlot: this.room.currentState.maySendStateEvent(
ELEMENT_CALL_SLOT_EVENT_TYPE,
this.userId,
),
};
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
const notify = (): void => listener(this.getRoomInfo());
const onState = (event: MatrixEvent): void => {
if (
event.getRoomId() === this.roomId &&
ROOM_INFO_EVENT_TYPES.has(event.getType())
)
notify();
};
this.room.on(RoomEvent.Name, notify);
this.client.on(RoomStateEvent.Events, onState);
return (): void => {
this.room.off(RoomEvent.Name, notify);
this.client.off(RoomStateEvent.Events, onState);
};
}
public getRoomMembers(): RoomMemberProfile[] {
const profile =
(membership: "join" | "invite") =>
(member: RoomMember): RoomMemberProfile => ({
userId: member.userId,
displayName: member.rawDisplayName ?? null,
avatarUrl: member.getMxcAvatarUrl() ?? null,
membership,
});
return [
...this.room
.getMembersWithMembership(KnownMembership.Join)
.map(profile("join")),
...this.room
.getMembersWithMembership(KnownMembership.Invite)
.map(profile("invite")),
];
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
const onMembers = (event: MatrixEvent): void => {
if (event.getRoomId() === this.roomId) listener(this.getRoomMembers());
};
this.client.on(RoomStateEvent.Members, onMembers);
return (): void => {
this.client.off(RoomStateEvent.Members, onMembers);
};
}
// --- timeline ----------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const res = await this.client.sendEvent(
this.roomId,
eventType as never,
content as never,
);
return { eventId: res.event_id };
}
public async redactEvent(eventId: string): Promise<void> {
await this.client.redactEvent(this.roomId, eventId);
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
// Our own events are seen twice — as the local echo, then as sent —
// and a redaction is both a timeline event and a Redaction signal.
const seen = new Set<string>();
const deliver = async (event: MatrixEvent): Promise<void> => {
if (event.getRoomId() !== this.roomId) return;
// Still sending: the LocalEchoUpdated listener gets the real id later.
if (event.status !== null) return;
if (event.unstableStickyInfo !== undefined || event.isState()) return;
const eventId = event.getId();
const sender = event.getSender();
if (!eventId || !sender || seen.has(eventId)) return;
try {
await this.client.decryptEventIfNeeded(event);
} catch (e) {
this.logger.warn(`Could not decrypt ${eventId}`, e);
}
if (event.isDecryptionFailure() || seen.has(eventId)) return;
seen.add(eventId);
if (seen.size > 1000) seen.delete(seen.values().next().value!);
listener({
eventId,
type: event.getType(),
sender,
content: event.getContent(),
originServerTs: event.getTs(),
redacts: event.event.redacts,
});
};
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId === this.roomId && !toStartOfTimeline)
void deliver(event);
};
const onEcho = (event: MatrixEvent): void => void deliver(event);
this.client.on(RoomEvent.Timeline, onTimeline);
this.room.on(RoomEvent.LocalEchoUpdated, onEcho);
this.room.on(RoomEvent.Redaction, onEcho);
return (): void => {
this.client.off(RoomEvent.Timeline, onTimeline);
this.room.off(RoomEvent.LocalEchoUpdated, onEcho);
this.room.off(RoomEvent.Redaction, onEcho);
};
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const relations = this.room.relations.getChildEventsForEvent(
eventId,
relType as never,
eventType as never,
);
return (relations?.getRelations() ?? [])
.filter(
(event) => !event.isRedacted() && event.getId() && event.getSender(),
)
.map((event) => ({
eventId: event.getId()!,
type: event.getType(),
sender: event.getSender()!,
content: event.getContent(),
originServerTs: event.getTs(),
}));
}
// --- profile -------------------------------------------------------------------
public getOwnProfile(): OwnProfile {
const user = this.client.getUser(this.userId);
return {
displayName: user?.rawDisplayName ?? null,
avatarUrl: user?.avatarUrl ?? null,
};
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
const user: User | null = this.client.getUser(this.userId);
if (user === null) return (): void => {};
const notify = (): void => listener(this.getOwnProfile());
user.on(UserEvent.DisplayName, notify);
user.on(UserEvent.AvatarUrl, notify);
return (): void => {
user.off(UserEvent.DisplayName, notify);
user.off(UserEvent.AvatarUrl, notify);
};
}
public async setDisplayName(name: string): Promise<void> {
await this.client.setDisplayName(name);
}
public async setAvatar(file: Blob): Promise<void> {
const { content_uri: uri } = await this.client.uploadContent(file);
await this.client.setAvatarUrl(uri);
}
// --- media -----------------------------------------------------------------------
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null> {
const httpUrl = this.client.mxcUrlToHttp(
mxcUrl,
width,
height,
resizeMethod,
false,
true,
true,
);
// Widget: no token of our own; the host bridge downloads media instead.
const token = this.client.getAccessToken();
if (httpUrl === null || token === null) return null;
const response = await fetch(httpUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
return URL.createObjectURL(await response.blob());
}
// --- capabilities and diagnostics ------------------------------------------------
public async getCapabilities(): Promise<DriverCapabilities> {
this.capabilities ??= this.probeCapabilities();
return this.capabilities;
}
private async probeCapabilities(): Promise<DriverCapabilities> {
const stickyEvents = await this.client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
.catch((e: unknown) => {
this.logger.warn("Could not probe sticky event support", e);
return false;
});
return {
stickyEvents,
verifiedEventOrigins: !this.widget,
crossSigningVerdicts: this.client.getCrypto() !== undefined,
};
}
public async getDiagnostics(): Promise<Record<string, string>> {
return Promise.resolve({
matrix_backend: this.widget ? "widget" : "jssdk",
crypto_version: this.client.getCrypto()?.getVersion() ?? "none",
sync_state: String(this.client.getSyncState()),
});
}
}
@@ -0,0 +1,488 @@
/*
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.
*/
import {
ClientEvent,
MatrixError,
MatrixEvent,
RoomStateEvent,
RoomStickyEventsEvent,
SyncState,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
} from "matrix-js-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
import {
LK,
ME,
MY_DEVICE,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
jsonResponse,
openIdToken,
type FakeClient,
type FakeRoom,
} from "./jsSdkTestFakes";
const memberJson = JSON.stringify({
id: "m-1",
claimed_user_id: ME,
claimed_device_id: MY_DEVICE,
});
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
describe("JsSdkRtcMatrixDriver", () => {
beforeEach(async () => {
// The bindings define the RtcError classes the driver throws.
await initMatrixRtcSdkForTests();
fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("over a full MatrixClient", () => {
it("sends sticky, delayed and state events through the unstable APIs", async () => {
const { client, driver } = fullClient();
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", '{"a":1}', 240_000n),
).resolves.toEqual({ eventId: "$sticky", delayId: undefined });
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
null,
"m.rtc.member",
{ a: 1 },
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
240_000n,
),
).resolves.toBe("delay-sticky");
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
undefined,
),
).resolves.toBe("delay-plain");
expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await driver.sendDelayedStateEvent(ROOM_ID, "m.x", "key", "{}", 5_000n);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 5_000 },
"m.x",
{},
"key",
);
await driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "m.call#ROOM", "{}");
expect(client.sendStateEvent).toHaveBeenCalledWith(
ROOM_ID,
"m.rtc.slot",
{},
"m.call#ROOM",
);
await driver.restartDelayedEvent(ROOM_ID, "d1");
await driver.cancelDelayedEvent(ROOM_ID, "d1");
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
1,
"d1",
UpdateDelayedEventAction.Restart,
);
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
2,
"d1",
UpdateDelayedEventAction.Cancel,
);
});
it("maps js-sdk failures onto the crate's error family", async () => {
const { client, driver } = fullClient();
client._unstable_sendStickyEvent.mockRejectedValueOnce(
new UnsupportedStickyEventsEndpointError("nope", "sendStickyEvent"),
);
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", "{}", 1n),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN" }, 403),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy((e) => RtcError.Rejected.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 1500 },
429,
),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy(
(e) =>
RtcError.RateLimited.instanceOf(e) && e.inner.retryAfterMs === 1500n,
);
});
it("sends to-device messages Olm-encrypted per device", async () => {
const { client, driver } = fullClient();
const recipients = [{ userId: "@a:example.org", deviceId: "ADEV" }];
await expect(
driver.sendToDevice(recipients, "m.rtc.encryption_key", '{"k":1}'),
).resolves.toEqual([{ recipient: recipients[0], error: undefined }]);
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
"m.rtc.encryption_key",
recipients,
{ k: 1 },
);
});
it("discovers transports through the client and answers in the crate's shape", async () => {
const { client, driver } = fullClient();
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: LK },
]);
await expect(driver.getRtcTransports()).resolves.toEqual([
{
transportType: "livekit",
propertiesJson: JSON.stringify({ livekit_service_url: LK }),
},
]);
});
it("exchanges an OpenID token for a LiveKit token, on either endpoint", async () => {
const { driver } = fullClient();
// a fresh Response per call: a body can be read once
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "the-jwt", url: "wss://sfu" })),
);
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).resolves.toEqual({ jwt: "the-jwt", url: "wss://sfu" });
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toEqual({
room_id: ROOM_ID,
slot_id: "m.call#ROOM",
openid_token: openIdToken,
member: JSON.parse(memberJson),
});
await driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: true,
});
const [legacyEndpoint, legacyInit] = fetchMock.mock
.calls[1] as unknown as [string, RequestInit];
expect(legacyEndpoint).toBe(`${LK}/sfu/get`);
expect(JSON.parse(legacyInit.body as string)).toEqual({
room: ROOM_ID,
openid_token: openIdToken,
device_id: MY_DEVICE,
});
fetchMock.mockResolvedValueOnce(new Response("gone", { status: 404 }));
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("delegates the delayed leave through the authorisation service's token endpoint", async () => {
const { driver } = fullClient();
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "discarded" })),
);
await driver.delegateLivekitDelayedLeave(
ROOM_ID,
"m.call#ROOM",
memberJson,
"delay-1",
LK,
3_600_000n,
);
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toMatchObject({
delay_id: "delay-1",
delay_timeout: 3_600_000,
delay_cs_api_url: "https://hs.example.org",
});
// receive-only: nothing to delegate to
await expect(
driver.delegateLivekitDelayedLeave(
ROOM_ID,
"m.call#ROOM",
memberJson,
"delay-1",
undefined,
1n,
),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("feeds sticky events and state updates into the crate's sinks with their origin", async () => {
const { client, room, driver } = fullClient();
const roomSink = sink();
const stateSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
driver.subscribeStateUpdates(stateSink as unknown as StateUpdateSinkLike);
const sticky = new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM" },
});
room.emit(RoomStickyEventsEvent.Update, [sticky], [], []);
await vi.waitFor(() => {
expect(roomSink.emit).toHaveBeenCalledTimes(1);
const [json, origin] = roomSink.emit.mock.calls[0] as unknown as [
string,
FfiEventOrigin,
];
expect(JSON.parse(json)).toMatchObject({
type: "m.rtc.member",
event_id: "$s1",
content: { slot_id: "m.call#ROOM" },
});
// not encrypted on this fake: the origin says so honestly
expect(FfiEventOrigin.Cleartext.instanceOf(origin)).toBe(true);
});
const slot = new MatrixEvent({
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: "$slot",
room_id: ROOM_ID,
state_key: "m.call#ROOM",
origin_server_ts: 2,
content: { status: "open" },
});
client.emit(RoomStateEvent.Events, slot, null, null);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
expect(
JSON.parse(
(stateSink.emit.mock.calls[0] as unknown as [string[]])[0][0],
),
).toMatchObject({ type: "m.rtc.slot", state_key: "m.call#ROOM" });
// another room's state is not ours
client.emit(
RoomStateEvent.Events,
new MatrixEvent({ ...slot.event, room_id: "!other:example.org" }),
null,
null,
);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
});
it("reports to-device messages with their Olm sender device and cross-signing verdict", async () => {
const { client, driver } = fullClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
client.emit(ClientEvent.ReceivedToDeviceMessage, {
message: {
type: "m.rtc.encryption_key",
sender: "@a:example.org",
content: { member_id: "m-a" },
},
encryptionInfo: {
sender: "@a:example.org",
senderDevice: "ADEV",
senderCurve25519KeyBase64: "k",
},
});
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [type, sender, json, origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean,
];
expect(type).toBe("m.rtc.encryption_key");
expect(sender).toBe("@a:example.org");
expect(JSON.parse(json)).toEqual({ member_id: "m-a" });
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBe(true);
});
it("reports homeserver connectivity from the sync state", () => {
const { client, driver } = fullClient();
expect(driver.isHomeserverConnected()).toBe(true);
const connectivity = sink();
driver.subscribeConnectivity(
connectivity as unknown as ConnectivitySinkLike,
);
client.getSyncState.mockReturnValue(SyncState.Error);
client.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
expect(connectivity.emit).toHaveBeenCalledWith(false);
expect(driver.isHomeserverConnected()).toBe(false);
client.getSyncState.mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Error);
expect(connectivity.emit).toHaveBeenLastCalledWith(true);
});
});
describe("over a RoomWidgetClient", () => {
it("listens for the legacy to-device event and reports the claimed device", async () => {
const { client, driver } = widgetClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
const event = new MatrixEvent({
type: "io.element.call.encryption_keys",
sender: "@a:example.org",
content: { member: { id: "m-a", claimed_device_id: "ADEV" } },
});
event.makeEncrypted("m.room.encrypted", {}, "", "");
client.emit(ClientEvent.ToDeviceEvent, event);
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [, , , origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean | undefined,
];
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBeUndefined();
});
it("treats member events in an encrypted room as encrypted by the claimed device", async () => {
const { room, driver } = widgetClient();
const roomSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
room.emit(
RoomStickyEventsEvent.Update,
[
new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM", member: { device_id: "ADEV" } },
}),
],
[],
[],
);
await vi.waitFor(() => expect(roomSink.emit).toHaveBeenCalledTimes(1));
const origin = (
roomSink.emit.mock.calls[0] as unknown as [string, FfiEventOrigin]
)[1];
expect(senderDeviceOf(origin)).toBe("ADEV");
});
});
});
function fullClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(false);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
function widgetClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(true);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
type SinkEmit = ReturnType<typeof vi.fn<(...args: unknown[]) => boolean>>;
function sink(): { emit: SinkEmit } {
return { emit: vi.fn<(...args: unknown[]) => boolean>(() => true) };
}
/** The sender device an origin carries, if it is an encrypted one. */
function senderDeviceOf(origin: FfiEventOrigin): string | undefined {
return FfiEventOrigin.Encrypted.instanceOf(origin)
? origin.inner.senderDeviceId
: undefined;
}
+630
View File
@@ -0,0 +1,630 @@
/*
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.
*/
/**
* An {@link RtcMatrixDriver} over a matrix-js-sdk client: the crate's
* MatrixRTC seam, nothing else. The standalone app, the widget and the SDK
* build hand this to Element Call, and a host that already runs matrix-js-sdk
* can use it as it is.
*
* It serves two kinds of client. A full `MatrixClient` syncs, decrypts and
* has an access token of its own. A `RoomWidgetClient` is a shell over the
* widget API: it never sees ciphertext (its host decrypts), has no crypto
* backend and no token, only emits the legacy to-device event, and answers
* transport discovery over the widget API. Every place the two differ is
* marked "widget".
*
* Adapted from the crate's `web-test-app/src/jsSdkDriver.ts`.
*/
import {
ClientEvent,
type IOpenIDToken,
type MatrixClient,
type MatrixEvent,
MatrixError,
type ReceivedToDeviceMessage,
type Room,
RoomEvent,
RoomStateEvent,
RoomStickyEventsEvent,
RoomWidgetClient,
SyncState,
UnsupportedDelayedEventsEndpointError,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
parseErrorResponse,
} from "matrix-js-sdk";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { doNetworkOperationWithRetry } from "../../utils/matrix";
import { type RtcMatrixDriver } from "../RtcMatrixDriver";
export interface JsSdkRtcMatrixDriverOptions {
logger?: Logger;
}
export class JsSdkRtcMatrixDriver implements RtcMatrixDriver {
private readonly roomId: string;
private readonly logger: Logger;
/** Widget: the host decrypts for us and there is no crypto backend. */
private readonly widget: boolean;
private readonly detachers: (() => void)[] = [];
/** curve25519 sender key → device id, per megolm-attributed sender. */
private readonly senderDeviceCache = new Map<string, string>();
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkRtcMatrixDriverOptions = {},
) {
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkRtcMatrixDriver ${room.roomId}]`,
);
}
/** Unhooks every client listener. The crate's sinks stop being fed. */
public detach(): void {
for (const detach of this.detachers.splice(0)) detach();
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client._unstable_sendStickyEvent(
roomId,
Number(durationMs),
null,
eventType as never,
JSON.parse(contentJson) as never,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client.sendStateEvent(
roomId,
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
return guard(async () => {
const content = JSON.parse(contentJson) as never;
const res =
stickyDurationMs === undefined
? await this.client._unstable_sendDelayedEvent(
roomId,
{ delay: Number(delayMs) },
null,
eventType as never,
content,
)
: await this.client._unstable_sendStickyDelayedEvent(
roomId,
Number(stickyDurationMs),
{ delay: Number(delayMs) },
null,
eventType as never,
content,
);
return res.delay_id;
});
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
return guard(async () => {
const res = await this.client._unstable_sendDelayedStateEvent(
roomId,
{ delay: Number(delayMs) },
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return res.delay_id;
});
}
public async restartDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Restart,
),
);
}
public async cancelDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Cancel,
),
);
}
/**
* MSC4195, the way Element Call has always done it: the MatrixRTC
* authorisation service takes over the delayed leave when asked for a token
* with `delay_id`, `delay_timeout` and the homeserver it should restart it
* at. The token in the answer is discarded. (Interim: plan item C5 moves
* the choice of route into the crate and leaves only primitives here.)
*/
public async delegateLivekitDelayedLeave(
roomId: string,
slotId: string,
memberJson: string,
delayId: string,
livekitServiceUrl: string | undefined,
delayMs: bigint,
): Promise<void> {
if (livekitServiceUrl === undefined)
throw new RtcError.Unsupported(
"A receive-only member has no transport to delegate to",
);
await guard(async () => {
const member = JSON.parse(memberJson) as MemberClaims;
const delegation = {
delay_id: delayId,
delay_timeout: Number(delayMs),
delay_cs_api_url: this.client.baseUrl,
};
await this.requestToken(
livekitServiceUrl,
roomId,
slotId,
member,
false,
delegation,
);
});
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
return guard(async () => {
// Olm-encrypted, per specific device — never `*`. On a widget client
// this asks the host to encrypt; the plain `sendToDevice` there would
// go out in clear.
await this.client.encryptAndSendToDevice(
eventType,
recipients,
JSON.parse(contentJson) as Record<string, unknown>,
);
return recipients.map((recipient) => ({ recipient, error: undefined }));
});
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return guard(async () => {
// The homeserver endpoint (MSC4143), or the widget host's answer to
// the same question (MSC4515) — the client knows which.
const transports = await doNetworkOperationWithRetry(async () =>
this.client._unstable_getRTCTransports(),
);
return transports.map(({ type, ...properties }: Transport) => ({
transportType: String(type),
propertiesJson: JSON.stringify(properties),
}));
});
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return guard(async () => {
const token = await this.requestToken(
request.url,
request.roomId,
request.slotId,
JSON.parse(request.memberJson) as MemberClaims,
request.legacySfuGet,
);
return { jwt: token.jwt, url: token.url };
});
}
private async requestToken(
serviceUrl: string,
roomId: string,
slotId: string,
member: MemberClaims,
legacySfuGet: boolean,
delegation: Record<string, unknown> = {},
): Promise<{ jwt: string; url?: string }> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
this.client.getOpenIdToken(),
);
} catch (e) {
throw new RtcError.Http(`Could not get an OpenID token: ${String(e)}`);
}
const base = serviceUrl.replace(/\/$/, "");
const [endpoint, body] = legacySfuGet
? [
`${base}/sfu/get`,
{
// The legacy endpoint derives the LiveKit room alias from the
// Matrix room id alone.
room: roomId,
openid_token: openIdToken,
device_id: member.claimed_device_id,
...delegation,
},
]
: [
`${base}/get_token`,
{
room_id: roomId,
slot_id: slotId,
openid_token: openIdToken,
member,
...delegation,
},
];
const response = await doNetworkOperationWithRetry(async () =>
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
if (!response.ok) {
const text = await response.text();
if (response.status === 404)
throw new RtcError.Unsupported(`${endpoint}: ${response.status}`);
throw parseErrorResponse(response, text);
}
return (await response.json()) as { jwt: string; url?: string };
}
// --- reads (the crate's session seed) ---------------------------------------
public async readEvents(
eventType: string,
_stateKey: string | undefined,
limit: number,
): Promise<string[]> {
const out: string[] = [];
for (const event of this.room._unstable_getStickyEvents()) {
await this.client.decryptEventIfNeeded(event);
if (event.getType() !== eventType) continue;
out.push(JSON.stringify(rawEvent(event)));
if (out.length >= limit) break;
}
return out;
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
const events =
stateKey === undefined
? this.room.currentState.getStateEvents(eventType)
: [this.room.currentState.getStateEvents(eventType, stateKey)].filter(
(e): e is MatrixEvent => Boolean(e),
);
return Promise.resolve(
events.map((event) => JSON.stringify(rawEvent(event))),
);
}
// --- inbound sinks ----------------------------------------------------------
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
const emit = async (event: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
this.logger.warn(`Event ${event.getId()} failed to decrypt; skipped`);
return;
}
if (
!sink.emit(
JSON.stringify(rawEvent(event)),
await this.roomEventOrigin(event),
)
)
detach();
};
// Sticky events come from the room's sticky store: sync delivers them in
// the room's `msc4354_sticky` section, our own included.
const onSticky = (
added: MatrixEvent[],
updated: { current: MatrixEvent }[],
): void => {
for (const event of [...added, ...updated.map((u) => u.current)])
void emit(event);
};
// Everything else (state events in the timeline) comes from the timeline;
// local echoes and sticky events are skipped there.
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId !== this.roomId || toStartOfTimeline) return;
if (event.status !== null || event.unstableStickyInfo !== undefined)
return;
void emit(event);
};
const detach = (): void => {
this.room.off(RoomStickyEventsEvent.Update, onSticky);
this.client.off(RoomEvent.Timeline, onTimeline);
};
this.room.on(RoomStickyEventsEvent.Update, onSticky);
this.client.on(RoomEvent.Timeline, onTimeline);
this.detachers.push(detach);
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
// Client-level listener: the room-level re-emit is unreliable under
// MSC4222 `state_after` churn.
const onState = (event: MatrixEvent): void => {
if (event.getRoomId() !== this.roomId) return;
if (!sink.emit([JSON.stringify(rawEvent(event))])) detach();
};
const detach = (): void => {
this.client.off(RoomStateEvent.Events, onState);
};
this.client.on(RoomStateEvent.Events, onState);
this.detachers.push(detach);
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
if (this.widget) {
// Widget: the legacy event is the only one the widget client emits.
// The host decrypted the message; it says whether it was encrypted but
// not by which device, so the device is the one the content claims.
const onToDevice = (event: MatrixEvent): void => {
const content = event.getContent() as KeyMessageContent;
const origin = event.isEncrypted()
? new FfiEventOrigin.Encrypted({
senderDeviceId: claimedKeyDevice(content),
})
: new FfiEventOrigin.Cleartext();
if (
!sink.emit(
event.getType(),
event.getSender() ?? "",
JSON.stringify(content),
origin,
undefined,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ToDeviceEvent, onToDevice);
};
this.client.on(ClientEvent.ToDeviceEvent, onToDevice);
this.detachers.push(detach);
return;
}
const onToDevice = (received: ReceivedToDeviceMessage): void =>
void handleToDevice(received);
const handleToDevice = async ({
message,
encryptionInfo,
}: ReceivedToDeviceMessage): Promise<void> => {
const origin = encryptionInfo
? new FfiEventOrigin.Encrypted({
senderDeviceId: encryptionInfo.senderDevice,
})
: new FfiEventOrigin.Cleartext();
// MSC4153: is the sending device cross-signed by its owner?
let crossSigned: boolean | undefined;
const crypto = this.client.getCrypto();
if (crypto && encryptionInfo?.senderDevice) {
const status = await crypto.getDeviceVerificationStatus(
encryptionInfo.sender,
encryptionInfo.senderDevice,
);
crossSigned = status?.signedByOwner ?? false;
}
if (
!sink.emit(
message.type,
message.sender,
JSON.stringify(message.content ?? {}),
origin,
crossSigned,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ReceivedToDeviceMessage, onToDevice);
};
this.client.on(ClientEvent.ReceivedToDeviceMessage, onToDevice);
this.detachers.push(detach);
}
// --- connectivity ------------------------------------------------------------
public isHomeserverConnected(): boolean {
// Widget: the widget client reports Syncing once it has seen an event
// and never anything else, so on a widget this is always true.
return this.client.getSyncState() === SyncState.Syncing;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
const onSync = (): void => {
if (!sink.emit(this.isHomeserverConnected())) detach();
};
const detach = (): void => {
this.client.off(ClientEvent.Sync, onSync);
};
this.client.on(ClientEvent.Sync, onSync);
this.detachers.push(detach);
}
private async roomEventOrigin(event: MatrixEvent): Promise<FfiEventOrigin> {
if (this.widget) {
// Widget: events arrive decrypted with no metadata. In an encrypted
// room they were encrypted, by the device the content claims — the
// same trust matrix-js-sdk's own session extends.
if (event.isState() || !this.room.hasEncryptionStateEvent())
return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: claimedMemberDevice(event.getContent()),
});
}
if (!event.isEncrypted()) return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: await this.senderDeviceOf(event),
});
}
/** The device that megolm-encrypted `event` (sender key → device list). */
private async senderDeviceOf(
event: MatrixEvent,
): Promise<string | undefined> {
const senderKey = event.getSenderKey();
const sender = event.getSender();
const crypto = this.client.getCrypto();
if (!senderKey || !sender || !crypto) return undefined;
const cached = this.senderDeviceCache.get(senderKey);
if (cached) return cached;
const devices = await crypto.getUserDeviceInfo([sender], true);
for (const device of devices.get(sender)?.values() ?? []) {
if (device.getIdentityKey() === senderKey) {
this.senderDeviceCache.set(senderKey, device.deviceId);
return device.deviceId;
}
}
return undefined;
}
}
/** The full (decrypted) event object the crate's dispatch reads. */
function rawEvent(event: MatrixEvent): Record<string, unknown> {
return {
...(event.event as Record<string, unknown>),
type: event.getType(),
content: event.getContent(),
sender: event.getSender(),
event_id: event.getId(),
room_id: event.getRoomId(),
origin_server_ts: event.getTs(),
state_key: event.getStateKey(),
};
}
/** MSC4195 member claims, as the crate serialises them. */
interface MemberClaims {
id: string;
claimed_user_id: string;
claimed_device_id: string;
}
/** The device a media key message claims to come from, in either dialect. */
interface KeyMessageContent {
member?: { claimed_device_id?: string };
device_id?: string;
}
function claimedKeyDevice(content: KeyMessageContent): string | undefined {
return content.member?.claimed_device_id ?? content.device_id;
}
/** The device a 2025-dialect membership claims (`member.device_id`). */
function claimedMemberDevice(
content: Record<string, unknown>,
): string | undefined {
const member = content.member as { device_id?: unknown } | undefined;
return typeof member?.device_id === "string" ? member.device_id : undefined;
}
/** Map js-sdk / HTTP failures onto the error the crate reasons about. */
function toRtcError(error: unknown): Error {
if (RtcError.instanceOf(error)) return error;
if (
error instanceof UnsupportedDelayedEventsEndpointError ||
error instanceof UnsupportedStickyEventsEndpointError
)
return new RtcError.Unsupported(String(error));
if (error instanceof MatrixError) {
if (error.errcode === "M_LIMIT_EXCEEDED")
return new RtcError.RateLimited({
retryAfterMs:
typeof error.data.retry_after_ms === "number"
? BigInt(error.data.retry_after_ms)
: undefined,
});
if (error.httpStatus === 404 || error.errcode === "M_UNRECOGNIZED")
return new RtcError.Unsupported(String(error));
if (error.httpStatus === 403 || error.errcode === "M_FORBIDDEN")
return new RtcError.Rejected(String(error));
return new RtcError.Http(String(error));
}
return new RtcError.Driver(String(error));
}
async function guard<T>(f: () => Promise<T>): Promise<T> {
try {
return await f();
} catch (error) {
throw toRtcError(error);
}
}
+150
View File
@@ -0,0 +1,150 @@
/*
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.
*/
// Fakes shared by the js-sdk driver tests. The drivers tell the two client
// kinds apart by prototype, so each fake is created on the real prototype
// and given only the members it needs; EventEmitter sits at the bottom of
// that chain, so `on`/`emit` work.
import { EventEmitter } from "events";
import {
MatrixClient,
type Room,
RoomWidgetClient,
SyncState,
User,
} from "matrix-js-sdk";
import { vi } from "vitest";
export const ROOM_ID = "!room:example.org";
export const ME = "@me:example.org";
export const MY_DEVICE = "MYDEV";
export const LK = "https://lk.example.org";
export const openIdToken = {
access_token: "openid",
expires_in: 3600,
matrix_server_name: "example.org",
token_type: "Bearer",
};
type Fn = ReturnType<typeof vi.fn>;
export interface FakeClient extends EventEmitter {
_unstable_sendStickyEvent: Fn;
_unstable_sendDelayedEvent: Fn;
_unstable_sendStickyDelayedEvent: Fn;
_unstable_sendDelayedStateEvent: Fn;
_unstable_updateDelayedEvent: Fn;
_unstable_getRTCTransports: Fn;
sendStateEvent: Fn;
encryptAndSendToDevice: Fn;
sendEvent: Fn;
redactEvent: Fn;
getSyncState: Fn;
getUser: (userId: string) => User | null;
}
export interface FakeRoom extends EventEmitter {
name: string;
}
function clientMembers(widget: boolean): Record<string, unknown> {
const user = new User(ME);
user.rawDisplayName = "Me";
user.avatarUrl = "mxc://example.org/me";
const verification = { signedByOwner: true };
return {
baseUrl: "https://hs.example.org",
getUserId: () => ME,
getDeviceId: () => MY_DEVICE,
getAccessToken: () => (widget ? null : "token"),
getCrypto: () =>
widget
? undefined
: {
getVersion: () => "fake 1.0",
getDeviceVerificationStatus: async () =>
Promise.resolve(verification),
getUserDeviceInfo: async () => Promise.resolve(new Map()),
},
getSyncState: vi.fn(() => SyncState.Syncing),
getUser: () => user,
getOpenIdToken: async () => Promise.resolve(openIdToken),
decryptEventIfNeeded: async () => Promise.resolve(),
doesServerSupportUnstableFeature: async () => Promise.resolve(true),
mxcUrlToHttp: (mxc: string) => `https://hs.example.org/media/${mxc}`,
_unstable_sendStickyEvent: vi.fn(async () =>
Promise.resolve({ event_id: "$sticky" }),
),
_unstable_sendDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-plain" }),
),
_unstable_sendStickyDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-sticky" }),
),
_unstable_sendDelayedStateEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-state" }),
),
_unstable_updateDelayedEvent: vi.fn(async () => Promise.resolve({})),
_unstable_getRTCTransports: vi.fn(async () => Promise.resolve([])),
sendStateEvent: vi.fn(async () => Promise.resolve({ event_id: "$state" })),
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
sendEvent: vi.fn(async () => Promise.resolve({ event_id: "$sent" })),
redactEvent: vi.fn(async () => Promise.resolve({ event_id: "$redaction" })),
};
}
export function fakeRoom(): FakeRoom {
const room = new EventEmitter() as FakeRoom;
const alice = {
userId: "@a:example.org",
rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://example.org/alice",
};
const bob = {
userId: "@b:example.org",
rawDisplayName: undefined,
getMxcAvatarUrl: () => undefined,
};
Object.assign(room, {
roomId: ROOM_ID,
name: "Standup",
getCanonicalAlias: () => "#standup:example.org",
getMxcAvatarUrl: () => "mxc://example.org/room",
hasEncryptionStateEvent: () => true,
currentState: {
getJoinRule: () => "public",
getStateEvents: () => [],
maySendStateEvent: () => true,
},
getMembersWithMembership: (membership: string) =>
membership === "join" ? [alice] : [bob],
_unstable_getStickyEvents: () => [],
relations: { getChildEventsForEvent: () => undefined },
});
return room;
}
export function fakeClient(widget: boolean): FakeClient {
const client = Object.create(
widget ? RoomWidgetClient.prototype : MatrixClient.prototype,
) as FakeClient;
Object.assign(client, clientMembers(widget));
return client;
}
export const asClient = (client: FakeClient): MatrixClient =>
client as unknown as MatrixClient;
export const asRoom = (room: FakeRoom): Room => room as unknown as Room;
export function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
+29
View File
@@ -0,0 +1,29 @@
/*
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.
*/
import { Observable } from "rxjs";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { type Unsubscribe } from "./ElementCallMatrixClientDriver";
/**
* Turns one of a driver's `get` / `subscribe` pairs into a behavior owned by
* `scope`: the current value now, every change until the scope ends.
*/
export function observeDriver<T>(
scope: ObservableScope,
get: () => T,
subscribe: (listener: (value: T) => void) => Unsubscribe,
): Behavior<T> {
return scope.behavior(
new Observable<T>((subscriber) =>
subscribe((value) => subscriber.next(value)),
),
get(),
);
}
+2
View File
@@ -0,0 +1,2 @@
matrix-rtc (MatrixSdkArchitectureDraft) a095ba7-dirty
built 2026-09-15T16:54:50Z by scripts/sync-matrix-rtc-sdk.sh
+103
View File
@@ -0,0 +1,103 @@
// This file was autogenerated by some hot garbage in the `uniffi-bindgen-react-native` crate.
// Trust me, you don't want to mess with it!
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
// Structs and function types for calling back into Typescript from Rust.
export type UniffiRustFutureContinuationCallback = (data: bigint, pollResult: number) => void;
export type UniffiForeignFutureDroppedCallback = (handle: bigint) => void;
export type UniffiForeignFutureDroppedCallbackStruct = {
handle: bigint;
free: UniffiForeignFutureDroppedCallback;
};
type UniffiCallbackInterfaceMatrixRtcConnectionsListenerMethod0 = (uniffiHandle: bigint, connections: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcConnectionsListener = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcConnectionsListener = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcConnectionsListener = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcConnectionsListener;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcConnectionsListener;
on_connections_change: UniffiCallbackInterfaceMatrixRtcConnectionsListenerMethod0;
};
type UniffiCallbackInterfaceMatrixRtcKeyMapListenerMethod0 = (uniffiHandle: bigint, keyMap: Uint8Array, change: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcKeyMapListener = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcKeyMapListener = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcKeyMapListener = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcKeyMapListener;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcKeyMapListener;
on_key_map_change: UniffiCallbackInterfaceMatrixRtcKeyMapListenerMethod0;
};
type UniffiCallbackInterfaceMatrixRtcKeyRejectedListenerMethod0 = (uniffiHandle: bigint, memberId: Uint8Array, reason: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcKeyRejectedListener = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcKeyRejectedListener = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcKeyRejectedListener;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcKeyRejectedListener;
on_key_rejected: UniffiCallbackInterfaceMatrixRtcKeyRejectedListenerMethod0;
};
export type UniffiForeignFutureResultRustBuffer = {
return_value: Uint8Array;
call_status: UniffiRustCallStatus;
};
export type UniffiForeignFutureCompleterustBuffer = (callbackData: bigint, result: UniffiForeignFutureResultRustBuffer) => void;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod0 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, durationMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod1 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, stateKey: Uint8Array, contentJson: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod2 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, delayMs: bigint, stickyDurationMs: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod3 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, stateKey: Uint8Array, contentJson: Uint8Array, delayMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
export type UniffiForeignFutureResultVoid = {
call_status: UniffiRustCallStatus;
};
export type UniffiForeignFutureCompletevoid = (callbackData: bigint, result: UniffiForeignFutureResultVoid) => void;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod4 = (uniffiHandle: bigint, roomId: Uint8Array, delayId: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod5 = (uniffiHandle: bigint, roomId: Uint8Array, delayId: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6 = (uniffiHandle: bigint, roomId: Uint8Array, slotId: Uint8Array, memberJson: Uint8Array, delayId: Uint8Array, livekitServiceUrl: Uint8Array, delayMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7 = (uniffiHandle: bigint, recipients: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8 = (uniffiHandle: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9 = (uniffiHandle: bigint, request: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, limit: number, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod13 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod14 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15 = (uniffiHandle: bigint) => number;
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcMatrixDriverCallback = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcMatrixDriverCallback = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcMatrixDriverCallback;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcMatrixDriverCallback;
send_sticky_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod0;
send_state_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod1;
send_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod2;
send_delayed_state_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod3;
restart_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod4;
cancel_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod5;
delegate_livekit_delayed_leave: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6;
send_to_device: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7;
get_rtc_transports: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8;
get_livekit_token: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9;
read_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10;
read_state: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11;
subscribe_room_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12;
subscribe_to_device_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod13;
subscribe_state_updates: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod14;
is_homeserver_connected: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15;
subscribe_connectivity: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16;
};
type UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0 = (uniffiHandle: bigint, memberships: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcMembershipsListener = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcMembershipsListener = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcMembershipsListener;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener;
on_memberships_change: UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0;
};
type UniffiCallbackInterfaceMatrixRtcStatusListenerMethod0 = (uniffiHandle: bigint, status: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcStatusListener = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcStatusListener = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcStatusListener = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcStatusListener;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcStatusListener;
on_status_change: UniffiCallbackInterfaceMatrixRtcStatusListenerMethod0;
};
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
/*
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.
*/
// Hand-written declaration for the wasm-bindgen glue next to it, which ubrn
// emits as plain JavaScript. Only the loading entry points are declared; the
// exported `ubrn_*` FFI functions are reached through matrix_rtc.ts alone.
// Kept out of the sync script's way: it is not generated.
/** What wasm-bindgen's loader accepts as the module to instantiate. */
export type InitInput =
| RequestInfo
| URL
| Response
| BufferSource
| WebAssembly.Module;
export default function initAsync(
moduleOrPath?:
| { module_or_path?: InitInput | Promise<InitInput> }
| InitInput
| Promise<InitInput>,
): Promise<unknown>;
export function initSync(
module: { module: BufferSource | WebAssembly.Module } | BufferSource | WebAssembly.Module,
): unknown;
File diff suppressed because it is too large Load Diff
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
/*
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.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import {
FfiElementCallCompat,
FfiMatrixDriver,
FfiParticipationManager,
FfiStatus,
computeSessionsFromEvents,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type MatrixDriverCallback,
} from ".";
const ROOM_ID = "!room:example.org";
describe("matrix-rtc-sdk", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("computes a session from raw events without a manager", () => {
const join = JSON.stringify({
type: "m.rtc.member",
sender: "@alice:example.org",
event_id: "$1",
room_id: ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: "m.call#ROOM",
msc4354_sticky_key: "m-1",
member: { id: "m-1", membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
can_subscribe: ["livekit"],
},
},
});
const [session] = computeSessionsFromEvents(
[join],
FfiElementCallCompat.StickyEvents,
);
expect(session.memberCount).toBe(1);
expect(session.members[0].eventId).toBe("$1");
expect(session.isActive).toBe(true);
});
it("constructs a manager over a TypeScript driver and starts disconnected", () => {
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.StickyEvents,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
expect(manager.memberships()).toEqual([]);
expect(manager.ownTransportIdentity()).toBeUndefined();
manager.uniffiDestroy();
});
});
/** A driver that answers every read with nothing and never sends. */
class InertDriver implements MatrixDriverCallback {
public async sendStickyEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$sticky", delayId: undefined });
}
public async sendStateEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$state", delayId: undefined });
}
public async sendDelayedEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async sendDelayedStateEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async restartDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async cancelDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async delegateLivekitDelayedLeave(): Promise<void> {
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
): Promise<FfiToDeviceDelivery[]> {
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return Promise.resolve([]);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return Promise.resolve({ jwt: "jwt", url: request.url });
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(): Promise<string[]> {
return Promise.resolve([]);
}
public subscribeRoomEvents(): void {}
public subscribeToDeviceEvents(): void {}
public subscribeStateUpdates(): void {}
public subscribeConnectivity(): void {}
public isHomeserverConnected(): boolean {
return true;
}
}
+90
View File
@@ -0,0 +1,90 @@
/*
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.
*/
/**
* The MatrixRTC SDK: the Rust `matrix-rtc` crate through its uniffi web
* bindings. Everything Element Call needs to *participate* in a MatrixRTC
* session — the session projection, our own membership with its keep-alive,
* transport tokens and the media key exchange — lives in the crate; this
* module loads it and re-exports the surface Element Call uses.
*
* The bindings under `generated/` are vendored by
* `scripts/sync-matrix-rtc-sdk.sh` and never edited by hand.
*/
import initAsync, { type InitInput } from "./generated/wasm-bindgen/index.js";
import bindings from "./generated/matrix_rtc";
export {
FfiMatrixDriver,
FfiParticipationManager,
FfiElementCallCompat,
FfiEventOrigin,
FfiStatus,
FfiDisconnectCause,
FfiJoinError,
FfiKeepAlive,
FfiMembershipState,
FfiTransportIntent,
RtcError,
computeSessionsFromEvents,
} from "./generated/matrix_rtc";
export type {
FfiConnectionData,
FfiConnectionWithMembers,
FfiJoinParams,
FfiLivekitToken,
FfiLivekitTokenRequest,
FfiMediaKey,
FfiMember,
FfiMembership,
FfiParticipationConfig,
FfiRtcTransport,
FfiSendEventResponse,
FfiSessionSnapshot,
FfiToDeviceDelivery,
FfiToDeviceRecipient,
ConnectivitySinkLike,
MatrixDriverCallback,
RoomEventSinkLike,
StateUpdateSinkLike,
ToDeviceSinkLike,
} from "./generated/matrix_rtc";
/**
* Where to load the wasm from. A URL (or anything `fetch` accepts) in a
* browser; bytes or a compiled module where there is nothing to fetch from,
* such as tests.
*/
export type MatrixRtcWasmSource = InitInput;
let loading: Promise<void> | null = null;
/**
* Loads and initialises the SDK. Idempotent: the first call decides the
* source, later calls await the same load.
*
* Without a `source`, the wasm is the one bundled next to this module (an
* asset URL in the app builds). A host that serves the file from somewhere
* else, or a test runner with no server, passes its own.
*/
export async function initMatrixRtcSdk(
source?: MatrixRtcWasmSource,
): Promise<void> {
loading ??= (async (): Promise<void> => {
await initAsync({ module_or_path: source ?? (await bundledWasm()) });
bindings.initialize();
})();
await loading;
}
/** The wasm as the bundler placed it, resolved lazily so nothing is fetched until needed. */
async function bundledWasm(): Promise<string> {
const { default: url } =
await import("./generated/wasm-bindgen/index_bg.wasm?url");
return url;
}
+393
View File
@@ -0,0 +1,393 @@
/*
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.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import {
MOCK_LK_SERVICE_URL,
MockRtcMatrixDriver,
roomEncryptionEvent,
slotEvent,
waitFor,
} from "../../driver/MockRtcMatrixDriver";
import {
FfiDisconnectCause,
FfiElementCallCompat,
FfiStatus,
type FfiMediaKey,
} from "../../matrix-rtc-sdk";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { testScope } from "../../utils/test";
import { ObservableScope } from "../ObservableScope";
import { CallParticipation } from "./CallParticipation";
import { errorForStatus } from "./errors";
import {
compatForMode,
joinParamsFromConfig,
participationConfig,
} from "./joinParams";
import { publishOnLivekit, receiveOnly } from "./transportIntent";
import {
MatrixRTCTransportMissingError,
NoOpenSlotError,
StickyEventsRequiredError,
} from "../../utils/errors";
const session = {
delayed_leave: { delay_ms: 15_000 },
delegated_delayed_leave: { delay_ms: 3_600_000 },
network_error_retry_ms: 1000,
wait_for_key_rotation_ms: 50,
};
const joinParams = joinParamsFromConfig({
session,
delegateDelayedLeave: false,
});
function create(
driver: MockRtcMatrixDriver,
overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {},
scope = testScope(),
): CallParticipation {
return new CallParticipation(
scope,
driver,
driver.roomId,
driver.userId,
driver.deviceId,
{
config: participationConfig({
mode: MatrixRTCMode.Matrix_2_0,
manageMediaKeys: overrides.manageMediaKeys ?? true,
session,
}),
transportFallbackUrl: overrides.transportFallbackUrl,
},
);
}
/** A room where nobody has started a call yet, and we may. */
const openSlot = { encrypted: false, canOpen: true };
const peer = {
userId: "@peer:example.org",
deviceId: "PEERDEV",
memberId: "m-peer",
};
describe("CallParticipation", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("starts disconnected and follows a remote member in and out", async () => {
// somebody already started the call: the slot is open
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const callParticipation = create(driver);
expect(
FfiStatus.Disconnected.instanceOf(callParticipation.status$.value),
).toBe(true);
expect(callParticipation.memberships$.value.value).toEqual([]);
driver.peerJoins(peer);
// getters are fresh at once; the listener fires a tick later
await waitFor(
"peer listed",
() => callParticipation.memberships$.value.value.length === 1,
);
const [membership] = callParticipation.memberships$.value.value;
expect(membership.member.memberId).toBe(peer.memberId);
expect(membership.connections).toEqual([MOCK_LK_SERVICE_URL]);
expect(callParticipation.session$.value.memberCount).toBe(1);
driver.peerLeaves(peer);
await waitFor(
"peer gone",
() => callParticipation.memberships$.value.value.length === 0,
);
});
it("joins publishing, exposes our identity before the echo and our membership after it", async () => {
const driver = new MockRtcMatrixDriver();
const callParticipation = create(driver);
await callParticipation.join(publishOnLivekit(), joinParams, openSlot);
expect(
FfiStatus.Connected.instanceOf(callParticipation.status$.value),
).toBe(true);
const ownMemberId = callParticipation.ownMemberId$.value;
expect(ownMemberId).toBeTruthy();
expect(callParticipation.ownTransportIdentity$.value).toBeTruthy();
// nobody had started a call: we opened the slot before joining
expect(callParticipation.session$.value.slotOpen).toBe(true);
const [slot] = driver.calls("stateEvent");
expect(slot.eventType).toBe("org.matrix.msc4143.rtc.slot");
expect(slot.stateKey).toBe("m.call#ROOM");
expect(slot.content).toEqual({
status: "open",
application: { type: "m.call" },
});
await waitFor(
"own echo",
() => callParticipation.ownMembership$.value !== null,
);
expect(callParticipation.ownMembership$.value?.member.memberId).toBe(
ownMemberId,
);
expect(callParticipation.ownMembership$.value?.transportIdentity).toBe(
callParticipation.ownTransportIdentity$.value,
);
expect(callParticipation.connections$.value).toHaveLength(1);
expect(callParticipation.connections$.value[0].connection.serviceUrl).toBe(
MOCK_LK_SERVICE_URL,
);
// discovery: the bare intent asked the homeserver
expect(driver.calls("getRtcTransports")).toHaveLength(1);
expect(driver.calls("getLivekitToken")[0].slotId).toBe("m.call#ROOM");
await callParticipation.leave();
const status = callParticipation.status$.value;
expect(FfiStatus.Disconnected.instanceOf(status)).toBe(true);
if (FfiStatus.Disconnected.instanceOf(status))
expect(FfiDisconnectCause.LeftByHost.instanceOf(status.inner.cause)).toBe(
true,
);
// ...and again: a manager can be reused, with a fresh member id
await callParticipation.join(publishOnLivekit(), joinParams, openSlot);
expect(callParticipation.ownMemberId$.value).not.toBe(ownMemberId);
await callParticipation.leave();
});
it("does not open a slot that is already open, and refuses to join without the power to open one", async () => {
const open = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const p1 = create(open);
await p1.join(receiveOnly(), joinParams, {
encrypted: false,
canOpen: false,
});
expect(open.calls("stateEvent")).toEqual([]);
await p1.leave();
const closed = new MockRtcMatrixDriver();
const p2 = create(closed);
await expect(
p2.join(receiveOnly(), joinParams, { encrypted: false, canOpen: false }),
).rejects.toBeInstanceOf(NoOpenSlotError);
expect(closed.calls("stateEvent")).toEqual([]);
expect(closed.calls("stickyEvent")).toEqual([]);
expect(FfiStatus.Disconnected.instanceOf(p2.status$.value)).toBe(true);
// an encrypted room gets a slot that prescribes per-member keys
const encryptedRoom = new MockRtcMatrixDriver({
roomState: [roomEncryptionEvent()],
});
const p3 = create(encryptedRoom);
await p3.join(receiveOnly(), joinParams, {
encrypted: true,
canOpen: true,
});
expect(encryptedRoom.calls("stateEvent")[0].content).toEqual({
status: "open",
application: { type: "m.call" },
encryption: { type: "m.per_member" },
});
expect(p3.session$.value.encrypted).toBe(true);
await p3.leave();
});
it("falls back to the configured transport when the homeserver has none or fails", async () => {
const none = new MockRtcMatrixDriver({ transports: [] });
const p1 = create(none, { transportFallbackUrl: "https://lk.config" });
await p1.join(publishOnLivekit(), joinParams, openSlot);
expect(none.calls("getLivekitToken")[0].url).toBe("https://lk.config");
await p1.leave();
const failing = new MockRtcMatrixDriver();
failing.failTransportDiscovery = true;
const p2 = create(failing, { transportFallbackUrl: "https://lk.config" });
await p2.join(publishOnLivekit(), joinParams, openSlot);
expect(failing.calls("getLivekitToken")[0].url).toBe("https://lk.config");
await p2.leave();
// a custom URL in the intent skips discovery altogether
const custom = new MockRtcMatrixDriver();
const p3 = create(custom);
await p3.join(publishOnLivekit("https://lk.custom"), joinParams, openSlot);
expect(custom.calls("getRtcTransports")).toHaveLength(0);
expect(custom.calls("getLivekitToken")[0].url).toBe("https://lk.custom");
await p3.leave();
});
it("streams key changes and filters members that left holding our key", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [
roomEncryptionEvent(),
slotEvent({ status: "open", encrypted: true }),
],
});
const callParticipation = create(driver);
const changes: FfiMediaKey[] = [];
callParticipation.keyChanges$.subscribe((k) => changes.push(k));
driver.addPeer(peer);
await callParticipation.join(receiveOnly(), joinParams, openSlot);
driver.peerJoins(peer);
await waitFor("peer key", () =>
callParticipation.keyMap$.value.some((k) => k.memberId === peer.memberId),
);
expect(changes.some((k) => k.memberId === peer.memberId)).toBe(true);
driver.peerLeaves(peer);
// the crate keeps a LeftWithKeys entry; the behavior does not
await waitFor(
"peer gone from memberships",
() =>
!callParticipation.memberships$.value.value.some(
(m) => m.member.memberId === peer.memberId,
),
);
await callParticipation.leave();
});
it("does not exchange keys when the call manages none", async () => {
const driver = new MockRtcMatrixDriver();
const callParticipation = create(driver, { manageMediaKeys: false });
driver.addPeer(peer);
await callParticipation.join(receiveOnly(), joinParams, openSlot);
driver.peerJoins(peer);
await new Promise((r) => setTimeout(r, 50));
expect(driver.calls("toDevice")).toEqual([]);
await callParticipation.leave();
});
it("surfaces a lost homeserver as a critical impairment in the status", async () => {
const driver = new MockRtcMatrixDriver();
const callParticipation = create(driver);
await callParticipation.join(receiveOnly(), joinParams, openSlot);
driver.setHomeserverConnected(false);
await waitFor("impairment in status$", () => {
const status = callParticipation.status$.value;
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
);
});
driver.setHomeserverConnected(true);
await waitFor("impairment cleared", () => {
const status = callParticipation.status$.value;
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments.length === 0
);
});
await callParticipation.leave();
});
it("leaves and destroys the manager when the scope ends", async () => {
const driver = new MockRtcMatrixDriver();
const scope = new ObservableScope();
const callParticipation = create(driver, {}, scope);
await callParticipation.join(receiveOnly(), joinParams, openSlot);
scope.end();
await waitFor(
"leave sent",
() =>
driver
.calls("stickyEvent")
.some(
(c) =>
c.content.member === undefined ||
c.content.msc4354_sticky_key !== undefined,
) && driver.calls("cancelDelayed").length === 1,
);
expect(callParticipation.debugSnapshot()).toBe("{}");
await expect(
callParticipation.join(receiveOnly(), joinParams, openSlot),
).rejects.toThrow("ended");
});
it("turns terminal causes into Element Call errors", async () => {
const driver = new MockRtcMatrixDriver({ transports: [] });
const callParticipation = create(driver);
await expect(
callParticipation.join(publishOnLivekit(), joinParams, openSlot),
).rejects.toThrow();
expect(
errorForStatus(callParticipation.status$.value, {
domain: "example.org",
stickyEventsSupported: true,
}),
).toBeInstanceOf(MatrixRTCTransportMissingError);
const noSticky = new MockRtcMatrixDriver();
noSticky.refuseStickyEvents = true;
const p2 = create(noSticky);
await expect(
p2.join(receiveOnly(), joinParams, openSlot),
).rejects.toThrow();
expect(
errorForStatus(p2.status$.value, {
domain: "example.org",
stickyEventsSupported: false,
}),
).toBeInstanceOf(StickyEventsRequiredError);
const fine = create(new MockRtcMatrixDriver());
expect(
errorForStatus(fine.status$.value, {
domain: "example.org",
stickyEventsSupported: true,
}),
).toBeNull();
});
it("derives the crate's parameters from Element Call's config", () => {
expect(compatForMode(MatrixRTCMode.Compatibility)).toBe(
FfiElementCallCompat.StateEvents,
);
expect(compatForMode(MatrixRTCMode.Matrix_2_0)).toBe(
FfiElementCallCompat.StickyEvents,
);
expect(joinParams).toEqual({
applicationType: "m.call",
intent: undefined,
// js-sdk's 4 h default, capped at the sticky hour
stickyDurationMs: 3_600_000n,
keepAliveTimeoutMs: 15_000n,
degradedLifetimeMs: undefined,
delegateDelayedLeave: false,
});
expect(
joinParamsFromConfig({
session: { ...session, membership_event_expiry_ms: 60_000 },
callIntent: "video",
delegateDelayedLeave: true,
}),
).toMatchObject({
intent: "video",
stickyDurationMs: 60_000n,
delegateDelayedLeave: true,
});
expect(
participationConfig({
mode: MatrixRTCMode.Compatibility,
manageMediaKeys: false,
session,
}),
).toEqual({
compat: FfiElementCallCompat.StateEvents,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 50n,
});
});
});
+494
View File
@@ -0,0 +1,494 @@
/*
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.
*/
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
type Observable,
Subject,
combineLatest,
map,
} from "rxjs";
import { type RtcMatrixDriver } from "../../driver/RtcMatrixDriver";
import {
FfiMatrixDriver,
FfiMembershipState,
FfiParticipationManager,
FfiStatus,
type FfiConnectionWithMembers,
type FfiJoinParams,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiMediaKey,
type FfiMembership,
type FfiParticipationConfig,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiSessionSnapshot,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type FfiTransportIntent,
type ConnectivitySinkLike,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { type Behavior } from "../Behavior";
import { Epoch, type ObservableScope, trackEpoch } from "../ObservableScope";
import { NoOpenSlotError } from "../../utils/errors";
import { ELEMENT_CALL_APPLICATION, ELEMENT_CALL_SLOT_ID } from "./slot";
import { LIVEKIT_TRANSPORT_TYPE } from "./transportIntent";
/**
* What to do about the room's slot when joining. A call needs an open
* MatrixRTC slot (`m.rtc.slot` state); a room that never had a call has
* none, and the client that starts the call opens it — if its user has the
* power level to send the state event.
*/
export interface SlotPolicy {
/** Whether the slot, if we open it, prescribes per-member media encryption (an encrypted room). */
encrypted: boolean;
/** Whether this user may send the slot state event (`RoomInfo.canOpenSlot`). */
canOpen: boolean;
}
/** How long to wait for the seed, and for our own slot event to echo back. */
const SLOT_WAIT_MS = 15_000;
export interface CallParticipationOptions {
/** One manager per `(room, slot)`; Element Call has one slot per room. */
slotId?: string;
config: FfiParticipationConfig;
/**
* A LiveKit service URL to fall back on when the homeserver advertises no
* transport (or cannot be asked) — Element Call's `config.json` value,
* which is Element Call's business rather than the host's.
*/
transportFallbackUrl?: string;
logger?: Logger;
}
/**
* Element Call's view of one participation in a MatrixRTC session: the
* crate's `FfiParticipationManager` as behaviors. "Participation" is the
* crate's word for the FFI side; this is the RxJS wrapper a call is built on.
*
* The crate does everything Matrix: it projects the session from the
* driver's events, publishes and keeps alive our own membership, mints
* transport tokens and exchanges media keys. This class owns the manager
* for the scope's lifetime, seeds each behavior from the manager's getter and
* keeps it current from the manager's listener, and ends the participation
* (leaving if still joined) when the scope ends.
*/
export class CallParticipation {
private readonly logger: Logger;
private readonly matrixDriver: FfiMatrixDriver;
private readonly manager: FfiParticipationManager;
private ended = false;
private readonly membershipsSubject$: BehaviorSubject<FfiMembership[]>;
private readonly connectionsSubject$: BehaviorSubject<
FfiConnectionWithMembers[]
>;
private readonly keyMapSubject$: BehaviorSubject<FfiMediaKey[]>;
private readonly keyChangesSubject$ = new Subject<FfiMediaKey>();
private readonly statusSubject$: BehaviorSubject<FfiStatus>;
private readonly sessionSubject$: BehaviorSubject<FfiSessionSnapshot>;
private readonly ownMemberIdSubject$: BehaviorSubject<string | null>;
private readonly ownTransportIdentitySubject$: BehaviorSubject<string | null>;
/**
* One entry per joined member, ourselves included once our own membership
* has echoed back from the homeserver. Members that left but may still hold
* our media key (`LeftWithKeys`) are not listed.
*/
public readonly memberships$: Behavior<Epoch<FfiMembership[]>>;
/** The LiveKit rooms to hold, with the token for each. */
public readonly connections$: Behavior<FfiConnectionWithMembers[]>;
/** Every media key in use, ours and theirs, one per (member, index). */
public readonly keyMap$: Behavior<FfiMediaKey[]>;
/** The single key that changed, as it changes. */
public readonly keyChanges$: Observable<FfiMediaKey> =
this.keyChangesSubject$;
public readonly status$: Behavior<FfiStatus>;
/** Slot open?, encrypted?, member count, seed honesty. */
public readonly session$: Behavior<FfiSessionSnapshot>;
/** Our member id, from the moment `join()` starts; null while not joined. */
public readonly ownMemberId$: Behavior<string | null>;
/** Our LiveKit participant identity, known as early as the member id. */
public readonly ownTransportIdentity$: Behavior<string | null>;
/** Our own entry in `memberships$`, once echoed. */
public readonly ownMembership$: Behavior<FfiMembership | null>;
public constructor(
scope: ObservableScope,
driver: RtcMatrixDriver,
roomId: string,
userId: string,
deviceId: string,
options: CallParticipationOptions,
) {
this.logger = (options.logger ?? rootLogger).getChild(
"[CallParticipation]",
);
const rtcDriver =
options.transportFallbackUrl === undefined
? driver
: new TransportFallbackDriver(
driver,
options.transportFallbackUrl,
this.logger,
);
this.matrixDriver = new FfiMatrixDriver(rtcDriver);
this.manager = new FfiParticipationManager(
roomId,
options.slotId ?? ELEMENT_CALL_SLOT_ID,
userId,
deviceId,
this.matrixDriver,
options.config,
);
this.membershipsSubject$ = new BehaviorSubject(
joinedOnly(this.manager.memberships()),
);
this.connectionsSubject$ = new BehaviorSubject(this.manager.connections());
this.keyMapSubject$ = new BehaviorSubject(this.manager.keyMap());
this.statusSubject$ = new BehaviorSubject(this.manager.status());
this.sessionSubject$ = new BehaviorSubject(this.manager.session());
this.ownMemberIdSubject$ = new BehaviorSubject(
this.manager.ownMemberId() ?? null,
);
this.ownTransportIdentitySubject$ = new BehaviorSubject(
this.manager.ownTransportIdentity() ?? null,
);
this.manager.setMembershipsListener({
onMembershipsChange: (memberships) => {
if (this.ended) return;
this.membershipsSubject$.next(joinedOnly(memberships));
this.sessionSubject$.next(this.manager.session());
this.refreshOwnIdentity();
},
});
this.manager.setConnectionsListener({
onConnectionsChange: (connections) => {
if (!this.ended) this.connectionsSubject$.next(connections);
},
});
this.manager.setKeyMapListener({
onKeyMapChange: (keyMap, change) => {
if (this.ended) return;
this.keyMapSubject$.next(keyMap);
this.keyChangesSubject$.next(change);
},
});
this.manager.setStatusListener({
onStatusChange: (status) => {
if (this.ended) return;
this.statusSubject$.next(status);
this.sessionSubject$.next(this.manager.session());
this.refreshOwnIdentity();
},
});
this.manager.setKeyRejectedListener({
onKeyRejected: (memberId, reason) =>
this.logger.warn(`Discarded a media key from ${memberId}: ${reason}`),
});
this.memberships$ = scope.behavior(
this.membershipsSubject$.pipe(trackEpoch()),
new Epoch(this.membershipsSubject$.value),
);
this.connections$ = scope.behavior(this.connectionsSubject$);
this.keyMap$ = scope.behavior(this.keyMapSubject$);
this.status$ = scope.behavior(this.statusSubject$);
this.session$ = scope.behavior(this.sessionSubject$);
this.ownMemberId$ = scope.behavior(this.ownMemberIdSubject$);
this.ownTransportIdentity$ = scope.behavior(
this.ownTransportIdentitySubject$,
);
this.ownMembership$ = scope.behavior(
combineLatest([this.memberships$, this.ownMemberId$]).pipe(
map(
([memberships, ownMemberId]) =>
memberships.value.find((m) => m.member.memberId === ownMemberId) ??
null,
),
),
);
scope.onEnd(() => void this.end());
}
/**
* Join the session, opening the room's slot first when nobody has
* (`slot`). Resolves once our membership is published, or rejects: with
* {@link NoOpenSlotError} when there is no slot and we may not open one,
* otherwise with the crate's typed error. The status keeps reporting from
* there.
*/
public async join(
intent: FfiTransportIntent,
params: FfiJoinParams,
slot: SlotPolicy,
): Promise<void> {
if (this.ended) throw new Error("The participation has ended");
try {
await this.ensureOpenSlot(slot);
await this.manager.join(intent, params);
} finally {
this.refreshAll();
}
}
private async ensureOpenSlot(slot: SlotPolicy): Promise<void> {
// The crate's own `join` waits for the seed too, but the slot check has
// to come first: a slot read that has not finished looks like no slot.
await this.waitUntil(
() => this.manager.session().seeded,
"the session seed",
);
if (this.manager.session().slotOpen === true) return;
if (!slot.canOpen) throw new NoOpenSlotError();
this.logger.info(
`No open slot in the room; opening ${ELEMENT_CALL_SLOT_ID} (encrypted: ${slot.encrypted})`,
);
await this.manager.openSlot(ELEMENT_CALL_APPLICATION, slot.encrypted);
// Open only once the homeserver has echoed the state back.
await this.waitUntil(
() => this.manager.session().slotOpen === true,
"our slot event to echo back",
);
}
private async waitUntil(
condition: () => boolean,
what: string,
): Promise<void> {
const deadline = Date.now() + SLOT_WAIT_MS;
while (!condition()) {
if (this.ended) throw new Error("The participation has ended");
if (Date.now() > deadline)
throw new Error(`Timed out waiting for ${what}`);
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
/** Leave the session. A no-op when not joined. */
public async leave(code?: string, reason?: string): Promise<void> {
if (this.ended) return;
if (FfiStatus.Disconnected.instanceOf(this.manager.status())) return;
try {
await this.manager.leave(code, reason);
} finally {
this.refreshAll();
}
}
/** The crate's diagnostics dump, for rageshakes. Not a UI contract. */
public debugSnapshot(): string {
return this.ended ? "{}" : this.manager.debugSnapshot();
}
private refreshOwnIdentity(): void {
this.ownMemberIdSubject$.next(this.manager.ownMemberId() ?? null);
this.ownTransportIdentitySubject$.next(
this.manager.ownTransportIdentity() ?? null,
);
}
/** After a join or leave the getters are fresh before any listener fires. */
private refreshAll(): void {
if (this.ended) return;
this.statusSubject$.next(this.manager.status());
this.membershipsSubject$.next(joinedOnly(this.manager.memberships()));
this.connectionsSubject$.next(this.manager.connections());
this.sessionSubject$.next(this.manager.session());
this.refreshOwnIdentity();
}
private async end(): Promise<void> {
if (this.ended) return;
this.ended = true;
if (!FfiStatus.Disconnected.instanceOf(this.manager.status())) {
try {
await this.manager.leave(undefined, undefined);
} catch (e) {
this.logger.warn("Could not leave the session cleanly", e);
}
}
this.keyChangesSubject$.complete();
this.manager.uniffiDestroy();
this.matrixDriver.uniffiDestroy();
}
}
function joinedOnly(memberships: FfiMembership[]): FfiMembership[] {
return memberships.filter((m) => m.state === FfiMembershipState.Joined);
}
/**
* Answers transport discovery with a configured LiveKit service URL when the
* host's homeserver advertises none or cannot be asked — the precedence
* Element Call has always had (homeserver first, `config.json` second) —
* and delegates everything else to the host's driver untouched.
*/
class TransportFallbackDriver implements RtcMatrixDriver {
public constructor(
private readonly inner: RtcMatrixDriver,
private readonly fallbackUrl: string,
private readonly logger: Logger,
) {}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
let transports: FfiRtcTransport[] = [];
try {
transports = await this.inner.getRtcTransports();
} catch (e) {
this.logger.info(
"Transport discovery failed; falling back to the configured LiveKit service",
e,
);
}
if (transports.some((t) => t.transportType === LIVEKIT_TRANSPORT_TYPE))
return transports;
this.logger.info(
"The homeserver advertises no LiveKit transport; using the configured one",
);
return [
{
transportType: LIVEKIT_TRANSPORT_TYPE,
propertiesJson: JSON.stringify({
livekit_service_url: this.fallbackUrl,
}),
},
];
}
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
return this.inner.sendStickyEvent(
roomId,
eventType,
contentJson,
durationMs,
);
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
return this.inner.sendStateEvent(roomId, eventType, stateKey, contentJson);
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
return this.inner.sendDelayedEvent(
roomId,
eventType,
contentJson,
delayMs,
stickyDurationMs,
);
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
return this.inner.sendDelayedStateEvent(
roomId,
eventType,
stateKey,
contentJson,
delayMs,
);
}
public async restartDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
return this.inner.restartDelayedEvent(roomId, delayId);
}
public async cancelDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
return this.inner.cancelDelayedEvent(roomId, delayId);
}
public async delegateLivekitDelayedLeave(
roomId: string,
slotId: string,
memberJson: string,
delayId: string,
livekitServiceUrl: string | undefined,
delayMs: bigint,
): Promise<void> {
return this.inner.delegateLivekitDelayedLeave(
roomId,
slotId,
memberJson,
delayId,
livekitServiceUrl,
delayMs,
);
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
return this.inner.sendToDevice(recipients, eventType, contentJson);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return this.inner.getLivekitToken(request);
}
public async readEvents(
eventType: string,
stateKey: string | undefined,
limit: number,
): Promise<string[]> {
return this.inner.readEvents(eventType, stateKey, limit);
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
return this.inner.readState(eventType, stateKey);
}
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
this.inner.subscribeRoomEvents(sink);
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
this.inner.subscribeToDeviceEvents(sink);
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
this.inner.subscribeStateUpdates(sink);
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.inner.subscribeConnectivity(sink);
}
public isHomeserverConnected(): boolean {
return this.inner.isHomeserverConnected();
}
}
+67
View File
@@ -0,0 +1,67 @@
/*
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.
*/
import {
FfiDisconnectCause,
FfiJoinError,
FfiStatus,
} from "../../matrix-rtc-sdk";
import {
ConnectionLostError,
type ElementCallError,
MatrixRTCTransportMissingError,
MembershipManagerError,
StickyEventsRequiredError,
} from "../../utils/errors";
export interface DisconnectContext {
/** The homeserver's domain, for the "no transport" message. */
domain: string;
/** Whether the homeserver was found to accept sticky events. */
stickyEventsSupported: boolean;
}
/**
* The error a participation ended with, or null when it ended on purpose
* (never joined, or the host left).
*
* The crate splits failures into recoverable state (`impairments`, which the
* call UI shows as interruptions) and terminal causes; only the latter are
* errors here.
*/
export function errorForStatus(
status: FfiStatus,
context: DisconnectContext,
): ElementCallError | null {
if (!FfiStatus.Disconnected.instanceOf(status)) return null;
const cause = status.inner.cause;
if (
FfiDisconnectCause.NeverJoined.instanceOf(cause) ||
FfiDisconnectCause.LeftByHost.instanceOf(cause)
)
return null;
if (FfiDisconnectCause.JoinFailed.instanceOf(cause)) {
const error = cause.inner.error;
if (FfiJoinError.NoTransport.instanceOf(error))
return new MatrixRTCTransportMissingError(context.domain);
// A homeserver that refuses sticky events fails the very first send.
if (FfiJoinError.Driver.instanceOf(error) && !context.stickyEventsSupported)
return new StickyEventsRequiredError();
return new MembershipManagerError(new Error(describeJoinError(error)));
}
// SlotClosed, ManagerStopped: we are out and nothing will bring us back.
return new ConnectionLostError();
}
function describeJoinError(error: FfiJoinError): string {
const inner: unknown = "inner" in error ? error.inner : undefined;
const message =
typeof inner === "object" && inner !== null && "message" in inner
? inner.message
: undefined;
return typeof message === "string" ? `${error.tag}: ${message}` : error.tag;
}
+104
View File
@@ -0,0 +1,104 @@
/*
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.
*/
import {
type ResolvedConfigOptions,
MatrixRTCMode,
} from "../../config/ConfigOptions";
import {
FfiElementCallCompat,
type FfiJoinParams,
type FfiParticipationConfig,
} from "../../matrix-rtc-sdk";
import { ELEMENT_CALL_APPLICATION } from "./slot";
/** matrix-js-sdk caps a sticky membership at an hour; so does the crate. */
const MAX_STICKY_DURATION_MS = 60 * 60 * 1000;
/** matrix-js-sdk's default membership expiry when the config names none. */
const DEFAULT_MEMBERSHIP_EXPIRY_MS = 4 * 60 * 60 * 1000;
/**
* Which pre-2026 Element Call dialect the crate speaks for a given mode.
* `compatibility` is MSC3401 state events; `matrix_2_0` is the sticky
* dialect deployed Element Call clients read today. Spec MSC4143 (`Off`)
* waits until Element Call opens slots.
*/
export function compatForMode(mode: MatrixRTCMode): FfiElementCallCompat {
switch (mode) {
case MatrixRTCMode.Compatibility:
return FfiElementCallCompat.StateEvents;
case MatrixRTCMode.Matrix_2_0:
return FfiElementCallCompat.StickyEvents;
}
}
export interface ParticipationConfigInputs {
mode: MatrixRTCMode;
/** Whether this call encrypts media with per-participant keys. */
manageMediaKeys: boolean;
/**
* Whether to discard media keys from senders not reported as cross-signed
* (MSC4153). Off for parity with matrix-js-sdk, which never checked, and
* because a passwordless guest cannot be cross-signed.
*
* TODO: we want this on. Turning it on needs (1) the SPA's passwordless
* users to be cross-signed or given another way in, and (2) the crate to
* report the sender's verdict on the tile while the check is off
* (`FfiMediaKeyState.senderCrossSigned`, plan item C10), so the UI can
* show an unverified sender before the switch flips.
*/
requireCrossSignedSender?: boolean;
session: ResolvedConfigOptions["matrix_rtc_session"];
}
export function participationConfig({
mode,
manageMediaKeys,
requireCrossSignedSender = false,
session,
}: ParticipationConfigInputs): FfiParticipationConfig {
return {
compat: compatForMode(mode),
manageMediaKeys,
requireCrossSignedSender,
useKeyDelayMs: BigInt(session.wait_for_key_rotation_ms ?? 1000),
};
}
export interface JoinParamsInputs {
session: ResolvedConfigOptions["matrix_rtc_session"];
/** `m.call.intent`: what kind of call the user is starting. */
callIntent?: string;
/** Hand the delayed leave to the SFU (MSC4195) — only where the probe said it can. */
delegateDelayedLeave: boolean;
}
/**
* The crate's join parameters from Element Call's `matrix_rtc_session`
* config. Keys the crate has no knob for (`restart_ms`, `restart_timeout_ms`,
* `network_error_retry_ms`, `key_rotation_participant_limit`,
* `delegated_delayed_leave.*`) have no effect any more.
*/
export function joinParamsFromConfig({
session,
callIntent,
delegateDelayedLeave,
}: JoinParamsInputs): FfiJoinParams {
return {
applicationType: ELEMENT_CALL_APPLICATION,
intent: callIntent,
stickyDurationMs: BigInt(
Math.min(
session.membership_event_expiry_ms ?? DEFAULT_MEMBERSHIP_EXPIRY_MS,
MAX_STICKY_DURATION_MS,
),
),
keepAliveTimeoutMs: BigInt(session.delayed_leave.delay_ms),
degradedLifetimeMs: undefined,
delegateDelayedLeave,
};
}
+19
View File
@@ -0,0 +1,19 @@
/*
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.
*/
/**
* The MatrixRTC slot Element Call lives in, `{application}#{id}` per
* MSC4143. One per room: the application is a call and the id is the room's.
*/
export const ELEMENT_CALL_SLOT_ID = "m.call#ROOM";
export const ELEMENT_CALL_APPLICATION = "m.call";
/**
* The state event type the crate opens a slot with (the unstable spelling
* deployed homeservers know). Opening a slot needs the power level to send
* it, which is what `RoomInfo.canOpenSlot` answers.
*/
export const ELEMENT_CALL_SLOT_EVENT_TYPE = "org.matrix.msc4143.rtc.slot";
+34
View File
@@ -0,0 +1,34 @@
/*
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.
*/
import { FfiTransportIntent } from "../../matrix-rtc-sdk";
export const LIVEKIT_TRANSPORT_TYPE = "livekit";
/**
* Publish on LiveKit. With a `serviceUrl` (a developer's custom URL) that
* transport is used as given; without one the crate discovers it through the
* driver's `getRtcTransports`, which is where the homeserver's answer and
* Element Call's config fallback come in.
*/
export function publishOnLivekit(serviceUrl?: string): FfiTransportIntent {
return new FfiTransportIntent.Publish({
transport: {
transportType: LIVEKIT_TRANSPORT_TYPE,
propertiesJson: JSON.stringify(
serviceUrl === undefined ? {} : { livekit_service_url: serviceUrl },
),
},
});
}
/** Take part without publishing media: recorders, observers, tests. */
export function receiveOnly(): FfiTransportIntent {
return new FfiTransportIntent.ReceiveOnly({
canSubscribe: [LIVEKIT_TRANSPORT_TYPE],
});
}
+16
View File
@@ -21,6 +21,8 @@ export enum ErrorCode {
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED", STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
/** The room has no open MatrixRTC slot and the user may not open one. */
NO_OPEN_SLOT = "NO_OPEN_SLOT",
OPEN_ID_ERROR = "OPEN_ID_ERROR", OPEN_ID_ERROR = "OPEN_ID_ERROR",
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE", NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
SFU_ERROR = "SFU_ERROR", SFU_ERROR = "SFU_ERROR",
@@ -140,6 +142,20 @@ export class StickyEventsRequiredError extends ElementCallError {
); );
} }
} }
/**
* A call needs an open MatrixRTC slot in the room. Nobody has opened one, and
* this user lacks the power level to send the slot state event.
*/
export class NoOpenSlotError extends ElementCallError {
public constructor() {
super(
i18n.t("error.no_open_slot"),
ErrorCode.NO_OPEN_SLOT,
ErrorCategory.CONFIGURATION_ISSUE,
i18n.t("error.no_open_slot_description"),
);
}
}
/** /**
* Error indicating that end-to-end encryption is not supported in the current environment. * Error indicating that end-to-end encryption is not supported in the current environment.
+31
View File
@@ -0,0 +1,31 @@
/*
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.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { initMatrixRtcSdk } from "../matrix-rtc-sdk";
/**
* Loads the MatrixRTC SDK for a unit test. The browser builds fetch the wasm
* by URL; under vitest there is no server, so the bytes come from disk.
*
* Only suites that construct a participation manager need this; do not put
* it in the global setup, where every test file would pay for the boot.
*/
export async function initMatrixRtcSdkForTests(): Promise<void> {
// Relative to this file: under the jsdom environment `import.meta.url` is
// not a file URL and `process.cwd()` is not the repository, but vitest
// still provides `__dirname`.
const wasm = readFileSync(
resolve(
__dirname,
"../matrix-rtc-sdk/generated/wasm-bindgen/index_bg.wasm",
),
);
await initMatrixRtcSdk(wasm);
}
+2
View File
@@ -57,6 +57,8 @@ export default defineConfig((configEnv) =>
"src/utils/test.ts", "src/utils/test.ts",
"src/utils/test-viewmodel.ts", "src/utils/test-viewmodel.ts",
"src/utils/test-fixtures.ts", "src/utils/test-fixtures.ts",
"src/utils/test-matrix-rtc.ts",
"src/matrix-rtc-sdk/generated/**",
"playwright/**", "playwright/**",
], ],
}, },