# From `CallViewModel` to `MatrixRTCClient` How Element Call gets from today's `CallViewModel`, which does everything, to a view model that consumes the SDK. The target shape is in [`SdkArchitecture.md`](./SdkArchitecture.md); this document is the inventory of what moves, what stays, and how far along it is. It refers to source files and symbols, so it ages with the code; the architecture document should not. ## Status | Slice | State | | -------------------------------------------------------------------------------------------------------------- | -------------------- | | 1. Scaffold: `sdk/` packaging, the interface, the harness, the smoke test | done, October 2026 | | 2. First implementation of `createMatrixRTCClient` under `sdk/src`, the media adapter, the e2e suite | done, 1 October 2026 | | 3. Move the shared modules out of `src/` into `sdk/src/`, leave re-exports behind, turn on the import boundary | next | | 4. `createCallViewModel$` consumes the client; media view models take `media$` | after 3 | | 5. Remove the re-exports, `sdk-target-based-on-call-view-model/` and the old `build:sdk` output | last | Slice 2 was built without touching `src/`, so the SDK imports its building blocks from there for now. Nothing in Element Call consumes the SDK yet. ## What the SDK still imports from `src/` These move into `sdk/src/` in slice 3, each with its tests, leaving a one-line re-export in `src` until Element Call's importers are updated. | Module | Used for | Notes for the move | | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `state/Behavior.ts`, `state/ObservableScope.ts`, `utils/observable.ts` | the reactive primitives | exported from `sdk/index.ts` already; 37 importers in `src` and `component` keep working through the re-export | | `state/SessionBehaviors.ts` | `createMemberships$`, `membershipsAndTransports$`, `createKeyRotationSuppressed$` | as is | | `state/CallViewModel/remoteMembers/Connection.ts` | `Connection`, `ConnectionState` | the SDK subclasses it as `ResolvedConnection` to keep the JWT answer; fold that in | | `state/CallViewModel/remoteMembers/ConnectionFactory.ts` | the `ConnectionFactory` interface only | `ECConnectionFactory` stays behind: it reads settings and `MediaDevices`; the SDK has `LivekitConnectionFactory` | | `state/CallViewModel/remoteMembers/ConnectionManager.ts` | `createConnectionManager$` | as is | | `state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts` | `createRemoteMatrixLivekitMembers$`, the membership-to-participant match | the `TaggedParticipant` type goes; the SDK maps the result to `RTCMember` | | `state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts` | `createRoomMembers$`, `createMatrixMemberMetadata$` | depends on `utils/displayname.ts`, which moves with it | | `state/CallViewModel/localMember/HomeserverConnected.ts` | `createHomeserverConnected$` | reads `Config.get()` unless the grace period is passed; the SDK passes it | | `state/CallViewModel/localMember/LocalTransport.ts` | the `LocalTransport` type only | `getLocalTransport` reads config and settings; the SDK has its own in `session/LocalTransport.ts` | | `state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts` | discovery through `rtc/transports` | takes a resolved config for the fallback url; the SDK passes `DEFAULT_CONFIG`, which has none | | `state/CallViewModel/localMember/LocalMember.ts` | `observeSharingScreen$` only | the SDK has its own local member; the rest stays until slice 4 | | `livekit/openIDSFU.ts` | `getSFUConfigWithOpenID`, `SFUConfig` | reads `Config.get()` only on the delegated delayed leave path, which the SDK does not use | | `livekit/options.ts` | `buildLiveKitOptions` | takes the media quality config as a parameter; `getLiveKitOptions` stays behind | | `e2ee/matrixKeyProvider.ts`, `e2ee/e2eeType.ts`, `e2ee/sharedKeyManagement.ts` | the key provider, `E2eeType`, the `EncryptionSystem` type | `sharedKeyManagement.ts` also holds React hooks; split the type out | | `config/ConfigOptions.ts` | `MatrixRTCMode`, `DEFAULT_CONFIG` for the timings | the enum moves; the timings become an option (see the architecture document's open decisions) | | `utils/errors.ts` | the error classes the modules above throw | they carry translation keys, so they stay; the SDK wraps them as `MatrixRTCError` with `cause` | Rewritten in the SDK rather than moved, because the originals read settings, config, analytics or the host bridge: the local member (`session/LocalMember.ts`), the publisher (`session/Publisher.ts`), the connection factory and LiveKit room options (`session/ConnectionFactory.ts`), transport discovery (`session/LocalTransport.ts`), the join (`session/joinJsSdkSession.ts`) and the key provider selection (`session/KeyProvider.ts`). ## What becomes what in `createCallViewModel$` Everything below `createCallViewModel$` that is not presentation and not in the call-specific table of the architecture document. Grouped by what it is today in `src/state/CallViewModel/CallViewModel.ts`. | Binding today | In the SDK | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `livekitKeyProvider` via `getE2eeKeyProvider` | internal, keyed off `options.encryptionSystem` | | `matrixRTCMode` resolution from `Config.get()` | an input; resolution stays in the caller | | `matrixRTCSession` parameter | internal: the session manager for the slot, `getRoomSession(room)` | | `memberships$`, `membershipsAndTransports` | internal; surfaces as `RTCMember.transport$` | | `ownMembershipIdentity` | internal; surfaces as `RTCMember.id` | | `localTransport$` via `getLocalTransport` | internal | | `connectionFactory`, `connectionManager` | internal; surfaces as `connectedTransports$` | | `remoteMatrixLivekitMembers$` | `remoteMembers$` | | `localMembership` via `createLocalMembership$`, `enterRTCSession`, `Publisher`, `delayId$` | internal; surfaces as `join`, `leave`, `status$`, `connected$`, `reconnecting$`, the screen share fields | | `localRtcMembership$`, `localMatrixLivekitMember$` | `localMember$` | | `matrixLivekitMembers$`, `participantCount$` | `memberCount$` | | `matrixRoomMembers$`, `matrixMemberMetadataStore` | `RTCMember.displayName$` / `avatarUrl$` | | `allConnections$` | `connectedTransports$` | | `livekitRoomItems$` | dropped; audio renders from `remoteMembers$[].media$` | | `keyRotationSuppressed$` | same | | `sharingScreen$`, `toggleScreenSharing` | `localMember$.sharingScreen$`, `.toggleScreenSharing` | | `errors$` and the `fatalError$` mapping | `fatalError$`, as `MatrixRTCError` | | `join`, `leave`, `connected$`, `reconnecting$`, `screenShareError$`, `dismissScreenShareError` | same names on `MatrixRTCClient` / `LocalRTCMember` | | `callIntent` option, `updateCallIntent` from the camera | `applicationData["m.call.intent"]`; the camera sync is still in the SDK, see the open decisions | Changes to the call sites that the rename brings: `localMember$` and `remoteMembers$` replace `localMatrixLivekitMember$` and `remoteMatrixLivekitMembers$`; `connection$` on a member goes away, since the view model only ever used `livekitRoom` (for `waitingForMedia$` and encryption errors, both on `MemberMedia` now) and `transport.livekit_service_url` (now `transport$.id`); `connectedTransports$` replaces `allConnections$`, since the developer panel only needed which transports exist and whether they are up. ## What stays in `CallViewModel` Everything in the call-specific table, plus everything that takes `userMedia$` or a UI input and turns it into something to draw: - Media view models (`createWrappedUserMedia` and below), built from `remoteMembers$` / `localMember$` instead of participants. - `CallNotificationLifecycle.ts` in full: `ringAttempts$`, `autoLeave$`, `ringingMedia$`, `ringingStatusLocation`. - `leave$` (= `autoLeave$` + `hangup()` + `hostBridge.hangUp$`), `hangup()`. - `screenShares$`. - Sounds: `joinSoundEffect$`, `leaveSoundEffect$`, `newHandRaised$`, `newScreenShare$`, `audibleReactions$`, `visibleReactions$`. - `handsRaised$`, `reactions$`, until they move onto `RTCMember`. - All layout: `spotlightSpeaker$`, `grid$`, `spotlight$`, `pip*`, `windowMode$`, `layoutSwitchVm`, every `*LayoutMedia$`, `layout$`, `visibleTiles$`, `tileStoreGeneration$`, `overflowing$`, `spotlightExpanded$`. - Chrome: `showHeader$`, `showFooter$`, `edgeToEdge$`, `showModals$`, `settingsOpen$`, `showSpotlightIndicators$`, `showSpeakingIndicators$`, `showNameTags$`, tap and hover subjects. - Audio routing: `earpieceMode$`, `audioOutputSwitcher$`, which read `MediaDevices`. - `CallViewModelOptions` keeps `hostBridge`, `controlledAudioDevices`, `header`, `showControls`, `hideScreensharing`, `waitForCallPickup`, `autoLeaveWhenOthersLeft`, `windowSize$`, `toggleScreensharing` (test override), and forwards `encryptionSystem`, `matrixRTCMode`, `sendNotificationType` and `applicationData` to `MatrixRTCClientOptions`. ## The media view models and the participant The goal of slice 4 on the media side: nothing in Element Call imports `Participant`, `RemoteParticipant`, `LocalParticipant`, `TrackPublication`, `Track` or `TrackReference`. This is where the participant flows today, and what each use becomes. ### Where the participant flows today Start: `remoteMatrixLivekitMembers$` in `CallViewModel.ts`, built by `createRemoteMatrixLivekitMembers$` in `src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts`, as `participant: { type: "remote"; value$: Behavior }`. Consumers, in order of depth: 1. `MatrixLivekitMembers.ts` matches `participant.identity` against `membership.rtcBackendIdentity`; logs `participant.sid`. 2. `CallViewModel.ts` `livekitRoomItems$` reads `isLocal`, `identity`, hands `connection.livekitRoom` plus identities to `LivekitRoomAudioRenderer`. 3. `CallViewModel.ts` `userMedia$` passes the whole `TaggedParticipant` into `createWrappedUserMedia`. 4. `src/state/media/WrappedUserMediaViewModel.ts`: `observeParticipantEvents(p, TrackPublished, TrackUnpublished, LocalTrackPublished, LocalTrackUnpublished)` then `p.isScreenShareEnabled` to spawn screen share view models. 5. `src/state/media/UserMediaViewModel.ts`: `observeParticipantMedia(p)` for `microphoneTrack?.isMuted` / `cameraTrack?.isMuted`; `IsSpeakingChanged` → `p.isSpeaking`; `observeRtpStreamStats$(p, source, type)`. 6. `src/state/media/MemberMediaViewModel.ts`: `observeTrackReference$(p, source)` for audio and video; `publication.isEncrypted`; `p.isLocal`; `p.identity` matched inside `RoomEvent.EncryptionError` messages; inbound RTP frame counters. 7. `src/state/media/RemoteUserMediaViewModel.ts`, `RemoteScreenShareViewModel.ts`: `p.setVolume(v)` / `p.setVolume(v, ScreenShareAudio)`; `participant === null` means "waiting for media". 8. `src/state/observeTrackReference.ts`: `p.getTrackPublication(source)` wrapped as `{ participant, publication, source }`. 9. `src/state/media/observeRtpStreamStats.ts`: `publication.track` must be `RemoteTrack | LocalTrack`, then `track.getRTCStatsReport()`. 10. Views: `src/tile/MediaView.tsx` renders `` from `@livekit/components-react`; `src/livekit/MatrixAudioRenderer.tsx` uses `useTracks` on the LiveKit room, filters on `ref.participant.isLocal` / `.identity`, and calls `RemoteAudioTrack.setAudioContext` / `setWebAudioPlugins`. Local-only extras, same shape but on `LocalParticipant` (`localMember/LocalMember.ts`, `Publisher.ts`, `src/state/media/LocalUserMediaViewModel.ts`): `trackPublications`, `audioTrackPublications`, `setScreenShareEnabled`, `setMicrophoneEnabled`, `setCameraEnabled`, `enableCameraAndMicrophone`, `isMicrophoneEnabled`, `isCameraEnabled`, `unpublishTrack`, and on the camera `LocalVideoTrack`: `facingModeFromLocalTrack`, `restartTrack`, `mediaStreamTrack`, `TrackEvent.Restarted` / `Muted`. ### Participant API actually used, and what covers it | LiveKit surface | Used for | Used in | Now | | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------- | ------------------------------------------------ | | `identity` | match to `rtcBackendIdentity`; audio renderer allow-list; E2EE error matching; logs | 1, 2, 6, 10 | matched inside the SDK; `RTCMember.id` | | `sid` | logs only | 1 | SDK logs | | `isLocal` | skip local audio; encryption status short-circuit | 2, 6, 10 | `MemberMedia.local` | | `isSpeaking` + `IsSpeakingChanged` | `speaking$` | 5 | `MemberMedia.speaking$` | | `isScreenShareEnabled` + publish events | spawn screen share VM | 4 | `MemberMedia.screenShareEnabled$` | | `observeParticipantMedia(p)` → `microphoneTrack?.isMuted`, `cameraTrack?.isMuted` | `audioEnabled$`, `videoEnabled$` | 5 | `microphone$` / `camera$` → `muted$` | | `getTrackPublication(source)` for the four sources | `TrackReference` for video, encryption, stats, screen share audio presence | 6, 7, 8 | the four track behaviors | | `publication.isEncrypted` | `unencryptedWarning$` | 6 | `MediaTrack.encrypted$` | | `publication.track.getRTCStatsReport()` | `audioStreamStats$`, `videoStreamStats$`, "receiving okay" heuristic | 5, 6, 9 | `MediaTrack.stats$` | | `setVolume(v)`, `setVolume(v, ScreenShareAudio)` | `VolumeControls` sink | 7 | `AudioMediaTrack.setVolume` | | `TrackReference` → `` | render video | 10 | `MediaTrack.attach` on a `