Files
element-call-Github/element-call-oxidation-plan.md
2026-09-17 21:03:41 +02:00

116 KiB
Raw Permalink Blame History

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_urls. 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

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 Behaviors internally (src/driver/observe.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:

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

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.