Add rust based createCallViewModel

This commit is contained in:
Timo K.
2026-09-16 13:30:05 +02:00
parent 36ed60d95c
commit 8506c098e9
47 changed files with 5165 additions and 1428 deletions
+125 -46
View File
@@ -16,8 +16,10 @@ 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–C8; C1 was reverted in favour of slot opening; C11 and C12 done;
C9 and C10 are pending) sit uncommitted in
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
@@ -136,8 +138,9 @@ membership, roster, encryption, impairments} | Leaving`.
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, StickyEvents, StateEvents}` today; `StickyEvents`
is removed by C9, leaving `Off` (spec MSC4143) and `StateEvents` (MSC3401).
- `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.
@@ -157,20 +160,21 @@ membership, roster, encryption, impairments} | Leaving`.
**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 (`CallParticipation.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 | 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. |
| 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_url`s. | 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. | **Remove the mode.** Delete `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. | Keep the MSC4153 verdict of the accepted key per member and expose 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. |
| # | 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 (`CallParticipation.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_url`s. | 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`; `CallParticipation.session$` is fed from it. |
No crate work is deferred: `update_application` is C11.
@@ -401,9 +405,8 @@ documented as such in `docs/`: `delayed_leave.restart_ms`,
`FfiParticipationConfig.useKeyDelayMs`.
Compat: `MatrixRTCMode.Compatibility → StateEvents`, `Matrix_2_0 → Off` (spec
MSC4143: sticky member events, slots, the spec key message). Until C9 lands the
code still maps `Matrix_2_0` to the crate's `StickyEvents`; `compatForMode` is
the one place that changes.
MSC4143: sticky member events, slots, the spec key message), in
`compatForMode`.
### 4.4 React tree
@@ -485,8 +488,8 @@ Differences from the draft, all required by the widget client:
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). Until C5 lands the
driver still carries Element Call's probe and JWT delegation; both go then.
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"`,
@@ -530,8 +533,9 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
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). Element Call keeps no probe and no
delegation code.
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
@@ -539,6 +543,20 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
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`); `CallParticipation` 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.
---
@@ -600,26 +618,50 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
manager; fallback transport when the host throws or advertises none.
### S3 — view model, four slices ☐
### S3 — view model, four slices ☑ (side by side with the js-sdk view model)
- **S3a** `Connection`/`ConnectionManager`/`ConnectionFactory` keyed by
`serviceUrl`, fed by `Behavior<{ serviceUrl, wsUrl, jwt, expiresAtTs }[]>`,
with a temporary adapter from today's `SFUConfig` so `openIDSFU` stays
until S3c. Introduces `mockCallParticipation()` and `mockFfiMembership()` in
`src/utils/test.ts`.
- **S3b** `MatrixLivekitMembers` + `MatrixKeyProvider` on `FfiMembership` /
key changes, with an adapter from `CallMembership` for the still-js-sdk
`memberships$`.
- **S3c** `LocalMember` on `callParticipation.join/leave` + status-derived
connectivity; delete `LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`.
- **S3d** `createCallViewModel$` signature, `CallNotificationLifecycle`,
`MatrixMemberMetadata`, `SessionBehaviors.ts` and
`useMatrixRTCSessionMemberships.ts` deleted, `ReactionsReader` on
`participation` + `TimelineDriver`; test kit swapped
(`MockRTCSession`/`mockRtcMembership` deleted). To stay green before S5,
`CallView` builds a `JsSdkMatrixDriver` from its existing `client` /
`rtcSession.room` props as a temporary shim.
- **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 ☐
@@ -635,9 +677,17 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
`ReactionsReader` keyed by `memberId`, `CallNotificationLifecycle` sending
through the driver; tests incl. a membership re-send mid-call.
### S5 — hosts ☐
### S5 — hosts ☐ (component props done 2026-09-15)
- `component/index.tsx`, `component/matrix-js-sdk.ts`, `component/package.json`
- **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`.
@@ -691,6 +741,7 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
| four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
| Real backend (`CallParticipation.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
---
@@ -728,3 +779,31 @@ 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/CallParticipation.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/CallParticipation.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.