- session -> matrixrtc (folder) - move MatrixRtcClient to top level of sdk - RtcTransport -> Transport - backend -> media-backend
38 KiB
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; 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. Copy the shared modules into sdk/src/ with their tests, drop every src/ import, turn on the import boundary |
done, 2 October 2026 |
4. createCallViewModel$ consumes the client; media view models take media$; Element Call imports the primitives and enums from the SDK and deletes its own copies |
done, 2 October 2026 |
5. sendData/data$ on the client; the legacy bundle sdk-target-based-on-call-view-model/ is rebuilt on the SDK with its interface unchanged, since it is in production use |
done, 2 October 2026 |
12. The media backend seam: everything LiveKit behind MediaBackend in sdk/src/media-backend/livekit/, the backend option, the lint guard; see MediaBackendPlan.md |
done, 4 October 2026 |
Element Call now consumes the SDK. InCallView creates the client from the
configuration, settings and URL parameters, and hands it to createCallViewModel$,
which keeps what makes the session a call. The modules below were deleted from
src/ once the SDK had its copies, and Behavior, ObservableScope, the
observable operators, E2eeType, EncryptionSystem and MatrixRTCMode are
re-exported from the SDK by their old paths so the rest of Element Call did not
have to change in the same step. The view model tests drive a fake
MatrixRTCClient (mockMatrixRTCClient in src/utils/test.ts) instead of mocked
LiveKit rooms; the tests of the deleted modules live on in sdk/src.
What still binds Element Call to LiveKit, as slices
Element Call still imports the LiveKit packages in a few places. Each is a gap in
the SDK's API rather than something to rename: the import goes away when the SDK
grows the piece. The names Element Call owes to the outside stay as they are: the
livekit.livekit_service_url key in config.json, the persisted setting keys
extended-livekit-logs and custom-livekit-url, the Posthog fields
callBackend and callReconnectingCountLivekit, the rageshake field
call_backend, and the translated error strings.
| Slice | Where it shows today |
|---|---|
6. Preview before join(): an SDK API that creates the local tracks for the lobby and hands them out as MediaTracks, so the preview renders like MediaView |
LobbyView (usePreviewTracks), VideoPreview (LocalVideoTrack, facingModeFromLocalTrack) |
7. A transport-neutral processor type on LocalMediaInputs.videoProcessor$, so the blur feature in src/tracks/ no longer types against LiveKit |
TrackProcessorContext, BlurBackgroundTransformer, the TrackProcessor type in api.ts |
| 8. Device enumeration without LiveKit's helpers, on the browser API or an SDK utility | MediaDevices (createMediaDeviceObserver), CallView (Room.getLocalDevices) |
9. A logging option on the client: log level and a sink for the transport's logs, so the rageshake wiring leaves initializer.tsx |
initializer.tsx, the extended-livekit-logs setting |
10. A capabilities field: output selection and background processors, asked of the SDK rather than of LiveKit |
CallFooterViewModel (supportsAudioOutputSelection, supportsBackgroundProcessors) |
11. Element Call's error and analytics types on the SDK's: DisconnectReason and the error classes, and a rename of the two Livekit* error classes on both sides at once |
PosthogEvents (DisconnectReason), errors.ts (ConnectionError, LivekitConnectionError) |
What was copied from src/, and what changed on the way
All of these are gone from src/ as of slice 4; the table records where they went.
Each module came with its tests (sdk/src/utils/test.ts and test-fixtures.ts are
the subset of src/utils/test.ts and test-fixtures.ts they need). In slice 4
Element Call switches to the SDK's copy and deletes its own.
In src/ |
In sdk/src/ |
What changed |
|---|---|---|
state/Behavior.ts, state/ObservableScope.ts, utils/observable.ts |
reactive/ |
as is |
state/SessionBehaviors.ts |
session/SessionBehaviors.ts |
as is |
state/CallViewModel/remoteMembers/Connection.ts |
session/Connection.ts |
throws MatrixRTCErrors; the SDK still subclasses it as ResolvedConnection to keep the JWT answer, to be folded in |
state/CallViewModel/remoteMembers/ConnectionFactory.ts |
session/ConnectionFactory.ts |
the interface only; ECConnectionFactory reads settings and MediaDevices, the SDK has LivekitConnectionFactory |
state/CallViewModel/remoteMembers/ConnectionManager.ts |
session/ConnectionManager.ts |
as is |
state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts |
session/MatrixLivekitMembers.ts |
as is; the SDK maps its result to RTCMember, TaggedParticipant is internal |
state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts, utils/displayname.ts |
session/MatrixMemberMetadata.ts, utils/displayname.ts |
as is |
state/CallViewModel/localMember/HomeserverConnected.ts |
session/HomeserverConnected.ts |
the grace period defaults to defaultSessionTimings instead of Config.get() |
state/CallViewModel/localMember/LocalTransport.ts |
session/LocalTransport.ts |
the type and isLocalTransport; discovery without the custom-url setting |
state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts |
session/RtcTransportAutoDiscovery.ts |
takes fallbackTransportUrl instead of a resolved config |
state/CallViewModel/localMember/LocalMember.ts |
session/LocalMember.ts |
rewritten; only observeSharingScreen$ was copied |
livekit/openIDSFU.ts |
session/openIDSFU.ts |
the delegated leave timeout is a parameter (delayTimeoutMs) instead of Config.get() |
livekit/options.ts |
session/livekitOptions.ts |
buildLiveKitOptions(mediaQuality?) only; getLiveKitOptions and the Config singleton are gone |
e2ee/matrixKeyProvider.ts |
session/MatrixKeyProvider.ts |
as is |
e2ee/e2eeType.ts, the EncryptionSystem type from e2ee/sharedKeyManagement.ts |
encryption.ts |
the React hooks stayed behind |
MatrixRTCMode, the timings and media quality types from config/ConfigOptions.ts |
config.ts |
defaultSessionTimings and defaultMediaQuality replace DEFAULT_CONFIG |
utils/errors.ts |
errors.ts |
MatrixRTCError with code and category replaces ElementCallError; messages are plain English, the host translates by code |
doNetworkOperationWithRetry from utils/matrix.ts |
utils/network.ts |
as is |
Rewritten in the SDK rather than copied, 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 (
createWrappedUserMediaand below), built fromremoteMembers$/localMember$instead of participants. CallNotificationLifecycle.tsin full:ringAttempts$,autoLeave$,ringingMedia$,ringingStatusLocation.leave$(=autoLeave$+hangup()+hostBridge.hangUp$),hangup().screenShares$.- Sounds:
joinSoundEffect$,leaveSoundEffect$,newHandRaised$,newScreenShare$,audibleReactions$,visibleReactions$. handsRaised$,reactions$, until they move ontoRTCMember.- 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 readMediaDevices. CallViewModelOptionskeepshostBridge,controlledAudioDevices,header,showControls,hideScreensharing,waitForCallPickup,autoLeaveWhenOthersLeft,windowSize$,toggleScreensharing(test override), and forwardsencryptionSystem,matrixRTCMode,sendNotificationTypeandapplicationDatatoMatrixRTCClientOptions.
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<RemoteParticipant | null> }.
Consumers, in order of depth:
MatrixLivekitMembers.tsmatchesparticipant.identityagainstmembership.rtcBackendIdentity; logsparticipant.sid.CallViewModel.tslivekitRoomItems$readsisLocal,identity, handsconnection.livekitRoomplus identities toLivekitRoomAudioRenderer.CallViewModel.tsuserMedia$passes the wholeTaggedParticipantintocreateWrappedUserMedia.src/state/media/WrappedUserMediaViewModel.ts:observeParticipantEvents(p, TrackPublished, TrackUnpublished, LocalTrackPublished, LocalTrackUnpublished)thenp.isScreenShareEnabledto spawn screen share view models.src/state/media/UserMediaViewModel.ts:observeParticipantMedia(p)formicrophoneTrack?.isMuted/cameraTrack?.isMuted;IsSpeakingChanged→p.isSpeaking;observeRtpStreamStats$(p, source, type).src/state/media/MemberMediaViewModel.ts:observeTrackReference$(p, source)for audio and video;publication.isEncrypted;p.isLocal;p.identitymatched insideRoomEvent.EncryptionErrormessages; inbound RTP frame counters.src/state/media/RemoteUserMediaViewModel.ts,RemoteScreenShareViewModel.ts:p.setVolume(v)/p.setVolume(v, ScreenShareAudio);participant === nullmeans "waiting for media".src/state/observeTrackReference.ts:p.getTrackPublication(source)wrapped as{ participant, publication, source }.src/state/media/observeRtpStreamStats.ts:publication.trackmust beRemoteTrack | LocalTrack, thentrack.getRTCStatsReport().- Views:
src/tile/MediaView.tsxrenders<VideoTrack trackRef={video}>from@livekit/components-react;src/livekit/MatrixAudioRenderer.tsxusesuseTrackson the LiveKit room, filters onref.participant.isLocal/.identity, and callsRemoteAudioTrack.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 → <VideoTrack> |
render video | 10 | MediaTrack.attach on a <video> |
room useTracks + RemoteAudioTrack.setAudioContext/setWebAudioPlugins |
render audio with earpiece pan/gain | 10 | one <audio> per audio track, setAudioContext |
room RoomEvent.EncryptionError with identity + "MissingKey"/"InvalidKey" |
encryptionStatus$ |
6 | MemberMedia.encryptionError$ |
Everything else in the media view models (observeSpeaker$, VolumeControls,
SortingBin, hand raise, reactions, display name, avatar, aspect ratio) already
works on plain booleans and observables and does not change.
How the call sites map onto MemberMedia
audioEnabled$=microphone$ → track?.muted$ === false;videoEnabled$likewise fromcamera$.unencryptedWarning$= any of the four tracks withencrypted$ === false.encryptionStatus$=encryptionError$plus a "frames decoded" check oncamera$.stats$/microphone$.stats$; same logic, no participant.screenShares$inWrappedUserMediaViewModelkeys offscreenShareEnabled$.VolumeControlssink =microphone$.value?.setVolume/screenShareAudio$.value?.setVolume.waitingForMedia$= transport present andmedia === null, unchanged semantics;TaggedParticipantgoes away.video$: Behavior<TrackReference | undefined>onBaseMemberMediaViewModelbecomesBehavior<VideoMediaTrack | undefined>;MediaViewreplaces<VideoTrack trackRef>with a<video>plusattach/detachin an effect, keeping theonResizecallback for the aspect ratio.VideoPreview.tsxalready follows this pattern.LivekitRoomAudioRendererstops usinguseTrackson the room and renders one<audio>permicrophone$/screenShareAudio$acrossremoteMembers$. That removes the "no matching matrix call member" warning path, since an unmatched track can no longer be seen.- The
showConnectionStatssetting gates the subscription tostats$in the view model; the SDK polls only while subscribed. - Stories and tests mock
attachby settingsrcObjectfromcanvas.captureStream()on the element, or by doing nothing and asserting the call.
Packaging, as it was planned
sdk/ mirrors component/ file for file. The table that drove the scaffold, kept
for the record:
component/ |
sdk/ |
Differences |
|---|---|---|
package.json (@element-hq/element-call-component) |
package.json (@element-hq/matrixrtc-sdk) |
peers: matrix-js-sdk, livekit-client, rxjs. No React, no Compound. exports: "." only. No ./style.css, no sideEffects. Same prepare. |
pnpm-workspace.yaml (marker, autoInstallPeers: false) |
same | none |
tsconfig.build.json |
same | include: ["./index.ts"] |
index.tsx + api.ts |
index.ts + src/api.ts |
one entry; no separate types entry until a host asks |
host.ts, localization.ts, ElementCall.module.css, build/scopeStylesToRoot.ts |
none | no host bridge, no i18n, no styles |
dev/ on port 3001 |
dev/ on port 3002 via vite-sdk-dev.config.ts |
plain DOM |
vite-component.config.ts |
vite-sdk.config.ts |
vitePluginsConfig({ mode, html: false }), outDir: "sdk/dist", externals = js-sdk and LiveKit entries plus rxjs |
root scripts build:component* |
build:sdk* |
build:sdk:types = tsc -p sdk/tsconfig.build.json |
root tsconfig.json include |
adds ./sdk/**/*.ts |
|
scripts/check-component-externals.mjs |
parameterised by config; sdk added to the sources, sdk/dev excluded |
|
knip.ts |
vite-sdk.config.ts, vite-sdk-dev.config.ts, sdk/index.ts as entry |
|
vitest.config.ts unit project |
adds sdk/**/*.test.ts |
|
package.json lint:oxlint paths |
adds sdk |
Why not a pnpm workspace package yet: the root has no packages: in
pnpm-workspace.yaml, so a workspace split means restructuring the app, the
component's prepare trick, CI and the lockfile at the same time as moving the
code, and dev-time HMR across workspace packages needs the same source alias
anyway. It is the right end state if the SDK gets a release cadence of its own.
Reference: the CallViewModel public API before the migration
Source: src/state/CallViewModel/CallViewModel.ts. Only consumer outside tests:
src/room/InCallView.tsx. A snapshot, so that the split can be checked against
what was there.
Factory
createCallViewModel$(scope, matrixRTCSession, matrixRoom, mediaDevices, muteStates, options, handsRaisedSubject$, reactionsSubject$, trackProcessorState$): CallViewModel
scope: ObservableScope: ending the scope is one of the two ways to leave.matrixRTCSession: MatrixRTCSession,matrixRoom: MatrixRoom: one room per view model.mediaDevices: MediaDevices,muteStates: MuteStatesoptions: CallViewModelOptions(below)handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>reactionsSubject$: Observable<Record<string, ReactionInfo>>trackProcessorState$: Behavior<ProcessorState>
Helper: callViewModelOptionsFromParams(params: UrlParams) picks
controlledAudioDevices, header, showControls, hideScreensharing,
sendNotificationType, callIntent out of the URL params. autoLeaveWhenOthersLeft
and waitForCallPickup are left to the caller.
CallViewModelOptions
| Field | Type | Note |
|---|---|---|
encryptionSystem |
EncryptionSystem |
required |
hostBridge? |
HostBridge |
host can hang up / observe join+leave; default none |
controlledAudioDevices? |
boolean |
host controls audio output devices |
header? |
HeaderStyle |
default Standard |
showControls? |
boolean |
default true |
hideScreensharing? |
boolean |
default false |
sendNotificationType? |
RTCNotificationType |
notification sent on join |
callIntent? |
RTCCallIntent |
|
autoLeaveWhenOthersLeft? |
boolean |
|
waitForCallPickup? |
boolean |
telephone-style ringing UI |
windowSize$ |
Behavior<{width; height}> |
required; drives WindowMode |
matrixRTCMode? |
MatrixRTCMode |
MatrixRTC version / compat mode |
livekitRoomFactory?, connectionState$?, localTransport?, connectionFactory?, toggleScreensharing? |
test overrides |
The interface, grouped
Lifecycle / connection (to the client): join(), leave(), hangup(),
leave$: Observable<"user" | AutoLeaveReason>, autoLeave$, connected$,
reconnecting$, allConnections$: Behavior<ConnectionManagerData>,
fatalError$: Behavior<ElementCallError | null>, keyRotationSuppressed$.
Members / participants (to the client): participantCount$,
localMatrixLivekitMember$, remoteMatrixLivekitMembers$, livekitRoomItems$,
handsRaised$, reactions$ (keyed by ${userId}:${deviceId}).
Screen sharing (capability to the client, error UI stays): toggleScreenSharing,
sharingScreen$, screenShareError$, dismissScreenShareError().
Ringing (stays): ringingVm$, ringingStatusLocation.
Audio routing (stays): earpieceMode$, audioOutputSwitcher$.
Sounds and transient events (stay): joinSoundEffect$, leaveSoundEffect$,
newHandRaised$, newScreenShare$, audibleReactions$, visibleReactions$,
MAX_PARTICIPANT_COUNT_FOR_SOUND = 8, THROTTLE_SOUND_EFFECT_MS = 500.
Layout and tiles (stay): layout$, tileStoreGeneration$,
showSpotlightIndicators$, showSpeakingIndicators$, showNameTags$,
spotlightExpanded$, toggleSpotlightExpanded$, layoutSwitchVm$.
Chrome visibility and UI interaction (stay): showHeader$, showFooter$,
edgeToEdge$, overflowing$, showModals$, settingsOpen$, setSettingsOpen$,
tapScreen(), tapControls(), hoverScreen(), unhoverScreen().
Pending rename noted in the source
A TODO above CallViewModelOptions proposes: member/membership → rtcMember,
participant → livekitParticipant, matrixLivekitItem → callMember; and in the
js-sdk callMembership → rtcMembership. The SDK's vocabulary (architecture
document, "Vocabulary") settles the first three; the js-sdk rename is still open.
Build targets before the migration
vite-sdk.config.ts (pnpm build:sdk), vite-component.config.ts,
vite-embedded.config.ts, vite.config.ts. The old SDK target
(sdk-target-based-on-call-view-model/) is the one whose dependency direction the
migration flips. It is in production use, so slice 5 rebuilt it on the SDK instead of
removing it: createMatrixRTCSdk keeps its signature, join/leave/stop,
connected$, data$ ({ rtcBackendIdentity, data } on the matrixRTC topic),
sendData, sendRoomMessage, localMember$ and remoteMembers$ keep their shapes.
The one break is inside the member objects: connection and participant are
always null now, because the SDK does not hand out LiveKit objects; membership
is unchanged. The bundle is removed once its consumers use the package directly.