33 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. 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 (
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 goes in slice 5.