Files
element-call-Github/sdk/SdkMigration.md
T
2026-10-08 13:53:57 +02:00

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

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 (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<RemoteParticipant | null> }. 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 <VideoTrack trackRef={video}> 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 → <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 from camera$.
  • unencryptedWarning$ = any of the four tracks with encrypted$ === false.
  • encryptionStatus$ = encryptionError$ plus a "frames decoded" check on camera$.stats$ / microphone$.stats$; same logic, no participant.
  • screenShares$ in WrappedUserMediaViewModel keys off screenShareEnabled$.
  • VolumeControls sink = microphone$.value?.setVolume / screenShareAudio$.value?.setVolume.
  • waitingForMedia$ = transport present and media === null, unchanged semantics; TaggedParticipant goes away.
  • video$: Behavior<TrackReference | undefined> on BaseMemberMediaViewModel becomes Behavior<VideoMediaTrack | undefined>; MediaView replaces <VideoTrack trackRef> with a <video> plus attach / detach in an effect, keeping the onResize callback for the aspect ratio. VideoPreview.tsx already follows this pattern.
  • LivekitRoomAudioRenderer stops using useTracks on the room and renders one <audio> per microphone$ / screenShareAudio$ across remoteMembers$. That removes the "no matching matrix call member" warning path, since an unmatched track can no longer be seen.
  • The showConnectionStats setting gates the subscription to stats$ in the view model; the SDK polls only while subscribed.
  • Stories and tests mock attach by setting srcObject from canvas.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: MuteStates
  • options: 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.