Files
element-call-Github/element-call-oxidation-plan.md
T
Timo K. 14b595345f renames:
callParticipation -> rtcParticipationManager
driverCapabilites -> MatrixClientFeatures
2026-09-17 21:02:52 +02:00

978 lines
116 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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–C12 (C1 reverted in favour of slot opening) are done; C2–C8,
C11 and C12 are committed in the draft repo as `a8e21b3`, C5 (final form), C9
and C10 are still uncommitted there. In Element Call everything sits
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: done upstream (`@element-hq/matrix-rtc`,
built and published by `npm-web-bindings.yml` in matrix-rust-rtc; consumed
here since 2026-09-16, §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` |
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, StateEvents}`: spec MSC4143, or MSC3401 state
events for the clients that predate sticky events (C9 removed the never
deployed 2025 sticky dialect).
- 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.getMatrixClientFeatures()` |
**Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`RtcParticipationManager.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
| C5 | **Done.** 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. **Real-backend check (2026-09-15):** both routes send the MSC4195 `member` _claims_ (`{ id, claimed_user_id, claimed_device_id }`), not the member block of the event. The homeserver route body is what lk-jwt-service 0.7 accepts behind a Synapse that proxies `rtc/livekit/*` to it (MSC4512, `backend/app-service.yaml`): `{ url, room_id, slot_id, member, delay_id, delay_timeout }`, where `url` is the SFU websocket URL the transport's token named (the service checks it is its own). The transport resolver therefore returns `ResolvedTransport { transport, sfu_url }` and the machine carries the url into `Action::DelegateViaHomeserver`; without an SFU url it goes straight to the service route, and a receive-only member (no transport) delegates nothing. Verified end to end against `pnpm backend`: the homeserver route takes the 1 h leave (`KeepAlive::Delegated { via: Homeserver }`, one delayed event of 3 600 000 ms on the server). |
| 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. | **Done.** Removed: `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. | **Done.** The inbound key store keeps the MSC4153 verdict of the accepted key per member and exposes it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `RtcParticipationManager.session$` is fed from it. |
| C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). |
No crate work is deferred: `update_application` is C11.
---
## 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 RtcParticipationManager for lobby → call → ended │
│ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
│ 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;
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>;
}
export interface MatrixClientFeatures {
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 `RtcParticipationManager` (Element Call's RxJS view of the manager)
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
`FfiParticipationConfig`); `RtcParticipationManager` is Element Call's RxJS wrapper
over it.
`src/state/rtc/RtcParticipationManager.ts`, a class taking the scope in its constructor:
```ts
new RtcParticipationManager(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, `RtcParticipationManager.homeserverConnected$` from the manager's getter |
| `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) |
| `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold |
| `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` |
| `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), in
`compatForMode`.
### 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 `RtcParticipationManager` (scope tied to its mount) and hands it to
`LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`.
- `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 → `getMatrixClientFeatures()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`.
- `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
`RtcParticipationManager`, and waits on `status$` instead of `JoinStateChanged`.
- **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each
`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`). `getMatrixClientFeatures().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). Element Call carries no
delegation probe or policy.
- 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. **The SDK is an npm package.** `@element-hq/matrix-rtc` (the draft's
`web-test-app/`, built by `npm-web-bindings.yml`: uniffi bindings, the
wasm-bindgen glue, a `wasm-opt`'d ~760 KB wasm, `.d.ts`, and `@ubjs/core`
as its own dependency) is a devDependency, aliased in `package.json` to
`@billcarsonfr/matrix-rtc@next` while it is published from the fork, so
the imports already say `@element-hq/matrix-rtc`. It lives on the GitHub
Packages registry: `.npmrc` maps the scope, a `read:packages` token goes
in `~/.npmrc` (CI: the `MATRIX_RTC_NPM_TOKEN` secret). **The GitHub
Packages registry is a stop-gap** for the phase in which the crate's API
moves with every push; once the SDK is ready for consumers beyond Element
Call it belongs on npmjs.com as `@element-hq/matrix-rtc`, which drops the
alias, `.npmrc`, the CI secret and every token requirement
(`docs/matrix_rtc_sdk.md`). `src/matrix-rtc-sdk/`
keeps only the loader (`initMatrixRtcSdk`: package `initAsync` + our log
sink, wasm via `@element-hq/matrix-rtc/wasm?url`) and the curated
re-exports. _History:_ until 2026-09-16 the bindings were vendored under
`src/matrix-rtc-sdk/generated/` by `scripts/sync-matrix-rtc-sdk.sh`; local
crate work now goes through `pnpm links:on` (`docs/linking.md`).
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. Wasm boot is **lazy** everywhere: in the app
`useRtcParticipationManager` awaits `initMatrixRtcSdk()` before constructing
the participation (so the js-sdk path never fetches it, §5.15), not the
`Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in
`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, done). Element Call keeps no probe and no
delegation code; `delegated_delayed_leave.delay_ms` feeds
`FfiJoinParams.delegatedDelayMs`.
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.
13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
the session from the MSC3401 state events alone and requires the legacy
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`,
`slotIdForCompat`); `RtcParticipationManager` picks it from the config and skips
the slot check and the slot open. Found by the real-backend check: with
`m.call#ROOM` the crate saw its own legacy membership as a candidate but
never projected it. Element Call's own rooms already let every member send
the legacy member state event (`state_default: 0`); a plain room does not,
which the backend test reproduces.
14. **Member events in encrypted rooms are Megolm-encrypted** by matrix-js-sdk
(sticky events are timeline events; the SDK exempts only reactions and
redactions), as they are with the js-sdk MatrixRTC code today. The driver
decrypts them on the way in; the sticky marker (`msc4354_sticky`) stays in
the clear. To cross-check against Element X before relying on it.
15. **The two view models stay switchable for one release.** A developer
setting, `callViewModelImplementation` (`"matrix-js-sdk"` |
`"matrix-rtc"`, `src/settings/settings.ts`, next to `matrixRTCMode`),
picks between `createJsClientCallViewModel$` and the driver-based
`createCallViewModel$` in the same build. Like `matrixRTCMode` it is
shown in the Developer Settings tab, sampled when the call is joined
(switching mid-call needs a rejoin), and a deployment can pin it through
`config.json` (`call_view_model_implementation`), which overrides the
user's choice and greys the control out. The default is
`"matrix-js-sdk"` when the setting lands (S4a) and flips to
`"matrix-rtc"` once the S5 gate is green; S6 removes the setting together
with the js-sdk path. The point is that a broken call can be compared
against the old path in the same session, and that Playwright can run
both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore
stays reachable from `CallView` until S6, and the `RtcParticipationManager` is
created only when the crate path is selected, so neither path pays for
the other.
---
## 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/**`~~
(replaced by the `@element-hq/matrix-rtc` package on 2026-09-16, §5.1),
`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`~~ now `@element-hq/matrix-rtc`; the
`generated/**` carve-outs in `knip.ts`, `.oxlintrc.json`, `.oxfmtrc.json`,
`vitest.config.ts` and `.gitattributes` went with the vendored files),
`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 — `RtcParticipationManager` ☑
- `src/state/rtc/RtcParticipationManager.ts`, `joinParams.ts`, `transportIntent.ts`,
`errors.ts` (cause → `ElementCallError`).
- `RtcParticipationManager.test.ts` through the real wasm + `MockMatrixDriver`:
memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave →
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
manager; fallback transport when the host throws or advertises none.
### S3 — view model, four slices ☑ (side by side with the js-sdk view model)
- **Shape (2026-09-15):** the existing factory is renamed
`createJsClientCallViewModel$` and the new `createCallViewModel$(scope,
participation, clientDriver, …)` sits next to it; both build a
`CallViewModelCore` and hand it to the shared `assembleCallViewModel`, so
the layout/tile half is one piece of code and the two Matrix sides can be
reviewed side by side. `MatrixLivekitMember.membership$` is a neutral
`CallMember` (`userId`, `deviceId`, `memberId`, `rtcBackendIdentity`) that
js-sdk's `CallMembership` satisfies; `CallNotificationLifecycle` takes a
neutral `DeclineEvent`. Nothing js-sdk is deleted yet (that is S6).
- **S3a** `remoteMembers/ParticipationConnections.ts`: one `Connection` per
service URL in `participation.connections$`, started with the crate's
token; a refreshed token keeps the connection (used on the next
(re)connect). `Connection`/`ECConnectionFactory` accept a `null` client.
Test fakes live in `src/utils/test-participation.ts`
(`FakeParticipation`, `fakeMembership`, `fakeConnection`, `fakeMediaKey`).
- **S3b** `remoteMembers/ParticipationMembers.ts` (remote members matched by
`transportIdentity`, keyed by `memberId`; `callMemberOf`) and
`e2ee/participationKeyProvider.ts` (`keyMap$` × `memberships$` ×
`ownTransportIdentity$` → `onSetEncryptionKey`, once per (member, index,
identity); a key that arrives before the identity waits for the roster).
- **S3c** `localMember/LocalMedia.ts` is the LiveKit half extracted from
`LocalMember.ts` (publisher, tracks, screen share, upstream pausing, host
notify) and shared; `localMember/ParticipationLocalMember.ts` joins and
leaves through the participation (slot policy from `roomInfo`, custom
LiveKit URL as a `Publish` intent, `updateApplication` on camera toggle),
derives connected / reconnecting / `probablyLeft` from `status$`
(`HomeserverUnreachable` → "sync", `RestartFailing`/`Expired` →
"probablyLeft") and the fatal error from `errorForStatus`. Deletions
(`LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`) wait for S6.
- **S3d** `ParticipationCallNotification.ts` sends `org.matrix.msc4075.rtc.notification`
after our own echo when nobody was in the session before us (resets on
leave) and reads declines from the `TimelineDriver`;
`remoteMembers/ParticipationMemberMetadata.ts` adapts the client driver's
roster to `RoomMemberMap` so `createMatrixMemberMetadata$` and the ringing
name work unchanged. Still open: `ReactionsReader` on `participation` +
`TimelineDriver` (the new factory takes the hands/reactions observables
as inputs like the old one; S4b), `keyRotationSuppressed$` is `constant(false)`,
and the js-sdk test kit stays until S6. Tests:
`CallViewModel.participation.test.ts` (real wasm, mock drivers, mocked
LiveKit: join → connection → own tile → peer → leave; transport-missing →
`fatalError$`), plus one file per module.
### S4 — React tree, two slices ☑ (2026-09-16)
- **S4a-0, the switch (§5.15) ☑:** `CallViewModelImplementation` enum,
`callViewModelImplementation` setting (`src/settings/settings.ts`),
`call_view_model_implementation` pin (`ConfigOptions.ts`, validated in
`Config.ts`), the radio group in `DeveloperSettingsTab.tsx`, the
implementation logged at join and sent as `call_view_model_implementation`
in rageshakes, `effectiveCallViewModelImplementation()` in
`src/state/rtc/implementation.ts`. `CallView` samples it once per mount;
with `matrix-rtc` and no drivers it warns and lets matrix-js-sdk carry the
call. Still open from this item: the Playwright helper and the second CI
run.
- **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`;
`useRtcParticipationManager(drivers, config)` owned by `CallView`, created only
when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute
and the error boundary's "were we joined" read from whichever side carries
the call; `window.matrixRtc = { participation }` next to `window.rtcSession`.
- (The original S4a-0 description follows for reference.) `callViewModelImplementation` setting and
`config.json` pin (`ConfigOptions.ts`, validated at load like
`matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the
`MatrixDriverProvider` and the `RtcParticipationManager` from `CallView`, for
`"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a
report says which path it came from. Playwright gets a helper that sets
the setting (local storage) before the call so every call spec can run
under both values; CI runs the suite twice until the default flips.
Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`RtcParticipationManager` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
`analytics/PosthogEvents.ts`, `controls.ts`, and a
first `CallView.stories.tsx` (lobby, in call, ended) driven by
`MockMatrixDriver`.
- **S4b ☑ (2026-09-16):** `ReactionsSenderProvider` takes `ownIdentifier`,
`ownMembershipEventId` and a `ReactionsTimeline` (`jsSdkReactionsTimeline`
over a client, or the client driver); `ParticipationReactionsReader`
(`src/reactions/`) reads hands and reactions from the participation and
the timeline driver, keyed by the member's media id
(`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`,
`useRtcParticipationManager.test.tsx`, three `CallView.test.tsx` cases for the
switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads the
room through `useRoomInfo()` (`src/room/useRoomInfo.ts`; `useRoomAvatar`,
`useJoinRule`, `useRoomState` deleted, `useRoomName` stays for `RoomPage`),
our own profile through `useOwnProfile()` (`src/profile/useOwnProfile.ts`)
and the encryption system through `useEncryptionSystemFor(roomId,
roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
home page). `MatrixInfo` is built from those. `InviteModal` takes
`roomId`/`roomName`/`e2eeSystem`; `CallEndedView` lost its `client` prop;
`Avatar` resolves thumbnails through `clientDriver.thumbnailUrl` when
drivers are provided (host `downloadMedia` first, client fallback for the
shell); rageshakes carry the driver's `getDiagnostics()` as `driver_*`
fields and the crate's `matrix_rtc_snapshot`.
- **The component is client-free (2026-09-16):** `CallView`'s `client` and
`rtcSession` are optional; without them the crate carries the call
whatever the setting says (`useMatrixRtc = setting || no session`), and
every js-sdk-only piece (the `MembershipManagerError` listener, the room
sanity check, `ReactionsReader`, `mediaKeyStatisticsOf`) is skipped.
`ActiveCall`/`InCallView` take `roomId` instead of `matrixRoom`, and
`client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the
sticky probe from `getMatrixClientFeatures()`, the crypto version from
`getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported
unless a client state forbids them. `ElementCall` renders no
`ClientProvider` and no shim — it hands its two drivers down and nothing
below asks for a client; `initializeElementCall(config, { matrixRtcWasm })`
can preload the wasm. `PosthogEvents.eventCallEnded.track` takes
`MediaKeyStatistics` (from the session on the js-sdk path, zero on the
crate path until the crate counts). `useTypedEventEmitter` and the js-sdk
client driver's public `client`/`room` are gone. **Consequence:** the
component and its dev harness (`ElementCallClientBased`) now run every
call on the crate.
- **Banner dropped (2026-09-17):** the disconnected banner, its
`useHomeserverConnected` hook and the client state's `disconnected` flag
are gone. The local membership already carries the connection state, so
the reconnecting overlay covers the in-call case where it matters; in the
lobby the banner added little. `HomeserverConnected` in
`state/CallViewModel/localMember/` remains the one consumer of
`rtcDriver.isHomeserverConnected()` / `subscribeConnectivity` and of
`sync_disconnect_grace_period_ms`. The mock RTC driver keeps several
connectivity sinks (the crate's and the UI's).
- **S4 closed (2026-09-16):** `CallView.stories.tsx` (Lobby with a peer,
NoTransport as the error path, Ended) over the mock drivers, no client;
`.storybook/preview.tsx` initialises the config; the Storybook vitest
project passes. The Playwright switch is `CALL_VIEW_MODEL_IMPLEMENTATION`
read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it
has passed once). `RtcParticipationManager.mediaKeyStatistics()` counts sent
and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits;
and `onLeft` had gained the member count as a dependency, which rebuilt
the view model on every roster change on both paths — read through
`useLatest` now. `create-call.spec.ts` passes in Chromium on both
implementations with two view model creations each (StrictMode): the
crate path's first full call in a browser.
### S5 — hosts ☐ (component props done 2026-09-15)
- **Done:** `ElementCallProps` takes `rtcDriver` + `clientDriver` (`roomId`
optional, asserted equal); `ElementCallClientBased` takes `client` +
`roomId` and builds the two js-sdk drivers (the dev harness uses it); the
driver types and the js-sdk drivers are exported from the component index.
**Temporary:** until S4 the tree under `CallView` still runs on the
client, so `ElementCall` requires the client driver to be
`JsSdkElementCallMatrixClientDriver` (its `client`/`room` are public for
this) and throws for any other driver.
- `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`.
- `callViewModelImplementation` setting, its `config.json` pin and the
Developer Settings control removed with `createJsClientCallViewModel$`
(§5.15); Playwright runs the suite once again.
- `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 | | | | | | | ● | |
| Real backend (`RtcParticipationManager.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
---
## 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); `RtcParticipationManager` lifetime at `CallView` (§4.2); notification
timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper.
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/RtcParticipationManager.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts`;
two registered users with rust crypto in an encrypted room, `matrix_2_0` and
`compatibility`): passes end to end — transport discovery from
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`,
delayed leave, delegation via the homeserver, roster with display names,
Olm-encrypted media keys both ways with `senderCrossSigned`, leave cancelling
the delayed event, and `HomeserverUnreachable` raised and cleared across a
simulated network outage. Fixed on the way: MSC4195 `member` claims and the
homeserver route's body (`url`, `delay_timeout`; C5), the missing session
listener (C13), the legacy slot id under compatibility mode (§5.13). Noted:
`vite-plugin-node-polyfills` shadows `process`, so tests read the environment
through `node:process`; Synapse develop already proxies the MSC4195 endpoint
to lk-jwt-service; member events are Megolm-encrypted in encrypted rooms
(§5.14). Not verified here: the widget host path (Element Web) and the
authorisation-service route (the homeserver route is taken first on this
stack; it is covered by unit tests only).
**S3 (2026-09-15):** the driver-based `createCallViewModel$` landed next to
the renamed `createJsClientCallViewModel$` (shared `assembleCallViewModel`,
neutral `CallMember`/`DeclineEvent`, `LocalMedia.ts` extracted); component
props moved to the two drivers with `ElementCallClientBased` on top. Gates:
`pnpm lint`, `format:check`, `test:unit` (803), `i18n:check`,
`build:component` green. Open from this slice: reactions reader on the
participation (S4b), the `?url&no-inline` wasm asset for the component build
(the wasm is loaded lazily and inlined into the component bundle today), and
the S6 deletions.
**S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a
`RtcParticipationManager` when the crate path is selected and `ActiveCall` builds
the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
green. Not verified: a real call on the crate path through the UI (the
`pnpm backend` check covers the view model's Matrix side; the browser run is
the S5 gate).
**Logging (2026-09-16):** C14 — the crate's log lines reach the host through
a `LogSink`; Element Call routes them to its rageshake logger under
`[matrix-rtc]`, the web-test-app to the console. Verified by a unit test that
sees the crate's "session created" line through the sink.
**S4 views (2026-09-16):** room info, own profile, encryption system,
avatars, the invite and ended views and the rageshake fields read the
drivers; `CallView` requires drivers. Three room hooks deleted. Gates:
`pnpm lint`, `format:check`, `test:unit` (819), `build:component` green.
**Client-free component (2026-09-16):** `CallView` and everything under it
run without a matrix-js-sdk client on the crate path; the component passes
only its drivers. Gates: `pnpm lint`, `format:check`, `test:unit` (819),
`build:component` green. Not yet done: a browser run of the component on
the crate path (the dev harness now is that run).
**S4 done (2026-09-16):** stories, Playwright switch and CI matrix, key
statistics. `create-call.spec.ts` green on `matrix-js-sdk` and `matrix-rtc`
in Chromium against the dev server; the rest of the suite under
`matrix-rtc` is the S5 gate. Gates: `pnpm lint`, `format:check`,
`test:unit` (822), `test:storybook` (28), `build:component` green.